@zereight/mcp-gitlab 2.0.23 → 2.0.25
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 +4 -1
- package/build/index.js +240 -75
- package/build/schemas.js +60 -1
- package/build/test/test-list-project-members.js +132 -0
- package/build/test/test-merge-request-approvals.js +187 -0
- package/build/test/test-mr-diffs-filter.js +132 -0
- package/build/test/utils/mock-gitlab-server.js +54 -0
- package/package.json +6 -5
- package/build/test/readonly-mcp-tests.js +0 -381
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ GitLab MCP(Model Context Protocol) Server. **Includes bug fixes and improvements
|
|
|
10
10
|
|
|
11
11
|
## Usage
|
|
12
12
|
|
|
13
|
-
### Using with Claude
|
|
13
|
+
### Using with Claude Code, Codex, Antigravity, OpenCode, Copilot, Cline, Roo Code, Cursor, Kilo Code, Amp Code
|
|
14
14
|
|
|
15
15
|
When using with the Claude App, you need to set up your API key and URLs directly.
|
|
16
16
|
|
|
@@ -545,6 +545,9 @@ The token is stored per session (identified by `mcp-session-id` header) and reus
|
|
|
545
545
|
93. `delete_release` - Delete a release from a GitLab project (does not delete the associated tag)
|
|
546
546
|
94. `create_release_evidence` - Create release evidence for an existing release (GitLab Premium/Ultimate only)
|
|
547
547
|
95. `download_release_asset` - Download a release asset file by direct asset path
|
|
548
|
+
96. `approve_merge_request` - Approve a merge request (requires appropriate permissions)
|
|
549
|
+
97. `unapprove_merge_request` - Unapprove a previously approved merge request
|
|
550
|
+
98. `get_merge_request_approval_state` - Get the approval state of a merge request including approval rules and who has approved
|
|
548
551
|
<!-- TOOLS-END -->
|
|
549
552
|
|
|
550
553
|
</details>
|
package/build/index.js
CHANGED
|
@@ -26,6 +26,7 @@ import { AsyncLocalStorage } from "node:async_hooks";
|
|
|
26
26
|
import express from "express";
|
|
27
27
|
import fetchCookie from "fetch-cookie";
|
|
28
28
|
import fs from "node:fs";
|
|
29
|
+
import os from "node:os";
|
|
29
30
|
import { HttpProxyAgent } from "http-proxy-agent";
|
|
30
31
|
import { HttpsProxyAgent } from "https-proxy-agent";
|
|
31
32
|
import nodeFetch from "node-fetch";
|
|
@@ -49,7 +50,7 @@ GitLabDiscussionNoteSchema, // Added
|
|
|
49
50
|
GitLabDiscussionSchema,
|
|
50
51
|
// Draft Notes Schemas
|
|
51
52
|
GitLabDraftNoteSchema, GitLabForkSchema, GitLabIssueLinkSchema, GitLabIssueSchema, GitLabIssueWithLinkDetailsSchema, GitLabMarkdownUploadSchema, GitLabMergeRequestSchema, GitLabMilestonesSchema, GitLabNamespaceExistsResponseSchema, GitLabNamespaceSchema, GitLabPipelineJobSchema, GitLabPipelineSchema, GitLabPipelineTriggerJobSchema, GitLabProjectMemberSchema, GitLabProjectSchema, GitLabReferenceSchema, GitLabRepositorySchema, GitLabSearchResponseSchema, GitLabTreeItemSchema, GitLabTreeSchema, GitLabUserSchema, GitLabUsersResponseSchema, GitLabWikiPageSchema, GroupIteration, ListCommitsSchema, ListDraftNotesSchema, ListGroupIterationsSchema, ListGroupProjectsSchema, ListIssueDiscussionsSchema, ListIssueLinksSchema, ListIssuesSchema, ListLabelsSchema, ListMergeRequestDiffsSchema, // Added
|
|
52
|
-
ListMergeRequestDiscussionsSchema, ListMergeRequestsSchema, ListMergeRequestVersionsSchema, GetMergeRequestVersionSchema, GitLabMergeRequestVersionSchema, GitLabMergeRequestVersionDetailSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelinesSchema, ListPipelineTriggerJobsSchema, ListProjectMembersSchema, ListProjectMilestonesSchema, ListProjectsSchema, ListWikiPagesSchema, MarkdownUploadSchema, DownloadAttachmentSchema, MergeMergeRequestSchema, MyIssuesSchema, PaginatedDiscussionsResponseSchema, PromoteProjectMilestoneSchema, PublishDraftNoteSchema, PlayPipelineJobSchema, PushFilesSchema, RetryPipelineJobSchema, RetryPipelineSchema, SearchRepositoriesSchema, UpdateDraftNoteSchema, UpdateIssueNoteSchema, UpdateIssueSchema, UpdateLabelSchema, UpdateMergeRequestNoteSchema, UpdateMergeRequestDiscussionNoteSchema, UpdateMergeRequestSchema, UpdateWikiPageSchema, VerifyNamespaceSchema, GitLabEventSchema, ListEventsSchema, GetProjectEventsSchema, ExecuteGraphQLSchema, GitLabReleaseSchema, ListReleasesSchema, GetReleaseSchema, CreateReleaseSchema, UpdateReleaseSchema, DeleteReleaseSchema, CreateReleaseEvidenceSchema, DownloadReleaseAssetSchema, GetMergeRequestNotesSchema, GetMergeRequestNoteSchema, DeleteMergeRequestDiscussionNoteSchema, ResolveMergeRequestThreadSchema, } from "./schemas.js";
|
|
53
|
+
ListMergeRequestDiscussionsSchema, ListMergeRequestsSchema, ListMergeRequestVersionsSchema, GetMergeRequestVersionSchema, GitLabMergeRequestVersionSchema, GitLabMergeRequestVersionDetailSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelinesSchema, ListPipelineTriggerJobsSchema, ListProjectMembersSchema, ListProjectMilestonesSchema, ListProjectsSchema, ListWikiPagesSchema, MarkdownUploadSchema, DownloadAttachmentSchema, MergeMergeRequestSchema, ApproveMergeRequestSchema, UnapproveMergeRequestSchema, GetMergeRequestApprovalStateSchema, GitLabMergeRequestApprovalStateSchema, MyIssuesSchema, PaginatedDiscussionsResponseSchema, PromoteProjectMilestoneSchema, PublishDraftNoteSchema, PlayPipelineJobSchema, PushFilesSchema, RetryPipelineJobSchema, RetryPipelineSchema, SearchRepositoriesSchema, UpdateDraftNoteSchema, UpdateIssueNoteSchema, UpdateIssueSchema, UpdateLabelSchema, UpdateMergeRequestNoteSchema, UpdateMergeRequestDiscussionNoteSchema, UpdateMergeRequestSchema, UpdateWikiPageSchema, VerifyNamespaceSchema, GitLabEventSchema, ListEventsSchema, GetProjectEventsSchema, ExecuteGraphQLSchema, GitLabReleaseSchema, ListReleasesSchema, GetReleaseSchema, CreateReleaseSchema, UpdateReleaseSchema, DeleteReleaseSchema, CreateReleaseEvidenceSchema, DownloadReleaseAssetSchema, GetMergeRequestNotesSchema, GetMergeRequestNoteSchema, DeleteMergeRequestDiscussionNoteSchema, ResolveMergeRequestThreadSchema, } from "./schemas.js";
|
|
53
54
|
import { randomUUID } from "node:crypto";
|
|
54
55
|
import { pino } from "pino";
|
|
55
56
|
const logger = pino({
|
|
@@ -236,77 +237,113 @@ const clientPool = new GitLabClientPool({
|
|
|
236
237
|
poolMaxSize: GITLAB_POOL_MAX_SIZE,
|
|
237
238
|
});
|
|
238
239
|
// Create cookie jar with clean Netscape file parsing
|
|
239
|
-
|
|
240
|
-
|
|
240
|
+
// Resolve cookie path once using os.homedir() for cross-platform support
|
|
241
|
+
const resolvedCookiePath = GITLAB_AUTH_COOKIE_PATH
|
|
242
|
+
? GITLAB_AUTH_COOKIE_PATH.startsWith("~/")
|
|
243
|
+
? path.join(os.homedir(), GITLAB_AUTH_COOKIE_PATH.slice(2))
|
|
244
|
+
: GITLAB_AUTH_COOKIE_PATH
|
|
245
|
+
: null;
|
|
246
|
+
const createCookieJar = async () => {
|
|
247
|
+
if (!resolvedCookiePath)
|
|
241
248
|
return null;
|
|
249
|
+
let cookieContent;
|
|
242
250
|
try {
|
|
243
|
-
|
|
244
|
-
? path.join(process.env.HOME || "", GITLAB_AUTH_COOKIE_PATH.slice(2))
|
|
245
|
-
: GITLAB_AUTH_COOKIE_PATH;
|
|
246
|
-
const jar = new CookieJar();
|
|
247
|
-
const cookieContent = fs.readFileSync(cookiePath, "utf8");
|
|
248
|
-
cookieContent.split("\n").forEach(line => {
|
|
249
|
-
// Handle #HttpOnly_ prefix
|
|
250
|
-
if (line.startsWith("#HttpOnly_")) {
|
|
251
|
-
line = line.slice(10);
|
|
252
|
-
}
|
|
253
|
-
// Skip comments and empty lines
|
|
254
|
-
if (line.startsWith("#") || !line.trim()) {
|
|
255
|
-
return;
|
|
256
|
-
}
|
|
257
|
-
// Parse Netscape format: domain, flag, path, secure, expires, name, value
|
|
258
|
-
const parts = line.split("\t");
|
|
259
|
-
if (parts.length >= 7) {
|
|
260
|
-
const [domain, , path, secure, expires, name, value] = parts;
|
|
261
|
-
// Build cookie string in standard format
|
|
262
|
-
const secureFlag = secure === "TRUE" ? "; Secure" : "";
|
|
263
|
-
const expiresFlag = expires === "0"
|
|
264
|
-
? ""
|
|
265
|
-
: `; Expires=${new Date(Number.parseInt(expires, 10) * 1000).toUTCString()}`;
|
|
266
|
-
const cookieStr = `${name}=${value}; Domain=${domain}; Path=${path}${secureFlag}${expiresFlag}`;
|
|
267
|
-
// Use tough-cookie's parse function for robust parsing
|
|
268
|
-
const cookie = parseCookie(cookieStr);
|
|
269
|
-
if (cookie) {
|
|
270
|
-
const url = `${secure === "TRUE" ? "https" : "http"}://${domain.startsWith(".") ? domain.slice(1) : domain}`;
|
|
271
|
-
jar.setCookieSync(cookie, url);
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
});
|
|
275
|
-
return jar;
|
|
251
|
+
cookieContent = await fs.promises.readFile(resolvedCookiePath, "utf8");
|
|
276
252
|
}
|
|
277
253
|
catch (error) {
|
|
278
|
-
logger.error("
|
|
254
|
+
logger.error({ error, path: resolvedCookiePath }, "Failed to read cookie file");
|
|
279
255
|
return null;
|
|
280
256
|
}
|
|
257
|
+
const jar = new CookieJar();
|
|
258
|
+
for (let line of cookieContent.split("\n")) {
|
|
259
|
+
// Handle #HttpOnly_ prefix
|
|
260
|
+
if (line.startsWith("#HttpOnly_")) {
|
|
261
|
+
line = line.slice(10);
|
|
262
|
+
}
|
|
263
|
+
// Skip comments and empty lines
|
|
264
|
+
if (line.startsWith("#") || !line.trim()) {
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
// Parse Netscape format: domain, flag, path, secure, expires, name, value
|
|
268
|
+
const parts = line.split("\t");
|
|
269
|
+
if (parts.length >= 7) {
|
|
270
|
+
const [domain, , cookiePath, secure, expires, name, value] = parts;
|
|
271
|
+
// Build cookie string in standard format
|
|
272
|
+
const secureFlag = secure === "TRUE" ? "; Secure" : "";
|
|
273
|
+
const expiresFlag = expires === "0"
|
|
274
|
+
? ""
|
|
275
|
+
: `; Expires=${new Date(Number.parseInt(expires, 10) * 1000).toUTCString()}`;
|
|
276
|
+
const cookieStr = `${name}=${value}; Domain=${domain}; Path=${cookiePath}${secureFlag}${expiresFlag}`;
|
|
277
|
+
// Use tough-cookie's parse function for robust parsing
|
|
278
|
+
const cookie = parseCookie(cookieStr);
|
|
279
|
+
if (cookie) {
|
|
280
|
+
const url = `${secure === "TRUE" ? "https" : "http"}://${domain.startsWith(".") ? domain.slice(1) : domain}`;
|
|
281
|
+
jar.setCookieSync(cookie, url);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return jar;
|
|
281
286
|
};
|
|
282
|
-
//
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
287
|
+
// Cookie jar and fetch - reloaded when cookie file changes
|
|
288
|
+
let cookieJar = null;
|
|
289
|
+
let fetch = nodeFetch;
|
|
290
|
+
let lastCookieMtime = 0;
|
|
291
|
+
let cookieReloadLock = null; // Mutex to prevent parallel reloads
|
|
292
|
+
// Auth proxies may redirect and set cookies on the first request. We make a throwaway
|
|
293
|
+
// request so subsequent requests have the correct cookies. Reset when cookies reload.
|
|
294
|
+
let initialSessionRequestMade = false;
|
|
295
|
+
// Cookie jar is loaded on first request via reloadCookiesIfChanged (lastCookieMtime=0 triggers load)
|
|
296
|
+
async function reloadCookiesIfChanged() {
|
|
297
|
+
if (!resolvedCookiePath)
|
|
288
298
|
return;
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
// Check if we already have GitLab session cookies
|
|
293
|
-
const gitlabCookies = cookieJar.getCookiesSync(baseUrl);
|
|
294
|
-
const hasSessionCookie = gitlabCookies.some(cookie => cookie.key === "_gitlab_session" || cookie.key === "remember_user_token");
|
|
295
|
-
if (!hasSessionCookie) {
|
|
299
|
+
if (cookieReloadLock)
|
|
300
|
+
return cookieReloadLock;
|
|
301
|
+
cookieReloadLock = (async () => {
|
|
296
302
|
try {
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
303
|
+
const mtime = (await fs.promises.stat(resolvedCookiePath)).mtimeMs;
|
|
304
|
+
if (mtime !== lastCookieMtime) {
|
|
305
|
+
logger.info({ oldMtime: lastCookieMtime, newMtime: mtime }, lastCookieMtime === 0 ? "Loading cookie file" : "Cookie file changed, reloading");
|
|
306
|
+
lastCookieMtime = mtime;
|
|
307
|
+
const newJar = await createCookieJar();
|
|
308
|
+
cookieJar = newJar;
|
|
309
|
+
fetch = newJar ? fetchCookie(nodeFetch, newJar) : nodeFetch;
|
|
310
|
+
initialSessionRequestMade = false;
|
|
311
|
+
}
|
|
306
312
|
}
|
|
307
313
|
catch {
|
|
308
|
-
//
|
|
314
|
+
// File deleted or inaccessible - clear cached cookies
|
|
315
|
+
if (cookieJar) {
|
|
316
|
+
logger.info("Cookie file removed, clearing cached cookies");
|
|
317
|
+
cookieJar = null;
|
|
318
|
+
fetch = nodeFetch;
|
|
319
|
+
lastCookieMtime = 0;
|
|
320
|
+
initialSessionRequestMade = false;
|
|
321
|
+
}
|
|
309
322
|
}
|
|
323
|
+
})();
|
|
324
|
+
try {
|
|
325
|
+
await cookieReloadLock;
|
|
326
|
+
}
|
|
327
|
+
finally {
|
|
328
|
+
cookieReloadLock = null;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
async function ensureSessionForRequest() {
|
|
332
|
+
if (!resolvedCookiePath)
|
|
333
|
+
return;
|
|
334
|
+
await reloadCookiesIfChanged();
|
|
335
|
+
if (!cookieJar || initialSessionRequestMade)
|
|
336
|
+
return;
|
|
337
|
+
try {
|
|
338
|
+
const response = await fetch(`${getEffectiveApiUrl()}/user`, {
|
|
339
|
+
...getFetchConfig(),
|
|
340
|
+
redirect: "follow",
|
|
341
|
+
});
|
|
342
|
+
// 401 means auth failed but the request completed - cookies were still exchanged
|
|
343
|
+
initialSessionRequestMade = response.ok || response.status === 401;
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
346
|
+
logger.debug("Session warmup request failed, will retry on next request");
|
|
310
347
|
}
|
|
311
348
|
}
|
|
312
349
|
const sessionAuthStore = new AsyncLocalStorage();
|
|
@@ -424,6 +461,21 @@ const allTools = [
|
|
|
424
461
|
description: "Merge a merge request in a GitLab project",
|
|
425
462
|
inputSchema: toJSONSchema(MergeMergeRequestSchema),
|
|
426
463
|
},
|
|
464
|
+
{
|
|
465
|
+
name: "approve_merge_request",
|
|
466
|
+
description: "Approve a merge request. Requires appropriate permissions.",
|
|
467
|
+
inputSchema: toJSONSchema(ApproveMergeRequestSchema),
|
|
468
|
+
},
|
|
469
|
+
{
|
|
470
|
+
name: "unapprove_merge_request",
|
|
471
|
+
description: "Unapprove a previously approved merge request. Requires appropriate permissions.",
|
|
472
|
+
inputSchema: toJSONSchema(UnapproveMergeRequestSchema),
|
|
473
|
+
},
|
|
474
|
+
{
|
|
475
|
+
name: "get_merge_request_approval_state",
|
|
476
|
+
description: "Get the approval state of a merge request including approval rules and who has approved",
|
|
477
|
+
inputSchema: toJSONSchema(GetMergeRequestApprovalStateSchema),
|
|
478
|
+
},
|
|
427
479
|
{
|
|
428
480
|
name: "execute_graphql",
|
|
429
481
|
description: "Execute a GitLab GraphQL query",
|
|
@@ -998,6 +1050,7 @@ const readOnlyTools = new Set([
|
|
|
998
1050
|
"list_releases",
|
|
999
1051
|
"get_release",
|
|
1000
1052
|
"download_release_asset",
|
|
1053
|
+
"get_merge_request_approval_state",
|
|
1001
1054
|
]);
|
|
1002
1055
|
// Define which tools are related to wiki and can be toggled by USE_GITLAB_WIKI
|
|
1003
1056
|
const wikiToolNames = new Set([
|
|
@@ -1762,7 +1815,7 @@ async function getMergeRequestNote(projectId, mergeRequestIid, noteId) {
|
|
|
1762
1815
|
const data = await response.json();
|
|
1763
1816
|
return GitLabDiscussionNoteSchema.parse(data);
|
|
1764
1817
|
}
|
|
1765
|
-
async function getMergeRequestNotes(projectId, mergeRequestIid, sort, order_by) {
|
|
1818
|
+
async function getMergeRequestNotes(projectId, mergeRequestIid, sort, order_by, per_page, page) {
|
|
1766
1819
|
projectId = decodeURIComponent(projectId); // Decode project ID
|
|
1767
1820
|
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/notes`);
|
|
1768
1821
|
if (sort) {
|
|
@@ -1771,6 +1824,12 @@ async function getMergeRequestNotes(projectId, mergeRequestIid, sort, order_by)
|
|
|
1771
1824
|
if (order_by) {
|
|
1772
1825
|
url.searchParams.append("order_by", order_by);
|
|
1773
1826
|
}
|
|
1827
|
+
if (per_page) {
|
|
1828
|
+
url.searchParams.append("per_page", per_page.toString());
|
|
1829
|
+
}
|
|
1830
|
+
if (page) {
|
|
1831
|
+
url.searchParams.append("page", page.toString());
|
|
1832
|
+
}
|
|
1774
1833
|
const response = await fetch(url.toString(), {
|
|
1775
1834
|
...getFetchConfig(),
|
|
1776
1835
|
method: "GET",
|
|
@@ -2177,6 +2236,68 @@ async function mergeMergeRequest(projectId, options, mergeRequestIid) {
|
|
|
2177
2236
|
await handleGitLabError(response);
|
|
2178
2237
|
return GitLabMergeRequestSchema.parse(await response.json());
|
|
2179
2238
|
}
|
|
2239
|
+
/**
|
|
2240
|
+
* Approve a merge request
|
|
2241
|
+
*
|
|
2242
|
+
* @param {string} projectId - The ID or URL-encoded path of the project
|
|
2243
|
+
* @param {string | number} mergeRequestIid - The internal ID of the merge request
|
|
2244
|
+
* @param {string} sha - Optional SHA to approve (for validation that MR hasn't changed)
|
|
2245
|
+
* @param {string} approvalPassword - Optional password for approvals requiring re-authentication
|
|
2246
|
+
* @returns {Promise<GitLabMergeRequestApprovalState>} The approval state after approving
|
|
2247
|
+
*/
|
|
2248
|
+
async function approveMergeRequest(projectId, mergeRequestIid, sha, approvalPassword) {
|
|
2249
|
+
projectId = decodeURIComponent(projectId);
|
|
2250
|
+
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/approve`);
|
|
2251
|
+
const body = {};
|
|
2252
|
+
if (sha) {
|
|
2253
|
+
body.sha = sha;
|
|
2254
|
+
}
|
|
2255
|
+
if (approvalPassword) {
|
|
2256
|
+
body.approval_password = approvalPassword;
|
|
2257
|
+
}
|
|
2258
|
+
const response = await fetch(url.toString(), {
|
|
2259
|
+
...getFetchConfig(),
|
|
2260
|
+
method: "POST",
|
|
2261
|
+
body: JSON.stringify(body),
|
|
2262
|
+
});
|
|
2263
|
+
await handleGitLabError(response);
|
|
2264
|
+
return GitLabMergeRequestApprovalStateSchema.parse(await response.json());
|
|
2265
|
+
}
|
|
2266
|
+
/**
|
|
2267
|
+
* Unapprove a previously approved merge request
|
|
2268
|
+
*
|
|
2269
|
+
* @param {string} projectId - The ID or URL-encoded path of the project
|
|
2270
|
+
* @param {string | number} mergeRequestIid - The internal ID of the merge request
|
|
2271
|
+
* @returns {Promise<GitLabMergeRequestApprovalState>} The approval state after unapproving
|
|
2272
|
+
*/
|
|
2273
|
+
async function unapproveMergeRequest(projectId, mergeRequestIid) {
|
|
2274
|
+
projectId = decodeURIComponent(projectId);
|
|
2275
|
+
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/unapprove`);
|
|
2276
|
+
const response = await fetch(url.toString(), {
|
|
2277
|
+
...getFetchConfig(),
|
|
2278
|
+
method: "POST",
|
|
2279
|
+
body: JSON.stringify({}),
|
|
2280
|
+
});
|
|
2281
|
+
await handleGitLabError(response);
|
|
2282
|
+
return GitLabMergeRequestApprovalStateSchema.parse(await response.json());
|
|
2283
|
+
}
|
|
2284
|
+
/**
|
|
2285
|
+
* Get the approval state of a merge request
|
|
2286
|
+
*
|
|
2287
|
+
* @param {string} projectId - The ID or URL-encoded path of the project
|
|
2288
|
+
* @param {string | number} mergeRequestIid - The internal ID of the merge request
|
|
2289
|
+
* @returns {Promise<GitLabMergeRequestApprovalState>} The approval state
|
|
2290
|
+
*/
|
|
2291
|
+
async function getMergeRequestApprovalState(projectId, mergeRequestIid) {
|
|
2292
|
+
projectId = decodeURIComponent(projectId);
|
|
2293
|
+
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/approval_state`);
|
|
2294
|
+
const response = await fetch(url.toString(), {
|
|
2295
|
+
...getFetchConfig(),
|
|
2296
|
+
method: "GET",
|
|
2297
|
+
});
|
|
2298
|
+
await handleGitLabError(response);
|
|
2299
|
+
return GitLabMergeRequestApprovalStateSchema.parse(await response.json());
|
|
2300
|
+
}
|
|
2180
2301
|
/**
|
|
2181
2302
|
* Create a new note (comment) on an issue or merge request
|
|
2182
2303
|
* 📦 새로운 함수: createNote - 이슈 또는 병합 요청에 노트(댓글)를 추가하는 함수
|
|
@@ -3572,7 +3693,8 @@ async function myIssues(options = {}) {
|
|
|
3572
3693
|
async function listProjectMembers(projectId, options = {}) {
|
|
3573
3694
|
projectId = decodeURIComponent(projectId);
|
|
3574
3695
|
const effectiveProjectId = getEffectiveProjectId(projectId);
|
|
3575
|
-
const
|
|
3696
|
+
const membersPath = options.include_inheritance ? "members/all" : "members";
|
|
3697
|
+
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/${membersPath}`);
|
|
3576
3698
|
// Add query parameters
|
|
3577
3699
|
if (options.query)
|
|
3578
3700
|
url.searchParams.append("query", options.query);
|
|
@@ -3924,6 +4046,37 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3924
4046
|
// Fallback for non-remote-auth mode or if session is not found
|
|
3925
4047
|
return handleToolCall(request.params);
|
|
3926
4048
|
});
|
|
4049
|
+
/**
|
|
4050
|
+
* Filter diffs by excluded file patterns
|
|
4051
|
+
* Safely handles invalid regex patterns by logging and ignoring them
|
|
4052
|
+
*
|
|
4053
|
+
* @param diffs - Array of diff objects with new_path property
|
|
4054
|
+
* @param excludedFilePatterns - Array of regex patterns to exclude
|
|
4055
|
+
* @returns Filtered array of diffs
|
|
4056
|
+
*/
|
|
4057
|
+
function filterDiffsByPatterns(diffs, excludedFilePatterns) {
|
|
4058
|
+
if (!excludedFilePatterns?.length)
|
|
4059
|
+
return diffs;
|
|
4060
|
+
const regexPatterns = excludedFilePatterns
|
|
4061
|
+
.map((pattern) => {
|
|
4062
|
+
try {
|
|
4063
|
+
return new RegExp(pattern);
|
|
4064
|
+
}
|
|
4065
|
+
catch (e) {
|
|
4066
|
+
console.warn(`Invalid regex pattern ignored: ${pattern}`);
|
|
4067
|
+
return null;
|
|
4068
|
+
}
|
|
4069
|
+
})
|
|
4070
|
+
.filter((regex) => regex !== null);
|
|
4071
|
+
if (regexPatterns.length === 0)
|
|
4072
|
+
return diffs;
|
|
4073
|
+
const matchesAnyPattern = (path) => {
|
|
4074
|
+
if (!path)
|
|
4075
|
+
return false;
|
|
4076
|
+
return regexPatterns.some((regex) => regex.test(path));
|
|
4077
|
+
};
|
|
4078
|
+
return diffs.filter((diff) => !matchesAnyPattern(diff.new_path));
|
|
4079
|
+
}
|
|
3927
4080
|
async function handleToolCall(params) {
|
|
3928
4081
|
try {
|
|
3929
4082
|
if (!params.arguments) {
|
|
@@ -4026,17 +4179,7 @@ async function handleToolCall(params) {
|
|
|
4026
4179
|
case "get_branch_diffs": {
|
|
4027
4180
|
const args = GetBranchDiffsSchema.parse(params.arguments);
|
|
4028
4181
|
const diffResp = await getBranchDiffs(args.project_id, args.from, args.to, args.straight);
|
|
4029
|
-
|
|
4030
|
-
const regexPatterns = args.excluded_file_patterns.map(pattern => new RegExp(pattern));
|
|
4031
|
-
// Helper function to check if a path matches any regex pattern
|
|
4032
|
-
const matchesAnyPattern = (path) => {
|
|
4033
|
-
if (!path)
|
|
4034
|
-
return false;
|
|
4035
|
-
return regexPatterns.some(regex => regex.test(path));
|
|
4036
|
-
};
|
|
4037
|
-
// Filter out files that match any of the regex patterns on new files
|
|
4038
|
-
diffResp.diffs = diffResp.diffs.filter(diff => !matchesAnyPattern(diff.new_path));
|
|
4039
|
-
}
|
|
4182
|
+
diffResp.diffs = filterDiffsByPatterns(diffResp.diffs, args.excluded_file_patterns);
|
|
4040
4183
|
return {
|
|
4041
4184
|
content: [{ type: "text", text: JSON.stringify(diffResp, null, 2) }],
|
|
4042
4185
|
};
|
|
@@ -4142,7 +4285,7 @@ async function handleToolCall(params) {
|
|
|
4142
4285
|
}
|
|
4143
4286
|
case "get_merge_request_notes": {
|
|
4144
4287
|
const args = GetMergeRequestNotesSchema.parse(params.arguments);
|
|
4145
|
-
const notes = await getMergeRequestNotes(args.project_id, args.merge_request_iid, args.sort, args.order_by);
|
|
4288
|
+
const notes = await getMergeRequestNotes(args.project_id, args.merge_request_iid, args.sort, args.order_by, args.per_page, args.page);
|
|
4146
4289
|
return {
|
|
4147
4290
|
content: [{ type: "text", text: JSON.stringify(notes, null, 2) }],
|
|
4148
4291
|
};
|
|
@@ -4178,8 +4321,9 @@ async function handleToolCall(params) {
|
|
|
4178
4321
|
case "get_merge_request_diffs": {
|
|
4179
4322
|
const args = GetMergeRequestDiffsSchema.parse(params.arguments);
|
|
4180
4323
|
const diffs = await getMergeRequestDiffs(args.project_id, args.merge_request_iid, args.source_branch, args.view);
|
|
4324
|
+
const filteredDiffs = filterDiffsByPatterns(diffs, args.excluded_file_patterns);
|
|
4181
4325
|
return {
|
|
4182
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
4326
|
+
content: [{ type: "text", text: JSON.stringify(filteredDiffs, null, 2) }],
|
|
4183
4327
|
};
|
|
4184
4328
|
}
|
|
4185
4329
|
case "list_merge_request_diffs": {
|
|
@@ -4219,6 +4363,27 @@ async function handleToolCall(params) {
|
|
|
4219
4363
|
content: [{ type: "text", text: JSON.stringify(mergeRequest, null, 2) }],
|
|
4220
4364
|
};
|
|
4221
4365
|
}
|
|
4366
|
+
case "approve_merge_request": {
|
|
4367
|
+
const args = ApproveMergeRequestSchema.parse(params.arguments);
|
|
4368
|
+
const approvalState = await approveMergeRequest(args.project_id, args.merge_request_iid, args.sha, args.approval_password);
|
|
4369
|
+
return {
|
|
4370
|
+
content: [{ type: "text", text: JSON.stringify(approvalState, null, 2) }],
|
|
4371
|
+
};
|
|
4372
|
+
}
|
|
4373
|
+
case "unapprove_merge_request": {
|
|
4374
|
+
const args = UnapproveMergeRequestSchema.parse(params.arguments);
|
|
4375
|
+
const approvalState = await unapproveMergeRequest(args.project_id, args.merge_request_iid);
|
|
4376
|
+
return {
|
|
4377
|
+
content: [{ type: "text", text: JSON.stringify(approvalState, null, 2) }],
|
|
4378
|
+
};
|
|
4379
|
+
}
|
|
4380
|
+
case "get_merge_request_approval_state": {
|
|
4381
|
+
const args = GetMergeRequestApprovalStateSchema.parse(params.arguments);
|
|
4382
|
+
const approvalState = await getMergeRequestApprovalState(args.project_id, args.merge_request_iid);
|
|
4383
|
+
return {
|
|
4384
|
+
content: [{ type: "text", text: JSON.stringify(approvalState, null, 2) }],
|
|
4385
|
+
};
|
|
4386
|
+
}
|
|
4222
4387
|
case "mr_discussions": {
|
|
4223
4388
|
const args = ListMergeRequestDiscussionsSchema.parse(params.arguments);
|
|
4224
4389
|
const { project_id, merge_request_iid, ...options } = args;
|
package/build/schemas.js
CHANGED
|
@@ -856,6 +856,8 @@ export const GetMergeRequestNotesSchema = ProjectParamsSchema.extend({
|
|
|
856
856
|
merge_request_iid: z.coerce.string().describe("The IID of a merge request"),
|
|
857
857
|
sort: z.enum(["asc", "desc"]).optional().describe("The sort order of the notes"),
|
|
858
858
|
order_by: z.enum(["created_at", "updated_at"]).optional().describe("The field to sort the notes by"),
|
|
859
|
+
per_page: z.coerce.number().optional().describe("Number of items per page"),
|
|
860
|
+
page: z.coerce.number().optional().describe("Page number for pagination"),
|
|
859
861
|
});
|
|
860
862
|
export const GetMergeRequestNoteSchema = ProjectParamsSchema.extend({
|
|
861
863
|
merge_request_iid: z.coerce.string().describe("The IID of a merge request"),
|
|
@@ -1003,7 +1005,7 @@ export const GetBranchDiffsSchema = ProjectParamsSchema.extend({
|
|
|
1003
1005
|
excluded_file_patterns: z
|
|
1004
1006
|
.array(z.string())
|
|
1005
1007
|
.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"]'),
|
|
1008
|
+
.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
1009
|
});
|
|
1008
1010
|
export const GetMergeRequestSchema = ProjectParamsSchema.extend({
|
|
1009
1011
|
merge_request_iid: z.coerce.string().optional().describe("The IID of a merge request"),
|
|
@@ -1039,8 +1041,61 @@ export const MergeMergeRequestSchema = ProjectParamsSchema.extend({
|
|
|
1039
1041
|
squash_commit_message: z.string().optional().describe("Custom squash commit message"),
|
|
1040
1042
|
squash: z.boolean().optional().default(false).describe("Squash commits into a single commit when merging"),
|
|
1041
1043
|
});
|
|
1044
|
+
// Merge Request Approval schemas
|
|
1045
|
+
export const ApproveMergeRequestSchema = ProjectParamsSchema.extend({
|
|
1046
|
+
merge_request_iid: z.coerce.string().describe("The IID of the merge request to approve"),
|
|
1047
|
+
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"),
|
|
1048
|
+
approval_password: z.string().optional().describe("Current user's password. Required if 'Require user re-authentication to approve' is enabled in the project settings"),
|
|
1049
|
+
});
|
|
1050
|
+
export const UnapproveMergeRequestSchema = ProjectParamsSchema.extend({
|
|
1051
|
+
merge_request_iid: z.coerce.string().describe("The IID of the merge request to unapprove"),
|
|
1052
|
+
});
|
|
1053
|
+
// Merge Request Approval State response schema
|
|
1054
|
+
export const GitLabApprovalUserSchema = z.object({
|
|
1055
|
+
id: z.coerce.string(),
|
|
1056
|
+
username: z.string(),
|
|
1057
|
+
name: z.string(),
|
|
1058
|
+
state: z.string(),
|
|
1059
|
+
avatar_url: z.string().nullable().optional(),
|
|
1060
|
+
web_url: z.string(),
|
|
1061
|
+
});
|
|
1062
|
+
export const GitLabApprovalRuleSchema = z.object({
|
|
1063
|
+
id: z.coerce.string(),
|
|
1064
|
+
name: z.string(),
|
|
1065
|
+
rule_type: z.string(),
|
|
1066
|
+
eligible_approvers: z.array(GitLabApprovalUserSchema).optional(),
|
|
1067
|
+
approvals_required: z.number(),
|
|
1068
|
+
users: z.array(GitLabApprovalUserSchema).optional(),
|
|
1069
|
+
groups: z.array(z.object({
|
|
1070
|
+
id: z.coerce.string(),
|
|
1071
|
+
name: z.string(),
|
|
1072
|
+
path: z.string(),
|
|
1073
|
+
full_path: z.string(),
|
|
1074
|
+
avatar_url: z.string().nullable().optional(),
|
|
1075
|
+
web_url: z.string(),
|
|
1076
|
+
})).optional(),
|
|
1077
|
+
contains_hidden_groups: z.boolean().optional(),
|
|
1078
|
+
approved_by: z.array(GitLabApprovalUserSchema).optional(),
|
|
1079
|
+
source_rule: z.object({
|
|
1080
|
+
id: z.coerce.string().optional(),
|
|
1081
|
+
name: z.string().optional(),
|
|
1082
|
+
rule_type: z.string().optional(),
|
|
1083
|
+
}).nullable().optional(),
|
|
1084
|
+
approved: z.boolean().optional(),
|
|
1085
|
+
});
|
|
1086
|
+
export const GitLabMergeRequestApprovalStateSchema = z.object({
|
|
1087
|
+
approval_rules_overwritten: z.boolean().optional(),
|
|
1088
|
+
rules: z.array(GitLabApprovalRuleSchema).optional(),
|
|
1089
|
+
});
|
|
1090
|
+
export const GetMergeRequestApprovalStateSchema = ProjectParamsSchema.extend({
|
|
1091
|
+
merge_request_iid: z.coerce.string().describe("The IID of the merge request"),
|
|
1092
|
+
});
|
|
1042
1093
|
export const GetMergeRequestDiffsSchema = GetMergeRequestSchema.extend({
|
|
1043
1094
|
view: z.enum(["inline", "parallel"]).optional().describe("Diff view type"),
|
|
1095
|
+
excluded_file_patterns: z
|
|
1096
|
+
.array(z.string())
|
|
1097
|
+
.optional()
|
|
1098
|
+
.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
1099
|
});
|
|
1045
1100
|
export const ListMergeRequestDiffsSchema = GetMergeRequestSchema.extend({
|
|
1046
1101
|
page: z.number().optional().describe("Page number for pagination (default: 1)"),
|
|
@@ -1674,6 +1729,10 @@ export const ListProjectMembersSchema = z.object({
|
|
|
1674
1729
|
query: z.string().optional().describe("Search for members by name or username"),
|
|
1675
1730
|
user_ids: z.array(z.number()).optional().describe("Filter by user IDs"),
|
|
1676
1731
|
skip_users: z.array(z.number()).optional().describe("User IDs to exclude"),
|
|
1732
|
+
include_inheritance: z
|
|
1733
|
+
.boolean()
|
|
1734
|
+
.optional()
|
|
1735
|
+
.describe("Include inherited members. Defaults to false."),
|
|
1677
1736
|
per_page: z.number().optional().describe("Number of items per page (default: 20, max: 100)"),
|
|
1678
1737
|
page: z.number().optional().describe("Page number for pagination (default: 1)"),
|
|
1679
1738
|
});
|
|
@@ -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
|
+
});
|