@zereight/mcp-gitlab 2.0.28 → 2.0.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -5
- package/build/index.js +974 -65
- package/build/oauth.js +16 -4
- package/build/schemas.js +504 -101
- package/build/test/schema-tests.js +311 -0
- package/build/test/test-deployment-tools.js +366 -0
- package/build/test/test-download-attachment.js +144 -0
- package/build/test/test-job-artifacts.js +194 -0
- package/build/test/test-merge-request-approval-state-tools.js +171 -0
- package/build/test/test-toolset-filtering.js +452 -0
- package/package.json +3 -2
package/build/index.js
CHANGED
|
@@ -36,21 +36,21 @@ import { CookieJar, parse as parseCookie } from "tough-cookie";
|
|
|
36
36
|
import { fileURLToPath, URL } from "node:url";
|
|
37
37
|
import { z } from "zod";
|
|
38
38
|
import { zodToJsonSchema } from "zod-to-json-schema";
|
|
39
|
-
import {
|
|
39
|
+
import { initializeOAuthClient } from "./oauth.js";
|
|
40
40
|
import { GitLabClientPool } from "./gitlab-client-pool.js";
|
|
41
41
|
// Add type imports for proxy agents
|
|
42
42
|
import { Agent } from "node:http";
|
|
43
43
|
import { Agent as HttpsAgent } from "node:https";
|
|
44
44
|
import { BulkPublishDraftNotesSchema, CancelPipelineJobSchema, CancelPipelineSchema, CreateBranchSchema, CreateDraftNoteSchema, CreateIssueLinkSchema, CreateIssueNoteSchema, CreateIssueSchema, CreateLabelSchema, // Added
|
|
45
|
-
CreateMergeRequestNoteSchema, CreateMergeRequestDiscussionNoteSchema, CreateMergeRequestSchema, CreateMergeRequestThreadSchema, CreateNoteSchema, CreateOrUpdateFileSchema, CreatePipelineSchema, CreateProjectMilestoneSchema, CreateRepositorySchema, CreateWikiPageSchema, DeleteDraftNoteSchema, DeleteIssueLinkSchema, DeleteIssueSchema, DeleteLabelSchema, DeleteProjectMilestoneSchema, DeleteWikiPageSchema, DeleteMergeRequestNoteSchema, EditProjectMilestoneSchema, ForkRepositorySchema, GetBranchDiffsSchema, GetCommitDiffSchema, GetCommitSchema, GetDraftNoteSchema, GetFileContentsSchema, GetIssueLinkSchema, GetIssueSchema, GetLabelSchema, GetMergeRequestDiffsSchema, GetMergeRequestSchema, GetMilestoneBurndownEventsSchema, GetMilestoneIssuesSchema, GetMilestoneMergeRequestsSchema, GetNamespaceSchema,
|
|
45
|
+
CreateMergeRequestNoteSchema, CreateMergeRequestDiscussionNoteSchema, CreateMergeRequestSchema, CreateMergeRequestThreadSchema, CreateNoteSchema, CreateOrUpdateFileSchema, CreatePipelineSchema, CreateProjectMilestoneSchema, CreateRepositorySchema, CreateWikiPageSchema, DeleteDraftNoteSchema, DeleteIssueLinkSchema, DeleteIssueSchema, DeleteLabelSchema, DeleteProjectMilestoneSchema, DeleteWikiPageSchema, DeleteMergeRequestNoteSchema, EditProjectMilestoneSchema, ForkRepositorySchema, GetBranchDiffsSchema, GetCommitDiffSchema, GetCommitSchema, GetDraftNoteSchema, GetFileContentsSchema, GetIssueLinkSchema, GetIssueSchema, GetLabelSchema, GetMergeRequestDiffsSchema, GetMergeRequestSchema, GetMilestoneBurndownEventsSchema, GetMilestoneIssuesSchema, GetMilestoneMergeRequestsSchema, GetDeploymentSchema, GetEnvironmentSchema, GetNamespaceSchema,
|
|
46
46
|
// pipeline job schemas
|
|
47
47
|
GetPipelineJobOutputSchema, GetPipelineSchema, GetProjectMilestoneSchema, GetProjectSchema, GetRepositoryTreeSchema, GetUsersSchema, GetWikiPageSchema, GitLabCommitSchema, GitLabCompareResultSchema, GitLabContentSchema, GitLabCreateUpdateFileResponseSchema, GitLabDiffSchema,
|
|
48
48
|
// Discussion Schemas
|
|
49
49
|
GitLabDiscussionNoteSchema, // Added
|
|
50
50
|
GitLabDiscussionSchema,
|
|
51
51
|
// Draft Notes Schemas
|
|
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
|
|
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";
|
|
52
|
+
GitLabDraftNoteSchema, GitLabForkSchema, GitLabIssueLinkSchema, GitLabIssueSchema, GitLabIssueWithLinkDetailsSchema, GitLabMarkdownUploadSchema, GitLabMergeRequestSchema, GitLabMilestonesSchema, GitLabNamespaceExistsResponseSchema, GitLabNamespaceSchema, GitLabPipelineJobSchema, GitLabDeploymentSchema, GitLabEnvironmentSchema, GitLabPipelineSchema, GitLabPipelineTriggerJobSchema, GitLabProjectMemberSchema, GitLabProjectSchema, GitLabReferenceSchema, GitLabRepositorySchema, GitLabSearchResponseSchema, GitLabTreeItemSchema, GitLabTreeSchema, GitLabUserSchema, GitLabUsersResponseSchema, GitLabWikiPageSchema, GroupIteration, ListCommitsSchema, ListDraftNotesSchema, ListGroupIterationsSchema, ListGroupProjectsSchema, ListIssueDiscussionsSchema, ListIssueLinksSchema, ListIssuesSchema, ListLabelsSchema, ListMergeRequestDiffsSchema, // Added
|
|
53
|
+
ListMergeRequestDiscussionsSchema, ListMergeRequestsSchema, ListMergeRequestVersionsSchema, GetMergeRequestVersionSchema, GitLabMergeRequestVersionSchema, GitLabMergeRequestVersionDetailSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelinesSchema, ListDeploymentsSchema, ListEnvironmentsSchema, ListPipelineTriggerJobsSchema, ListProjectMembersSchema, ListProjectMilestonesSchema, ListProjectsSchema, ListWikiPagesSchema, MarkdownUploadSchema, DownloadAttachmentSchema, DownloadJobArtifactsSchema, GetJobArtifactFileSchema, GitLabArtifactEntrySchema, ListJobArtifactsSchema, MergeMergeRequestSchema, ApproveMergeRequestSchema, UnapproveMergeRequestSchema, GetMergeRequestApprovalStateSchema, GitLabMergeRequestApprovalsResponseSchema, 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";
|
|
54
54
|
import { randomUUID } from "node:crypto";
|
|
55
55
|
import { pino } from "pino";
|
|
56
56
|
const logger = pino({
|
|
@@ -95,6 +95,29 @@ catch {
|
|
|
95
95
|
* cross-client data leakage (GHSA-345p-7cg4-v4c7).
|
|
96
96
|
*/
|
|
97
97
|
function createServer() {
|
|
98
|
+
// Precompute filtered tool list once at server creation (Steps 1–5 are static)
|
|
99
|
+
// Step 1: Toolset filter — keep tools in enabled toolsets
|
|
100
|
+
const toolsAfterToolsets = allTools.filter(tool => isToolInEnabledToolset(tool.name, enabledToolsets));
|
|
101
|
+
// Step 2: Add GITLAB_TOOLS (individual tools bypass toolset filter)
|
|
102
|
+
const toolsetToolNames = new Set(toolsAfterToolsets.map(t => t.name));
|
|
103
|
+
const toolsAfterIndividual = [
|
|
104
|
+
...toolsAfterToolsets,
|
|
105
|
+
...allTools.filter(tool => individuallyEnabledTools.has(tool.name) && !toolsetToolNames.has(tool.name)),
|
|
106
|
+
];
|
|
107
|
+
// Step 3: Add legacy flag overrides (USE_PIPELINE, USE_MILESTONE, USE_GITLAB_WIKI)
|
|
108
|
+
const afterIndividualNames = new Set(toolsAfterIndividual.map(t => t.name));
|
|
109
|
+
const toolsAfterLegacy = [
|
|
110
|
+
...toolsAfterIndividual,
|
|
111
|
+
...allTools.filter(tool => featureFlagOverrides.has(tool.name) && !afterIndividualNames.has(tool.name)),
|
|
112
|
+
];
|
|
113
|
+
// Step 4: Read-only filter
|
|
114
|
+
const toolsAfterReadOnly = GITLAB_READ_ONLY_MODE
|
|
115
|
+
? toolsAfterLegacy.filter(tool => readOnlyTools.has(tool.name))
|
|
116
|
+
: toolsAfterLegacy;
|
|
117
|
+
// Step 5: Regex denial filter
|
|
118
|
+
const precomputedFilteredTools = GITLAB_DENIED_TOOLS_REGEX
|
|
119
|
+
? toolsAfterReadOnly.filter(tool => !GITLAB_DENIED_TOOLS_REGEX.test(tool.name))
|
|
120
|
+
: toolsAfterReadOnly;
|
|
98
121
|
const serverInstance = new Server({
|
|
99
122
|
name: "better-gitlab-mcp-server",
|
|
100
123
|
version: SERVER_VERSION,
|
|
@@ -104,39 +127,25 @@ function createServer() {
|
|
|
104
127
|
},
|
|
105
128
|
});
|
|
106
129
|
serverInstance.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
// Toggle wiki tools by USE_GITLAB_WIKI flag
|
|
112
|
-
const tools1 = USE_GITLAB_WIKI ? tools0 : tools0.filter(tool => !wikiToolNames.has(tool.name));
|
|
113
|
-
// Toggle milestone tools by USE_MILESTONE flag
|
|
114
|
-
const tools2 = USE_MILESTONE
|
|
115
|
-
? tools1
|
|
116
|
-
: tools1.filter(tool => !milestoneToolNames.has(tool.name));
|
|
117
|
-
// Toggle pipeline tools by USE_PIPELINE flag
|
|
118
|
-
let tools = USE_PIPELINE ? tools2 : tools2.filter(tool => !pipelineToolNames.has(tool.name));
|
|
119
|
-
tools = GITLAB_DENIED_TOOLS_REGEX
|
|
120
|
-
? tools.filter(tool => !GITLAB_DENIED_TOOLS_REGEX.test(tool.name))
|
|
121
|
-
: tools;
|
|
122
|
-
// <<< START: Gemini 호환성을 위해 $schema 제거 >>>
|
|
123
|
-
tools = tools.map(tool => {
|
|
124
|
-
// inputSchema가 존재하고 객체인지 확인
|
|
130
|
+
// Step 6: Gemini $schema cleanup (only dynamic step per request)
|
|
131
|
+
// <<< START: Remove $schema for Gemini compatibility >>>
|
|
132
|
+
const tools = precomputedFilteredTools.map(tool => {
|
|
133
|
+
// Check if inputSchema exists and is an object
|
|
125
134
|
if (tool.inputSchema && typeof tool.inputSchema === "object" && tool.inputSchema !== null) {
|
|
126
|
-
// $schema
|
|
135
|
+
// Remove $schema key if present
|
|
127
136
|
if ("$schema" in tool.inputSchema) {
|
|
128
|
-
//
|
|
137
|
+
// Create a new object to preserve immutability (optional but recommended)
|
|
129
138
|
const modifiedSchema = { ...tool.inputSchema };
|
|
130
139
|
delete modifiedSchema.$schema;
|
|
131
140
|
return { ...tool, inputSchema: modifiedSchema };
|
|
132
141
|
}
|
|
133
142
|
}
|
|
134
|
-
//
|
|
143
|
+
// Return as-is if no modification needed
|
|
135
144
|
return tool;
|
|
136
145
|
});
|
|
137
|
-
// <<< END:
|
|
146
|
+
// <<< END: Remove $schema for Gemini compatibility >>>
|
|
138
147
|
return {
|
|
139
|
-
tools, //
|
|
148
|
+
tools, // return tool list with $schema removed
|
|
140
149
|
};
|
|
141
150
|
});
|
|
142
151
|
serverInstance.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
@@ -234,6 +243,28 @@ function validateConfiguration() {
|
|
|
234
243
|
}
|
|
235
244
|
const GITLAB_PERSONAL_ACCESS_TOKEN = getConfig("token", "GITLAB_PERSONAL_ACCESS_TOKEN");
|
|
236
245
|
let OAUTH_ACCESS_TOKEN = null;
|
|
246
|
+
let oauthClient = null;
|
|
247
|
+
/**
|
|
248
|
+
* Ensure the OAuth token is valid before making an API call.
|
|
249
|
+
* Refreshes the token lazily (only when a tool is actually called).
|
|
250
|
+
* This avoids background timers that cause issues with multiple instances.
|
|
251
|
+
*/
|
|
252
|
+
async function ensureValidOAuthToken() {
|
|
253
|
+
if (!oauthClient)
|
|
254
|
+
return;
|
|
255
|
+
if (oauthClient.hasValidToken())
|
|
256
|
+
return;
|
|
257
|
+
try {
|
|
258
|
+
logger.info("OAuth token expired or missing, refreshing...");
|
|
259
|
+
const freshToken = await oauthClient.getAccessToken();
|
|
260
|
+
OAUTH_ACCESS_TOKEN = freshToken;
|
|
261
|
+
logger.info("OAuth token refreshed successfully");
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
logger.error("Failed to refresh OAuth token:", error);
|
|
265
|
+
throw error;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
237
268
|
const GITLAB_AUTH_COOKIE_PATH = getConfig("cookie-path", "GITLAB_AUTH_COOKIE_PATH");
|
|
238
269
|
const USE_OAUTH = getConfig("use-oauth", "GITLAB_USE_OAUTH") === "true";
|
|
239
270
|
const IS_OLD = getConfig("is-old", "GITLAB_IS_OLD") === "true";
|
|
@@ -269,6 +300,8 @@ const GITLAB_DENIED_TOOLS_REGEX = (() => {
|
|
|
269
300
|
const USE_GITLAB_WIKI = getConfig("use-wiki", "USE_GITLAB_WIKI") === "true";
|
|
270
301
|
const USE_MILESTONE = getConfig("use-milestone", "USE_MILESTONE") === "true";
|
|
271
302
|
const USE_PIPELINE = getConfig("use-pipeline", "USE_PIPELINE") === "true";
|
|
303
|
+
const GITLAB_TOOLSETS_RAW = getConfig("toolsets", "GITLAB_TOOLSETS");
|
|
304
|
+
const GITLAB_TOOLS_RAW = getConfig("tools", "GITLAB_TOOLS");
|
|
272
305
|
const SSE = getConfig("sse", "SSE") === "true";
|
|
273
306
|
const STREAMABLE_HTTP = getConfig("streamable-http", "STREAMABLE_HTTP") === "true";
|
|
274
307
|
const REMOTE_AUTHORIZATION = getConfig("remote-auth", "REMOTE_AUTHORIZATION") === "true";
|
|
@@ -315,7 +348,7 @@ httpsAgent = httpsAgent || new HttpsAgent(sslOptions);
|
|
|
315
348
|
httpAgent = httpAgent || new Agent();
|
|
316
349
|
// Initialize the client pool for managing multiple GitLab instances
|
|
317
350
|
const clientPool = new GitLabClientPool({
|
|
318
|
-
apiUrls: (
|
|
351
|
+
apiUrls: (getConfig("api-url", "GITLAB_API_URL") || "https://gitlab.com")
|
|
319
352
|
.split(",")
|
|
320
353
|
.map(normalizeGitLabApiUrl),
|
|
321
354
|
httpProxy: HTTP_PROXY,
|
|
@@ -446,7 +479,7 @@ const BASE_HEADERS = {
|
|
|
446
479
|
/**
|
|
447
480
|
* Build authentication headers dynamically based on context
|
|
448
481
|
* In REMOTE_AUTHORIZATION mode, reads from AsyncLocalStorage session context
|
|
449
|
-
* Otherwise, uses environment token
|
|
482
|
+
* Otherwise, uses environment token (OAuth token is refreshed lazily before each tool call)
|
|
450
483
|
*/
|
|
451
484
|
function buildAuthHeaders() {
|
|
452
485
|
if (REMOTE_AUTHORIZATION) {
|
|
@@ -561,7 +594,7 @@ const allTools = [
|
|
|
561
594
|
},
|
|
562
595
|
{
|
|
563
596
|
name: "get_merge_request_approval_state",
|
|
564
|
-
description: "Get
|
|
597
|
+
description: "Get merge request approval details including approvers (uses approval_state when available, falls back to approvals endpoint)",
|
|
565
598
|
inputSchema: toJSONSchema(GetMergeRequestApprovalStateSchema),
|
|
566
599
|
},
|
|
567
600
|
{
|
|
@@ -616,7 +649,7 @@ const allTools = [
|
|
|
616
649
|
},
|
|
617
650
|
{
|
|
618
651
|
name: "get_merge_request",
|
|
619
|
-
description: "Get details of a merge request (Either mergeRequestIid or branchName must be provided)",
|
|
652
|
+
description: "Get details of a merge request with compact deployment, commit addition, and approval summaries (Either mergeRequestIid or branchName must be provided)",
|
|
620
653
|
inputSchema: toJSONSchema(GetMergeRequestSchema),
|
|
621
654
|
},
|
|
622
655
|
{
|
|
@@ -904,6 +937,26 @@ const allTools = [
|
|
|
904
937
|
description: "Get details of a specific pipeline in a GitLab project",
|
|
905
938
|
inputSchema: toJSONSchema(GetPipelineSchema),
|
|
906
939
|
},
|
|
940
|
+
{
|
|
941
|
+
name: "list_deployments",
|
|
942
|
+
description: "List deployments in a GitLab project with filtering options",
|
|
943
|
+
inputSchema: toJSONSchema(ListDeploymentsSchema),
|
|
944
|
+
},
|
|
945
|
+
{
|
|
946
|
+
name: "get_deployment",
|
|
947
|
+
description: "Get details of a specific deployment in a GitLab project",
|
|
948
|
+
inputSchema: toJSONSchema(GetDeploymentSchema),
|
|
949
|
+
},
|
|
950
|
+
{
|
|
951
|
+
name: "list_environments",
|
|
952
|
+
description: "List environments in a GitLab project",
|
|
953
|
+
inputSchema: toJSONSchema(ListEnvironmentsSchema),
|
|
954
|
+
},
|
|
955
|
+
{
|
|
956
|
+
name: "get_environment",
|
|
957
|
+
description: "Get details of a specific environment in a GitLab project",
|
|
958
|
+
inputSchema: toJSONSchema(GetEnvironmentSchema),
|
|
959
|
+
},
|
|
907
960
|
{
|
|
908
961
|
name: "list_pipeline_jobs",
|
|
909
962
|
description: "List all jobs in a specific pipeline",
|
|
@@ -954,6 +1007,21 @@ const allTools = [
|
|
|
954
1007
|
description: "Cancel a running pipeline job",
|
|
955
1008
|
inputSchema: toJSONSchema(CancelPipelineJobSchema),
|
|
956
1009
|
},
|
|
1010
|
+
{
|
|
1011
|
+
name: "list_job_artifacts",
|
|
1012
|
+
description: "List artifact files in a job's artifacts archive. Returns file names, paths, types, and sizes.",
|
|
1013
|
+
inputSchema: toJSONSchema(ListJobArtifactsSchema),
|
|
1014
|
+
},
|
|
1015
|
+
{
|
|
1016
|
+
name: "download_job_artifacts",
|
|
1017
|
+
description: "Download the entire artifact archive (zip) for a job to a local path. Returns the saved file path.",
|
|
1018
|
+
inputSchema: toJSONSchema(DownloadJobArtifactsSchema),
|
|
1019
|
+
},
|
|
1020
|
+
{
|
|
1021
|
+
name: "get_job_artifact_file",
|
|
1022
|
+
description: "Get the content of a single file from a job's artifacts by its path within the archive",
|
|
1023
|
+
inputSchema: toJSONSchema(GetJobArtifactFileSchema),
|
|
1024
|
+
},
|
|
957
1025
|
{
|
|
958
1026
|
name: "list_merge_requests",
|
|
959
1027
|
description: "List merge requests. Without project_id, lists MRs assigned to the authenticated user by default (use scope='all' for all accessible MRs). With project_id, lists MRs for that specific project.",
|
|
@@ -1036,7 +1104,7 @@ const allTools = [
|
|
|
1036
1104
|
},
|
|
1037
1105
|
{
|
|
1038
1106
|
name: "download_attachment",
|
|
1039
|
-
description: "Download an uploaded file from a GitLab project by secret and filename",
|
|
1107
|
+
description: "Download an uploaded file from a GitLab project by secret and filename. Image files (png, jpg, gif, webp, svg, bmp, ico) are returned inline as base64 image content so the AI can view them directly. Non-image files are saved to disk. Use local_path to force saving image files to disk instead.",
|
|
1040
1108
|
inputSchema: toJSONSchema(DownloadAttachmentSchema),
|
|
1041
1109
|
},
|
|
1042
1110
|
{
|
|
@@ -1111,10 +1179,17 @@ const readOnlyTools = new Set([
|
|
|
1111
1179
|
"list_project_members",
|
|
1112
1180
|
"get_pipeline",
|
|
1113
1181
|
"list_pipelines",
|
|
1182
|
+
"list_deployments",
|
|
1183
|
+
"get_deployment",
|
|
1184
|
+
"list_environments",
|
|
1185
|
+
"get_environment",
|
|
1114
1186
|
"list_pipeline_jobs",
|
|
1115
1187
|
"list_pipeline_trigger_jobs",
|
|
1116
1188
|
"get_pipeline_job",
|
|
1117
1189
|
"get_pipeline_job_output",
|
|
1190
|
+
"list_job_artifacts",
|
|
1191
|
+
"download_job_artifacts",
|
|
1192
|
+
"get_job_artifact_file",
|
|
1118
1193
|
"list_labels",
|
|
1119
1194
|
"get_label",
|
|
1120
1195
|
"list_group_projects",
|
|
@@ -1165,6 +1240,10 @@ const milestoneToolNames = new Set([
|
|
|
1165
1240
|
const pipelineToolNames = new Set([
|
|
1166
1241
|
"list_pipelines",
|
|
1167
1242
|
"get_pipeline",
|
|
1243
|
+
"list_deployments",
|
|
1244
|
+
"get_deployment",
|
|
1245
|
+
"list_environments",
|
|
1246
|
+
"get_environment",
|
|
1168
1247
|
"list_pipeline_jobs",
|
|
1169
1248
|
"list_pipeline_trigger_jobs",
|
|
1170
1249
|
"get_pipeline_job",
|
|
@@ -1175,7 +1254,271 @@ const pipelineToolNames = new Set([
|
|
|
1175
1254
|
"play_pipeline_job",
|
|
1176
1255
|
"retry_pipeline_job",
|
|
1177
1256
|
"cancel_pipeline_job",
|
|
1257
|
+
"list_job_artifacts",
|
|
1258
|
+
"download_job_artifacts",
|
|
1259
|
+
"get_job_artifact_file",
|
|
1178
1260
|
]);
|
|
1261
|
+
const TOOLSET_DEFINITIONS = [
|
|
1262
|
+
{
|
|
1263
|
+
id: "merge_requests",
|
|
1264
|
+
isDefault: true,
|
|
1265
|
+
tools: new Set([
|
|
1266
|
+
"merge_merge_request",
|
|
1267
|
+
"approve_merge_request",
|
|
1268
|
+
"unapprove_merge_request",
|
|
1269
|
+
"get_merge_request_approval_state",
|
|
1270
|
+
"get_merge_request",
|
|
1271
|
+
"get_merge_request_diffs",
|
|
1272
|
+
"list_merge_request_diffs",
|
|
1273
|
+
"list_merge_request_versions",
|
|
1274
|
+
"get_merge_request_version",
|
|
1275
|
+
"update_merge_request",
|
|
1276
|
+
"create_merge_request",
|
|
1277
|
+
"list_merge_requests",
|
|
1278
|
+
"get_branch_diffs",
|
|
1279
|
+
"mr_discussions",
|
|
1280
|
+
"create_merge_request_note",
|
|
1281
|
+
"update_merge_request_note",
|
|
1282
|
+
"delete_merge_request_note",
|
|
1283
|
+
"get_merge_request_note",
|
|
1284
|
+
"get_merge_request_notes",
|
|
1285
|
+
"delete_merge_request_discussion_note",
|
|
1286
|
+
"update_merge_request_discussion_note",
|
|
1287
|
+
"create_merge_request_discussion_note",
|
|
1288
|
+
"get_draft_note",
|
|
1289
|
+
"list_draft_notes",
|
|
1290
|
+
"create_draft_note",
|
|
1291
|
+
"update_draft_note",
|
|
1292
|
+
"delete_draft_note",
|
|
1293
|
+
"publish_draft_note",
|
|
1294
|
+
"bulk_publish_draft_notes",
|
|
1295
|
+
"create_merge_request_thread",
|
|
1296
|
+
"resolve_merge_request_thread",
|
|
1297
|
+
]),
|
|
1298
|
+
},
|
|
1299
|
+
{
|
|
1300
|
+
id: "issues",
|
|
1301
|
+
isDefault: true,
|
|
1302
|
+
tools: new Set([
|
|
1303
|
+
"create_issue",
|
|
1304
|
+
"list_issues",
|
|
1305
|
+
"my_issues",
|
|
1306
|
+
"get_issue",
|
|
1307
|
+
"update_issue",
|
|
1308
|
+
"delete_issue",
|
|
1309
|
+
"create_issue_note",
|
|
1310
|
+
"update_issue_note",
|
|
1311
|
+
"list_issue_links",
|
|
1312
|
+
"list_issue_discussions",
|
|
1313
|
+
"get_issue_link",
|
|
1314
|
+
"create_issue_link",
|
|
1315
|
+
"delete_issue_link",
|
|
1316
|
+
"create_note",
|
|
1317
|
+
]),
|
|
1318
|
+
},
|
|
1319
|
+
{
|
|
1320
|
+
id: "repositories",
|
|
1321
|
+
isDefault: true,
|
|
1322
|
+
tools: new Set([
|
|
1323
|
+
"search_repositories",
|
|
1324
|
+
"create_repository",
|
|
1325
|
+
"get_file_contents",
|
|
1326
|
+
"push_files",
|
|
1327
|
+
"create_or_update_file",
|
|
1328
|
+
"fork_repository",
|
|
1329
|
+
"get_repository_tree",
|
|
1330
|
+
]),
|
|
1331
|
+
},
|
|
1332
|
+
{
|
|
1333
|
+
id: "branches",
|
|
1334
|
+
isDefault: true,
|
|
1335
|
+
tools: new Set([
|
|
1336
|
+
"create_branch",
|
|
1337
|
+
"list_commits",
|
|
1338
|
+
"get_commit",
|
|
1339
|
+
"get_commit_diff",
|
|
1340
|
+
]),
|
|
1341
|
+
},
|
|
1342
|
+
{
|
|
1343
|
+
id: "projects",
|
|
1344
|
+
isDefault: true,
|
|
1345
|
+
tools: new Set([
|
|
1346
|
+
"get_project",
|
|
1347
|
+
"list_projects",
|
|
1348
|
+
"list_project_members",
|
|
1349
|
+
"list_namespaces",
|
|
1350
|
+
"get_namespace",
|
|
1351
|
+
"verify_namespace",
|
|
1352
|
+
"list_group_projects",
|
|
1353
|
+
"list_group_iterations",
|
|
1354
|
+
]),
|
|
1355
|
+
},
|
|
1356
|
+
{
|
|
1357
|
+
id: "labels",
|
|
1358
|
+
isDefault: true,
|
|
1359
|
+
tools: new Set([
|
|
1360
|
+
"list_labels",
|
|
1361
|
+
"get_label",
|
|
1362
|
+
"create_label",
|
|
1363
|
+
"update_label",
|
|
1364
|
+
"delete_label",
|
|
1365
|
+
]),
|
|
1366
|
+
},
|
|
1367
|
+
{
|
|
1368
|
+
id: "pipelines",
|
|
1369
|
+
isDefault: true,
|
|
1370
|
+
tools: new Set([
|
|
1371
|
+
"list_pipelines",
|
|
1372
|
+
"get_pipeline",
|
|
1373
|
+
"list_deployments",
|
|
1374
|
+
"get_deployment",
|
|
1375
|
+
"list_environments",
|
|
1376
|
+
"get_environment",
|
|
1377
|
+
"list_pipeline_jobs",
|
|
1378
|
+
"list_pipeline_trigger_jobs",
|
|
1379
|
+
"get_pipeline_job",
|
|
1380
|
+
"get_pipeline_job_output",
|
|
1381
|
+
"create_pipeline",
|
|
1382
|
+
"retry_pipeline",
|
|
1383
|
+
"cancel_pipeline",
|
|
1384
|
+
"play_pipeline_job",
|
|
1385
|
+
"retry_pipeline_job",
|
|
1386
|
+
"cancel_pipeline_job",
|
|
1387
|
+
"list_job_artifacts",
|
|
1388
|
+
"download_job_artifacts",
|
|
1389
|
+
"get_job_artifact_file",
|
|
1390
|
+
]),
|
|
1391
|
+
},
|
|
1392
|
+
{
|
|
1393
|
+
id: "milestones",
|
|
1394
|
+
isDefault: true,
|
|
1395
|
+
tools: new Set([
|
|
1396
|
+
"list_milestones",
|
|
1397
|
+
"get_milestone",
|
|
1398
|
+
"create_milestone",
|
|
1399
|
+
"edit_milestone",
|
|
1400
|
+
"delete_milestone",
|
|
1401
|
+
"get_milestone_issue",
|
|
1402
|
+
"get_milestone_merge_requests",
|
|
1403
|
+
"promote_milestone",
|
|
1404
|
+
"get_milestone_burndown_events",
|
|
1405
|
+
]),
|
|
1406
|
+
},
|
|
1407
|
+
{
|
|
1408
|
+
id: "wiki",
|
|
1409
|
+
isDefault: true,
|
|
1410
|
+
tools: new Set([
|
|
1411
|
+
"list_wiki_pages",
|
|
1412
|
+
"get_wiki_page",
|
|
1413
|
+
"create_wiki_page",
|
|
1414
|
+
"update_wiki_page",
|
|
1415
|
+
"delete_wiki_page",
|
|
1416
|
+
]),
|
|
1417
|
+
},
|
|
1418
|
+
{
|
|
1419
|
+
id: "releases",
|
|
1420
|
+
isDefault: true,
|
|
1421
|
+
tools: new Set([
|
|
1422
|
+
"list_releases",
|
|
1423
|
+
"get_release",
|
|
1424
|
+
"create_release",
|
|
1425
|
+
"update_release",
|
|
1426
|
+
"delete_release",
|
|
1427
|
+
"create_release_evidence",
|
|
1428
|
+
"download_release_asset",
|
|
1429
|
+
]),
|
|
1430
|
+
},
|
|
1431
|
+
{
|
|
1432
|
+
id: "users",
|
|
1433
|
+
isDefault: true,
|
|
1434
|
+
tools: new Set([
|
|
1435
|
+
"get_users",
|
|
1436
|
+
"list_events",
|
|
1437
|
+
"get_project_events",
|
|
1438
|
+
"upload_markdown",
|
|
1439
|
+
"download_attachment",
|
|
1440
|
+
]),
|
|
1441
|
+
},
|
|
1442
|
+
];
|
|
1443
|
+
// Derived lookup: tool name → toolset ID
|
|
1444
|
+
const TOOLSET_BY_TOOL_NAME = new Map();
|
|
1445
|
+
for (const def of TOOLSET_DEFINITIONS) {
|
|
1446
|
+
for (const tool of def.tools) {
|
|
1447
|
+
if (TOOLSET_BY_TOOL_NAME.has(tool)) {
|
|
1448
|
+
logger.warn(`Tool "${tool}" is defined in multiple toolsets: "${TOOLSET_BY_TOOL_NAME.get(tool)}" and "${def.id}"`);
|
|
1449
|
+
}
|
|
1450
|
+
TOOLSET_BY_TOOL_NAME.set(tool, def.id);
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
const DEFAULT_TOOLSET_IDS = new Set(TOOLSET_DEFINITIONS.filter(d => d.isDefault).map(d => d.id));
|
|
1454
|
+
const ALL_TOOLSET_IDS = new Set(TOOLSET_DEFINITIONS.map(d => d.id));
|
|
1455
|
+
function parseEnabledToolsets(raw) {
|
|
1456
|
+
if (!raw || raw.trim() === "") {
|
|
1457
|
+
return DEFAULT_TOOLSET_IDS;
|
|
1458
|
+
}
|
|
1459
|
+
const trimmed = raw.trim().toLowerCase();
|
|
1460
|
+
if (trimmed === "all") {
|
|
1461
|
+
return ALL_TOOLSET_IDS;
|
|
1462
|
+
}
|
|
1463
|
+
const selected = new Set(trimmed
|
|
1464
|
+
.split(",")
|
|
1465
|
+
.map(s => s.trim())
|
|
1466
|
+
.filter((s) => ALL_TOOLSET_IDS.has(s)));
|
|
1467
|
+
if (selected.size === 0) {
|
|
1468
|
+
logger.warn(`No valid toolsets found in configuration (${raw}). Falling back to default toolsets.`);
|
|
1469
|
+
return DEFAULT_TOOLSET_IDS;
|
|
1470
|
+
}
|
|
1471
|
+
return selected;
|
|
1472
|
+
}
|
|
1473
|
+
function parseIndividualTools(raw) {
|
|
1474
|
+
if (!raw || raw.trim() === "") {
|
|
1475
|
+
return new Set();
|
|
1476
|
+
}
|
|
1477
|
+
const allToolNames = new Set(allTools.map((t) => t.name));
|
|
1478
|
+
const parsed = raw
|
|
1479
|
+
.trim()
|
|
1480
|
+
.split(",")
|
|
1481
|
+
.map(s => s.trim().toLowerCase())
|
|
1482
|
+
.filter(Boolean);
|
|
1483
|
+
const unknown = parsed.filter(name => !allToolNames.has(name));
|
|
1484
|
+
if (unknown.length > 0) {
|
|
1485
|
+
logger.warn(`Unknown tool names in GITLAB_TOOLS (will be ignored): ${unknown.join(", ")}`);
|
|
1486
|
+
}
|
|
1487
|
+
return new Set(parsed);
|
|
1488
|
+
}
|
|
1489
|
+
function buildFeatureFlagOverrides() {
|
|
1490
|
+
const overrides = new Set();
|
|
1491
|
+
if (USE_GITLAB_WIKI) {
|
|
1492
|
+
for (const t of wikiToolNames)
|
|
1493
|
+
overrides.add(t);
|
|
1494
|
+
}
|
|
1495
|
+
if (USE_MILESTONE) {
|
|
1496
|
+
for (const t of milestoneToolNames)
|
|
1497
|
+
overrides.add(t);
|
|
1498
|
+
}
|
|
1499
|
+
if (USE_PIPELINE) {
|
|
1500
|
+
for (const t of pipelineToolNames)
|
|
1501
|
+
overrides.add(t);
|
|
1502
|
+
}
|
|
1503
|
+
return overrides;
|
|
1504
|
+
}
|
|
1505
|
+
function isToolInEnabledToolset(toolName, enabledToolsets) {
|
|
1506
|
+
const toolsetId = TOOLSET_BY_TOOL_NAME.get(toolName);
|
|
1507
|
+
// Tools not in any toolset (e.g. execute_graphql) are excluded by default
|
|
1508
|
+
if (toolsetId === undefined)
|
|
1509
|
+
return false;
|
|
1510
|
+
return enabledToolsets.has(toolsetId);
|
|
1511
|
+
}
|
|
1512
|
+
// Compute at startup
|
|
1513
|
+
const enabledToolsets = parseEnabledToolsets(GITLAB_TOOLSETS_RAW);
|
|
1514
|
+
const individuallyEnabledTools = parseIndividualTools(GITLAB_TOOLS_RAW);
|
|
1515
|
+
const featureFlagOverrides = buildFeatureFlagOverrides();
|
|
1516
|
+
// Warn about potentially confusing configuration
|
|
1517
|
+
if (GITLAB_TOOLSETS_RAW && (USE_PIPELINE || USE_MILESTONE || USE_GITLAB_WIKI)) {
|
|
1518
|
+
logger.warn("GITLAB_TOOLSETS is set alongside legacy flags (USE_PIPELINE, USE_MILESTONE, USE_GITLAB_WIKI). " +
|
|
1519
|
+
"Legacy flags add tools additively on top of the toolset selection and may produce unexpected results.");
|
|
1520
|
+
}
|
|
1521
|
+
const MERGE_REQUEST_DEPLOYMENT_SUMMARY_LIMIT = 10;
|
|
1179
1522
|
/**
|
|
1180
1523
|
* Smart URL handling for GitLab API
|
|
1181
1524
|
*
|
|
@@ -1196,7 +1539,7 @@ function normalizeGitLabApiUrl(url) {
|
|
|
1196
1539
|
return normalizedUrl;
|
|
1197
1540
|
}
|
|
1198
1541
|
// Use the normalizeGitLabApiUrl function to handle various URL formats
|
|
1199
|
-
const GITLAB_API_URLS = (
|
|
1542
|
+
const GITLAB_API_URLS = (getConfig("api-url", "GITLAB_API_URL") || "https://gitlab.com")
|
|
1200
1543
|
.split(",")
|
|
1201
1544
|
.map(normalizeGitLabApiUrl);
|
|
1202
1545
|
const GITLAB_API_URL = GITLAB_API_URLS[0];
|
|
@@ -1292,7 +1635,7 @@ async function forkProject(projectId, namespace) {
|
|
|
1292
1635
|
...getFetchConfig(),
|
|
1293
1636
|
method: "POST",
|
|
1294
1637
|
});
|
|
1295
|
-
//
|
|
1638
|
+
// Handle case where project already exists
|
|
1296
1639
|
if (response.status === 409) {
|
|
1297
1640
|
throw new Error("Project already exists in the target namespace");
|
|
1298
1641
|
}
|
|
@@ -1351,26 +1694,26 @@ async function getDefaultBranchRef(projectId) {
|
|
|
1351
1694
|
* @returns {Promise<GitLabContent>} The file content
|
|
1352
1695
|
*/
|
|
1353
1696
|
async function getFileContents(projectId, filePath, ref) {
|
|
1354
|
-
|
|
1355
|
-
const effectiveProjectId = getEffectiveProjectId(
|
|
1697
|
+
const decodedProjectId = projectId ? decodeURIComponent(projectId) : "";
|
|
1698
|
+
const effectiveProjectId = getEffectiveProjectId(decodedProjectId);
|
|
1356
1699
|
const encodedPath = encodeURIComponent(filePath);
|
|
1357
|
-
//
|
|
1700
|
+
// Fall back to default branch if ref is not provided
|
|
1358
1701
|
if (!ref) {
|
|
1359
|
-
ref = await getDefaultBranchRef(
|
|
1702
|
+
ref = await getDefaultBranchRef(decodedProjectId);
|
|
1360
1703
|
}
|
|
1361
1704
|
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/repository/files/${encodedPath}`);
|
|
1362
1705
|
url.searchParams.append("ref", ref);
|
|
1363
1706
|
const response = await fetch(url.toString(), {
|
|
1364
1707
|
...getFetchConfig(),
|
|
1365
1708
|
});
|
|
1366
|
-
//
|
|
1709
|
+
// Handle file not found
|
|
1367
1710
|
if (response.status === 404) {
|
|
1368
1711
|
throw new Error(`File not found: ${filePath}`);
|
|
1369
1712
|
}
|
|
1370
1713
|
await handleGitLabError(response);
|
|
1371
1714
|
const data = await response.json();
|
|
1372
1715
|
const parsedData = GitLabContentSchema.parse(data);
|
|
1373
|
-
// Base64
|
|
1716
|
+
// Decode Base64-encoded file content to UTF-8
|
|
1374
1717
|
if (!Array.isArray(parsedData) && parsedData.content) {
|
|
1375
1718
|
parsedData.content = Buffer.from(parsedData.content, "base64").toString("utf8");
|
|
1376
1719
|
parsedData.encoding = "utf8";
|
|
@@ -1400,7 +1743,7 @@ async function createIssue(projectId, options) {
|
|
|
1400
1743
|
labels: options.labels?.join(","),
|
|
1401
1744
|
}),
|
|
1402
1745
|
});
|
|
1403
|
-
//
|
|
1746
|
+
// Handle bad request
|
|
1404
1747
|
if (response.status === 400) {
|
|
1405
1748
|
const errorBody = await response.text();
|
|
1406
1749
|
throw new Error(`Invalid request: ${errorBody}`);
|
|
@@ -2190,6 +2533,7 @@ async function getMergeRequest(projectId, mergeRequestIid, branchName) {
|
|
|
2190
2533
|
let url;
|
|
2191
2534
|
if (mergeRequestIid) {
|
|
2192
2535
|
url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}`);
|
|
2536
|
+
url.searchParams.append("include_diverged_commits_count", "true");
|
|
2193
2537
|
}
|
|
2194
2538
|
else if (branchName) {
|
|
2195
2539
|
url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests?source_branch=${encodeURIComponent(branchName)}`);
|
|
@@ -2204,10 +2548,219 @@ async function getMergeRequest(projectId, mergeRequestIid, branchName) {
|
|
|
2204
2548
|
const data = await response.json();
|
|
2205
2549
|
// If response is an array (Comes from branchName search), return the first item if exist
|
|
2206
2550
|
if (Array.isArray(data) && data.length > 0) {
|
|
2207
|
-
|
|
2551
|
+
const mergeRequest = GitLabMergeRequestSchema.parse(data[0]);
|
|
2552
|
+
return getMergeRequest(projectId, mergeRequest.iid, undefined);
|
|
2208
2553
|
}
|
|
2209
2554
|
return GitLabMergeRequestSchema.parse(data);
|
|
2210
2555
|
}
|
|
2556
|
+
async function getMergeRequestSourceCommitCount(projectId, mergeRequestIid) {
|
|
2557
|
+
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/commits`);
|
|
2558
|
+
url.searchParams.append("per_page", "100");
|
|
2559
|
+
let totalCount = 0;
|
|
2560
|
+
let page = 1;
|
|
2561
|
+
while (true) {
|
|
2562
|
+
url.searchParams.set("page", String(page));
|
|
2563
|
+
const response = await fetch(url.toString(), {
|
|
2564
|
+
...getFetchConfig(),
|
|
2565
|
+
});
|
|
2566
|
+
await handleGitLabError(response);
|
|
2567
|
+
const data = await response.json();
|
|
2568
|
+
if (!Array.isArray(data)) {
|
|
2569
|
+
throw new Error("Unexpected merge request commits response format");
|
|
2570
|
+
}
|
|
2571
|
+
totalCount += data.length;
|
|
2572
|
+
const nextPage = response.headers.get("x-next-page");
|
|
2573
|
+
if (!nextPage) {
|
|
2574
|
+
break;
|
|
2575
|
+
}
|
|
2576
|
+
page = Number.parseInt(nextPage, 10);
|
|
2577
|
+
if (Number.isNaN(page) || page <= 0) {
|
|
2578
|
+
break;
|
|
2579
|
+
}
|
|
2580
|
+
}
|
|
2581
|
+
return totalCount;
|
|
2582
|
+
}
|
|
2583
|
+
async function getProjectMergeMethod(projectId) {
|
|
2584
|
+
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}`);
|
|
2585
|
+
const response = await fetch(url.toString(), {
|
|
2586
|
+
...getFetchConfig(),
|
|
2587
|
+
});
|
|
2588
|
+
await handleGitLabError(response);
|
|
2589
|
+
const data = await response.json();
|
|
2590
|
+
const mergeMethod = z
|
|
2591
|
+
.object({
|
|
2592
|
+
merge_method: z.string().nullable().optional(),
|
|
2593
|
+
})
|
|
2594
|
+
.parse(data).merge_method;
|
|
2595
|
+
return typeof mergeMethod === "string" ? mergeMethod : null;
|
|
2596
|
+
}
|
|
2597
|
+
function estimateMergeCommitCount(mergeMethod, sourceCommitCount) {
|
|
2598
|
+
if (sourceCommitCount === 0) {
|
|
2599
|
+
return 0;
|
|
2600
|
+
}
|
|
2601
|
+
if (mergeMethod === "merge") {
|
|
2602
|
+
return 1;
|
|
2603
|
+
}
|
|
2604
|
+
if (mergeMethod === "ff" || mergeMethod === "rebase_merge") {
|
|
2605
|
+
return 0;
|
|
2606
|
+
}
|
|
2607
|
+
return null;
|
|
2608
|
+
}
|
|
2609
|
+
async function buildMergeRequestCommitAdditionSummary(projectId, mergeRequest) {
|
|
2610
|
+
try {
|
|
2611
|
+
const sourceCommitCount = await getMergeRequestSourceCommitCount(projectId, mergeRequest.iid);
|
|
2612
|
+
const mergeMethod = await getProjectMergeMethod(projectId);
|
|
2613
|
+
const mergeCommitCount = estimateMergeCommitCount(mergeMethod, sourceCommitCount);
|
|
2614
|
+
const summary = mergeCommitCount === null
|
|
2615
|
+
? null
|
|
2616
|
+
: `${sourceCommitCount} commits and ${mergeCommitCount} merge commit${mergeCommitCount === 1 ? "" : "s"} will be added to ${mergeRequest.target_branch}.`;
|
|
2617
|
+
return {
|
|
2618
|
+
target_branch: mergeRequest.target_branch,
|
|
2619
|
+
source_commits_count: sourceCommitCount,
|
|
2620
|
+
merge_method: mergeMethod,
|
|
2621
|
+
merge_commit_count: mergeCommitCount,
|
|
2622
|
+
summary,
|
|
2623
|
+
};
|
|
2624
|
+
}
|
|
2625
|
+
catch (error) {
|
|
2626
|
+
const unavailableReason = error instanceof Error ? error.message : String(error);
|
|
2627
|
+
return {
|
|
2628
|
+
target_branch: mergeRequest.target_branch,
|
|
2629
|
+
source_commits_count: null,
|
|
2630
|
+
merge_method: null,
|
|
2631
|
+
merge_commit_count: null,
|
|
2632
|
+
summary: null,
|
|
2633
|
+
unavailable_reason: unavailableReason,
|
|
2634
|
+
};
|
|
2635
|
+
}
|
|
2636
|
+
}
|
|
2637
|
+
async function buildMergeRequestApprovalSummary(projectId, mergeRequestIid) {
|
|
2638
|
+
try {
|
|
2639
|
+
const approvalState = await getMergeRequestApprovalState(projectId, mergeRequestIid);
|
|
2640
|
+
const approvedByUsers = approvalState.approved_by || [];
|
|
2641
|
+
const approvedByUsernames = approvalState.approved_by_usernames || approvedByUsers.map(user => user.username);
|
|
2642
|
+
const inferredApproved = inferMergeRequestApproved(approvalState.rules);
|
|
2643
|
+
return {
|
|
2644
|
+
approved: approvalState.approved ?? inferredApproved,
|
|
2645
|
+
user_has_approved: approvalState.user_has_approved ?? null,
|
|
2646
|
+
user_can_approve: approvalState.user_can_approve ?? null,
|
|
2647
|
+
approved_by: approvedByUsers,
|
|
2648
|
+
approved_by_usernames: approvedByUsernames,
|
|
2649
|
+
rules_count: approvalState.rules?.length ?? null,
|
|
2650
|
+
source_endpoint: approvalState.source_endpoint ?? null,
|
|
2651
|
+
};
|
|
2652
|
+
}
|
|
2653
|
+
catch (error) {
|
|
2654
|
+
const unavailableReason = error instanceof Error ? error.message : String(error);
|
|
2655
|
+
return {
|
|
2656
|
+
approved: null,
|
|
2657
|
+
user_has_approved: null,
|
|
2658
|
+
user_can_approve: null,
|
|
2659
|
+
approved_by: [],
|
|
2660
|
+
approved_by_usernames: [],
|
|
2661
|
+
rules_count: null,
|
|
2662
|
+
source_endpoint: null,
|
|
2663
|
+
unavailable_reason: unavailableReason,
|
|
2664
|
+
};
|
|
2665
|
+
}
|
|
2666
|
+
}
|
|
2667
|
+
function toMergeRequestDeploymentSummaryRecord(deployment) {
|
|
2668
|
+
return {
|
|
2669
|
+
id: deployment.id,
|
|
2670
|
+
status: deployment.status,
|
|
2671
|
+
ref: deployment.ref,
|
|
2672
|
+
sha: deployment.sha,
|
|
2673
|
+
created_at: deployment.created_at,
|
|
2674
|
+
updated_at: deployment.updated_at,
|
|
2675
|
+
finished_at: deployment.finished_at,
|
|
2676
|
+
web_url: deployment.web_url,
|
|
2677
|
+
environment: deployment.environment
|
|
2678
|
+
? {
|
|
2679
|
+
id: deployment.environment.id,
|
|
2680
|
+
name: deployment.environment.name,
|
|
2681
|
+
slug: deployment.environment.slug,
|
|
2682
|
+
external_url: deployment.environment.external_url,
|
|
2683
|
+
state: deployment.environment.state,
|
|
2684
|
+
tier: deployment.environment.tier,
|
|
2685
|
+
}
|
|
2686
|
+
: undefined,
|
|
2687
|
+
deployable: deployment.deployable === null
|
|
2688
|
+
? null
|
|
2689
|
+
: deployment.deployable
|
|
2690
|
+
? {
|
|
2691
|
+
id: deployment.deployable.id,
|
|
2692
|
+
name: deployment.deployable.name,
|
|
2693
|
+
status: deployment.deployable.status,
|
|
2694
|
+
stage: deployment.deployable.stage,
|
|
2695
|
+
web_url: deployment.deployable.web_url,
|
|
2696
|
+
pipeline: deployment.deployable.pipeline
|
|
2697
|
+
? {
|
|
2698
|
+
id: deployment.deployable.pipeline.id,
|
|
2699
|
+
status: deployment.deployable.pipeline.status,
|
|
2700
|
+
ref: deployment.deployable.pipeline.ref,
|
|
2701
|
+
sha: deployment.deployable.pipeline.sha,
|
|
2702
|
+
web_url: deployment.deployable.pipeline.web_url,
|
|
2703
|
+
}
|
|
2704
|
+
: undefined,
|
|
2705
|
+
}
|
|
2706
|
+
: undefined,
|
|
2707
|
+
};
|
|
2708
|
+
}
|
|
2709
|
+
function sortDeploymentsByCreatedAtDesc(deployments) {
|
|
2710
|
+
return [...deployments].sort((a, b) => {
|
|
2711
|
+
const aTime = Date.parse(a.created_at);
|
|
2712
|
+
const bTime = Date.parse(b.created_at);
|
|
2713
|
+
if (Number.isNaN(aTime) || Number.isNaN(bTime)) {
|
|
2714
|
+
return b.created_at.localeCompare(a.created_at);
|
|
2715
|
+
}
|
|
2716
|
+
return bTime - aTime;
|
|
2717
|
+
});
|
|
2718
|
+
}
|
|
2719
|
+
async function buildMergeRequestDeploymentSummary(projectId, mergeRequest) {
|
|
2720
|
+
const lookupSha = mergeRequest.merge_commit_sha ?? mergeRequest.diff_refs?.head_sha ?? null;
|
|
2721
|
+
if (!lookupSha) {
|
|
2722
|
+
return {
|
|
2723
|
+
lookup_sha: null,
|
|
2724
|
+
sort: "created_at_desc",
|
|
2725
|
+
limit: MERGE_REQUEST_DEPLOYMENT_SUMMARY_LIMIT,
|
|
2726
|
+
total_count: 0,
|
|
2727
|
+
returned_count: 0,
|
|
2728
|
+
records: [],
|
|
2729
|
+
};
|
|
2730
|
+
}
|
|
2731
|
+
try {
|
|
2732
|
+
const deployments = await listDeployments(projectId, {
|
|
2733
|
+
sha: lookupSha,
|
|
2734
|
+
order_by: "created_at",
|
|
2735
|
+
sort: "desc",
|
|
2736
|
+
per_page: 100,
|
|
2737
|
+
});
|
|
2738
|
+
const sortedDeployments = sortDeploymentsByCreatedAtDesc(deployments);
|
|
2739
|
+
const records = sortedDeployments
|
|
2740
|
+
.slice(0, MERGE_REQUEST_DEPLOYMENT_SUMMARY_LIMIT)
|
|
2741
|
+
.map(toMergeRequestDeploymentSummaryRecord);
|
|
2742
|
+
return {
|
|
2743
|
+
lookup_sha: lookupSha,
|
|
2744
|
+
sort: "created_at_desc",
|
|
2745
|
+
limit: MERGE_REQUEST_DEPLOYMENT_SUMMARY_LIMIT,
|
|
2746
|
+
total_count: sortedDeployments.length,
|
|
2747
|
+
returned_count: records.length,
|
|
2748
|
+
records,
|
|
2749
|
+
};
|
|
2750
|
+
}
|
|
2751
|
+
catch (error) {
|
|
2752
|
+
const unavailableReason = error instanceof Error ? error.message : String(error);
|
|
2753
|
+
return {
|
|
2754
|
+
lookup_sha: lookupSha,
|
|
2755
|
+
sort: "created_at_desc",
|
|
2756
|
+
limit: MERGE_REQUEST_DEPLOYMENT_SUMMARY_LIMIT,
|
|
2757
|
+
total_count: 0,
|
|
2758
|
+
returned_count: 0,
|
|
2759
|
+
records: [],
|
|
2760
|
+
unavailable_reason: unavailableReason,
|
|
2761
|
+
};
|
|
2762
|
+
}
|
|
2763
|
+
}
|
|
2211
2764
|
/**
|
|
2212
2765
|
* Get merge request changes/diffs
|
|
2213
2766
|
* MR 변경사항 조회 함수 (Function to retrieve merge request changes)
|
|
@@ -2402,13 +2955,61 @@ async function unapproveMergeRequest(projectId, mergeRequestIid) {
|
|
|
2402
2955
|
*/
|
|
2403
2956
|
async function getMergeRequestApprovalState(projectId, mergeRequestIid) {
|
|
2404
2957
|
projectId = decodeURIComponent(projectId);
|
|
2405
|
-
const
|
|
2406
|
-
const
|
|
2958
|
+
const approvalStateUrl = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/approval_state`);
|
|
2959
|
+
const approvalStateResponse = await fetch(approvalStateUrl.toString(), {
|
|
2407
2960
|
...getFetchConfig(),
|
|
2408
2961
|
method: "GET",
|
|
2409
2962
|
});
|
|
2410
|
-
|
|
2411
|
-
|
|
2963
|
+
if (approvalStateResponse.status === 404) {
|
|
2964
|
+
return getMergeRequestApprovalsFallback(projectId, mergeRequestIid);
|
|
2965
|
+
}
|
|
2966
|
+
await handleGitLabError(approvalStateResponse);
|
|
2967
|
+
const parsedApprovalState = GitLabMergeRequestApprovalStateSchema.parse(await approvalStateResponse.json());
|
|
2968
|
+
const approvedByUsers = getUniqueApprovalUsers((parsedApprovalState.rules || []).flatMap(rule => rule.approved_by || []));
|
|
2969
|
+
const approvedByUsernames = approvedByUsers.map(user => user.username);
|
|
2970
|
+
return {
|
|
2971
|
+
...parsedApprovalState,
|
|
2972
|
+
approved_by: approvedByUsers,
|
|
2973
|
+
approved_by_usernames: approvedByUsernames,
|
|
2974
|
+
source_endpoint: "approval_state",
|
|
2975
|
+
};
|
|
2976
|
+
}
|
|
2977
|
+
async function getMergeRequestApprovalsFallback(projectId, mergeRequestIid) {
|
|
2978
|
+
const approvalsUrl = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/approvals`);
|
|
2979
|
+
const approvalsResponse = await fetch(approvalsUrl.toString(), {
|
|
2980
|
+
...getFetchConfig(),
|
|
2981
|
+
method: "GET",
|
|
2982
|
+
});
|
|
2983
|
+
await handleGitLabError(approvalsResponse);
|
|
2984
|
+
const parsedApprovals = GitLabMergeRequestApprovalsResponseSchema.parse(await approvalsResponse.json());
|
|
2985
|
+
const approvedByUsers = getUniqueApprovalUsers((parsedApprovals.approved_by || []).map(approvedByEntry => approvedByEntry.user));
|
|
2986
|
+
const approvedByUsernames = approvedByUsers.map(user => user.username);
|
|
2987
|
+
return GitLabMergeRequestApprovalStateSchema.parse({
|
|
2988
|
+
approved: parsedApprovals.approved,
|
|
2989
|
+
user_has_approved: parsedApprovals.user_has_approved,
|
|
2990
|
+
user_can_approve: parsedApprovals.user_can_approve,
|
|
2991
|
+
approved_by: approvedByUsers,
|
|
2992
|
+
approved_by_usernames: approvedByUsernames,
|
|
2993
|
+
source_endpoint: "approvals",
|
|
2994
|
+
});
|
|
2995
|
+
}
|
|
2996
|
+
function getUniqueApprovalUsers(users) {
|
|
2997
|
+
const uniqueUsers = new Map();
|
|
2998
|
+
for (const user of users) {
|
|
2999
|
+
if (!uniqueUsers.has(user.id)) {
|
|
3000
|
+
uniqueUsers.set(user.id, user);
|
|
3001
|
+
}
|
|
3002
|
+
}
|
|
3003
|
+
return [...uniqueUsers.values()];
|
|
3004
|
+
}
|
|
3005
|
+
function inferMergeRequestApproved(rules) {
|
|
3006
|
+
if (!rules || rules.length === 0) {
|
|
3007
|
+
return null;
|
|
3008
|
+
}
|
|
3009
|
+
if (rules.some(rule => typeof rule.approved !== "boolean")) {
|
|
3010
|
+
return null;
|
|
3011
|
+
}
|
|
3012
|
+
return rules.every(rule => rule.approved === true);
|
|
2412
3013
|
}
|
|
2413
3014
|
/**
|
|
2414
3015
|
* Create a new note (comment) on an issue or merge request
|
|
@@ -2421,10 +3022,10 @@ async function getMergeRequestApprovalState(projectId, mergeRequestIid) {
|
|
|
2421
3022
|
* @param {string} body - The content of the note
|
|
2422
3023
|
* @returns {Promise<any>} The created note
|
|
2423
3024
|
*/
|
|
2424
|
-
async function createNote(projectId, noteableType, // 'issue'
|
|
3025
|
+
async function createNote(projectId, noteableType, // specifies 'issue' or 'merge_request' type
|
|
2425
3026
|
noteableIid, body) {
|
|
2426
3027
|
projectId = decodeURIComponent(projectId); // Decode project ID
|
|
2427
|
-
// ⚙️
|
|
3028
|
+
// ⚙️ Response type can be adjusted according to the GitLab API documentation
|
|
2428
3029
|
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/${noteableType}s/${noteableIid}/notes` // Using plural form (issues/merge_requests) as per GitLab API documentation
|
|
2429
3030
|
);
|
|
2430
3031
|
const response = await fetch(url.toString(), {
|
|
@@ -2472,14 +3073,18 @@ async function listDraftNotes(projectId, mergeRequestIid) {
|
|
|
2472
3073
|
* @param {string} projectId - The ID or URL-encoded path of the project
|
|
2473
3074
|
* @param {number|string} mergeRequestIid - The internal ID of the merge request
|
|
2474
3075
|
* @param {string} body - The content of the draft note
|
|
3076
|
+
* @param {string} [inReplyToDiscussionId] - The ID of a discussion the draft note replies to
|
|
2475
3077
|
* @param {MergeRequestThreadPosition} [position] - Position information for diff notes
|
|
2476
3078
|
* @param {boolean} [resolveDiscussion] - Whether to resolve the discussion when publishing
|
|
2477
3079
|
* @returns {Promise<GitLabDraftNote>} The created draft note
|
|
2478
3080
|
*/
|
|
2479
|
-
async function createDraftNote(projectId, mergeRequestIid, body, position, resolveDiscussion) {
|
|
3081
|
+
async function createDraftNote(projectId, mergeRequestIid, body, inReplyToDiscussionId, position, resolveDiscussion) {
|
|
2480
3082
|
projectId = decodeURIComponent(projectId);
|
|
2481
3083
|
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/draft_notes`);
|
|
2482
3084
|
const requestBody = { note: body };
|
|
3085
|
+
if (inReplyToDiscussionId) {
|
|
3086
|
+
requestBody.in_reply_to_discussion_id = inReplyToDiscussionId;
|
|
3087
|
+
}
|
|
2483
3088
|
if (position) {
|
|
2484
3089
|
requestBody.position = position;
|
|
2485
3090
|
}
|
|
@@ -3141,6 +3746,90 @@ async function getPipeline(projectId, pipelineId) {
|
|
|
3141
3746
|
const data = await response.json();
|
|
3142
3747
|
return GitLabPipelineSchema.parse(data);
|
|
3143
3748
|
}
|
|
3749
|
+
/**
|
|
3750
|
+
* List deployments in a GitLab project
|
|
3751
|
+
*
|
|
3752
|
+
* @param {string} projectId - The ID or URL-encoded path of the project
|
|
3753
|
+
* @param {ListDeploymentsOptions} options - Options for filtering deployments
|
|
3754
|
+
* @returns {Promise<GitLabDeployment[]>} List of deployments
|
|
3755
|
+
*/
|
|
3756
|
+
async function listDeployments(projectId, options = {}) {
|
|
3757
|
+
projectId = decodeURIComponent(projectId);
|
|
3758
|
+
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/deployments`);
|
|
3759
|
+
Object.entries(options).forEach(([key, value]) => {
|
|
3760
|
+
if (value !== undefined) {
|
|
3761
|
+
url.searchParams.append(key, value.toString());
|
|
3762
|
+
}
|
|
3763
|
+
});
|
|
3764
|
+
const response = await fetch(url.toString(), {
|
|
3765
|
+
...getFetchConfig(),
|
|
3766
|
+
});
|
|
3767
|
+
await handleGitLabError(response);
|
|
3768
|
+
const data = await response.json();
|
|
3769
|
+
return z.array(GitLabDeploymentSchema).parse(data);
|
|
3770
|
+
}
|
|
3771
|
+
/**
|
|
3772
|
+
* Get details of a specific deployment
|
|
3773
|
+
*
|
|
3774
|
+
* @param {string} projectId - The ID or URL-encoded path of the project
|
|
3775
|
+
* @param {number | string} deploymentId - The ID of the deployment
|
|
3776
|
+
* @returns {Promise<GitLabDeployment>} Deployment details
|
|
3777
|
+
*/
|
|
3778
|
+
async function getDeployment(projectId, deploymentId) {
|
|
3779
|
+
projectId = decodeURIComponent(projectId);
|
|
3780
|
+
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/deployments/${deploymentId}`);
|
|
3781
|
+
const response = await fetch(url.toString(), {
|
|
3782
|
+
...getFetchConfig(),
|
|
3783
|
+
});
|
|
3784
|
+
if (response.status === 404) {
|
|
3785
|
+
throw new Error(`Deployment not found`);
|
|
3786
|
+
}
|
|
3787
|
+
await handleGitLabError(response);
|
|
3788
|
+
const data = await response.json();
|
|
3789
|
+
return GitLabDeploymentSchema.parse(data);
|
|
3790
|
+
}
|
|
3791
|
+
/**
|
|
3792
|
+
* List environments in a GitLab project
|
|
3793
|
+
*
|
|
3794
|
+
* @param {string} projectId - The ID or URL-encoded path of the project
|
|
3795
|
+
* @param {ListEnvironmentsOptions} options - Options for filtering environments
|
|
3796
|
+
* @returns {Promise<GitLabEnvironment[]>} List of environments
|
|
3797
|
+
*/
|
|
3798
|
+
async function listEnvironments(projectId, options = {}) {
|
|
3799
|
+
projectId = decodeURIComponent(projectId);
|
|
3800
|
+
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/environments`);
|
|
3801
|
+
Object.entries(options).forEach(([key, value]) => {
|
|
3802
|
+
if (value !== undefined) {
|
|
3803
|
+
url.searchParams.append(key, value.toString());
|
|
3804
|
+
}
|
|
3805
|
+
});
|
|
3806
|
+
const response = await fetch(url.toString(), {
|
|
3807
|
+
...getFetchConfig(),
|
|
3808
|
+
});
|
|
3809
|
+
await handleGitLabError(response);
|
|
3810
|
+
const data = await response.json();
|
|
3811
|
+
return z.array(GitLabEnvironmentSchema).parse(data);
|
|
3812
|
+
}
|
|
3813
|
+
/**
|
|
3814
|
+
* Get details of a specific environment
|
|
3815
|
+
*
|
|
3816
|
+
* @param {string} projectId - The ID or URL-encoded path of the project
|
|
3817
|
+
* @param {number | string} environmentId - The ID of the environment
|
|
3818
|
+
* @returns {Promise<GitLabEnvironment>} Environment details
|
|
3819
|
+
*/
|
|
3820
|
+
async function getEnvironment(projectId, environmentId) {
|
|
3821
|
+
projectId = decodeURIComponent(projectId);
|
|
3822
|
+
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/environments/${environmentId}`);
|
|
3823
|
+
const response = await fetch(url.toString(), {
|
|
3824
|
+
...getFetchConfig(),
|
|
3825
|
+
});
|
|
3826
|
+
if (response.status === 404) {
|
|
3827
|
+
throw new Error(`Environment not found`);
|
|
3828
|
+
}
|
|
3829
|
+
await handleGitLabError(response);
|
|
3830
|
+
const data = await response.json();
|
|
3831
|
+
return GitLabEnvironmentSchema.parse(data);
|
|
3832
|
+
}
|
|
3144
3833
|
/**
|
|
3145
3834
|
* List all jobs in a specific pipeline
|
|
3146
3835
|
*
|
|
@@ -3265,21 +3954,107 @@ async function getPipelineJobOutput(projectId, jobId, limit, offset) {
|
|
|
3265
3954
|
}
|
|
3266
3955
|
return fullTrace;
|
|
3267
3956
|
}
|
|
3957
|
+
/**
|
|
3958
|
+
* List artifact files in a job's artifacts archive
|
|
3959
|
+
*
|
|
3960
|
+
* @param {string} projectId - The ID or URL-encoded path of the project
|
|
3961
|
+
* @param {string} jobId - The ID of the job
|
|
3962
|
+
* @param {Object} options - Options for listing artifacts
|
|
3963
|
+
* @returns {Promise<GitLabArtifactEntry[]>} List of artifact entries
|
|
3964
|
+
*/
|
|
3965
|
+
async function listJobArtifacts(projectId, jobId, options = {}) {
|
|
3966
|
+
projectId = decodeURIComponent(projectId);
|
|
3967
|
+
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/jobs/${jobId}/artifacts/tree`);
|
|
3968
|
+
Object.entries(options).forEach(([key, value]) => {
|
|
3969
|
+
if (value !== undefined) {
|
|
3970
|
+
if (typeof value === "boolean") {
|
|
3971
|
+
url.searchParams.append(key, value ? "true" : "false");
|
|
3972
|
+
}
|
|
3973
|
+
else {
|
|
3974
|
+
url.searchParams.append(key, value.toString());
|
|
3975
|
+
}
|
|
3976
|
+
}
|
|
3977
|
+
});
|
|
3978
|
+
const response = await fetch(url.toString(), {
|
|
3979
|
+
...getFetchConfig(),
|
|
3980
|
+
});
|
|
3981
|
+
if (response.status === 404) {
|
|
3982
|
+
throw new Error(`Job artifacts not found. The job may not have produced artifacts or the job ID is invalid.`);
|
|
3983
|
+
}
|
|
3984
|
+
await handleGitLabError(response);
|
|
3985
|
+
const data = await response.json();
|
|
3986
|
+
return z.array(GitLabArtifactEntrySchema).parse(data);
|
|
3987
|
+
}
|
|
3988
|
+
/**
|
|
3989
|
+
* Download the entire artifact archive for a job and save to disk
|
|
3990
|
+
*
|
|
3991
|
+
* @param {string} projectId - The ID or URL-encoded path of the project
|
|
3992
|
+
* @param {string} jobId - The ID of the job
|
|
3993
|
+
* @param {string} localPath - Optional local directory to save the archive
|
|
3994
|
+
* @returns {Promise<string>} The path where the artifact archive was saved
|
|
3995
|
+
*/
|
|
3996
|
+
async function downloadJobArtifacts(projectId, jobId, localPath) {
|
|
3997
|
+
projectId = decodeURIComponent(projectId);
|
|
3998
|
+
const effectiveProjectId = getEffectiveProjectId(projectId);
|
|
3999
|
+
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/jobs/${jobId}/artifacts`);
|
|
4000
|
+
const response = await fetch(url.toString(), {
|
|
4001
|
+
...getFetchConfig(),
|
|
4002
|
+
});
|
|
4003
|
+
if (response.status === 404) {
|
|
4004
|
+
throw new Error(`Job artifacts not found. The job may not have produced artifacts or the job ID is invalid.`);
|
|
4005
|
+
}
|
|
4006
|
+
await handleGitLabError(response);
|
|
4007
|
+
const buffer = await response.arrayBuffer();
|
|
4008
|
+
const filename = `artifacts_job_${jobId}.zip`;
|
|
4009
|
+
const savePath = localPath ? path.join(localPath, filename) : filename;
|
|
4010
|
+
fs.mkdirSync(path.dirname(savePath), { recursive: true });
|
|
4011
|
+
fs.writeFileSync(savePath, Buffer.from(buffer));
|
|
4012
|
+
return savePath;
|
|
4013
|
+
}
|
|
4014
|
+
/**
|
|
4015
|
+
* Download a single file from a job's artifacts
|
|
4016
|
+
*
|
|
4017
|
+
* @param {string} projectId - The ID or URL-encoded path of the project
|
|
4018
|
+
* @param {string} jobId - The ID of the job
|
|
4019
|
+
* @param {string} artifactPath - Path to the file within the artifacts archive
|
|
4020
|
+
* @returns {Promise<string>} The file content as text
|
|
4021
|
+
*/
|
|
4022
|
+
async function getJobArtifactFile(projectId, jobId, artifactPath) {
|
|
4023
|
+
projectId = decodeURIComponent(projectId);
|
|
4024
|
+
const effectiveProjectId = getEffectiveProjectId(projectId);
|
|
4025
|
+
const encodedArtifactPath = artifactPath
|
|
4026
|
+
.split("/")
|
|
4027
|
+
.map(segment => encodeURIComponent(segment))
|
|
4028
|
+
.join("/");
|
|
4029
|
+
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/jobs/${jobId}/artifacts/${encodedArtifactPath}`);
|
|
4030
|
+
const response = await fetch(url.toString(), {
|
|
4031
|
+
...getFetchConfig(),
|
|
4032
|
+
});
|
|
4033
|
+
if (response.status === 404) {
|
|
4034
|
+
throw new Error(`Artifact file not found: ${artifactPath}`);
|
|
4035
|
+
}
|
|
4036
|
+
await handleGitLabError(response);
|
|
4037
|
+
return await response.text();
|
|
4038
|
+
}
|
|
3268
4039
|
/**
|
|
3269
4040
|
* Create a new pipeline
|
|
3270
4041
|
*
|
|
3271
4042
|
* @param {string} projectId - The ID or URL-encoded path of the project
|
|
3272
4043
|
* @param {string} ref - The branch or tag to run the pipeline on
|
|
3273
4044
|
* @param {Array} variables - Optional variables for the pipeline
|
|
4045
|
+
* @param {Record<string, string>} inputs - Optional input parameters for the pipeline
|
|
3274
4046
|
* @returns {Promise<GitLabPipeline>} The created pipeline
|
|
3275
4047
|
*/
|
|
3276
|
-
async function createPipeline(projectId, ref, variables) {
|
|
4048
|
+
async function createPipeline(projectId, ref, variables, inputs) {
|
|
3277
4049
|
projectId = decodeURIComponent(projectId); // Decode project ID
|
|
3278
4050
|
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipeline`);
|
|
3279
4051
|
const body = { ref };
|
|
3280
4052
|
if (variables && variables.length > 0) {
|
|
3281
4053
|
body.variables = variables;
|
|
3282
4054
|
}
|
|
4055
|
+
if (inputs && Object.keys(inputs).length > 0) {
|
|
4056
|
+
body.inputs = inputs;
|
|
4057
|
+
}
|
|
3283
4058
|
const response = await fetch(url.toString(), {
|
|
3284
4059
|
method: "POST",
|
|
3285
4060
|
headers: {
|
|
@@ -3902,6 +4677,20 @@ async function markdownUpload(projectId, filePath) {
|
|
|
3902
4677
|
const data = await response.json();
|
|
3903
4678
|
return GitLabMarkdownUploadSchema.parse(data);
|
|
3904
4679
|
}
|
|
4680
|
+
const IMAGE_MIME_TYPES = {
|
|
4681
|
+
".png": "image/png",
|
|
4682
|
+
".jpg": "image/jpeg",
|
|
4683
|
+
".jpeg": "image/jpeg",
|
|
4684
|
+
".gif": "image/gif",
|
|
4685
|
+
".webp": "image/webp",
|
|
4686
|
+
".svg": "image/svg+xml",
|
|
4687
|
+
".bmp": "image/bmp",
|
|
4688
|
+
".ico": "image/x-icon",
|
|
4689
|
+
};
|
|
4690
|
+
function getImageMimeType(filename) {
|
|
4691
|
+
const ext = path.extname(filename).toLowerCase();
|
|
4692
|
+
return IMAGE_MIME_TYPES[ext] ?? null;
|
|
4693
|
+
}
|
|
3905
4694
|
async function downloadAttachment(projectId, secret, filename, localPath) {
|
|
3906
4695
|
const effectiveProjectId = getEffectiveProjectId(projectId);
|
|
3907
4696
|
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/uploads/${secret}/${filename}`);
|
|
@@ -3916,12 +4705,33 @@ async function downloadAttachment(projectId, secret, filename, localPath) {
|
|
|
3916
4705
|
await handleGitLabError(response);
|
|
3917
4706
|
}
|
|
3918
4707
|
// Get the file content as buffer
|
|
3919
|
-
const buffer = await response.arrayBuffer();
|
|
3920
|
-
|
|
3921
|
-
|
|
3922
|
-
//
|
|
3923
|
-
|
|
3924
|
-
|
|
4708
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
4709
|
+
const mimeType = getImageMimeType(filename);
|
|
4710
|
+
// For non-image files, always save to disk.
|
|
4711
|
+
// For image files, only save to disk if local_path is explicitly provided.
|
|
4712
|
+
if (!mimeType || localPath) {
|
|
4713
|
+
let savePath;
|
|
4714
|
+
if (localPath) {
|
|
4715
|
+
const normalizedLocalPath = path.normalize(localPath);
|
|
4716
|
+
if (path.isAbsolute(normalizedLocalPath) ||
|
|
4717
|
+
normalizedLocalPath === ".." ||
|
|
4718
|
+
normalizedLocalPath.startsWith(".." + path.sep) ||
|
|
4719
|
+
normalizedLocalPath.includes(path.sep + ".." + path.sep)) {
|
|
4720
|
+
throw new Error("Invalid local_path: directory traversal is not allowed.");
|
|
4721
|
+
}
|
|
4722
|
+
savePath = path.join(normalizedLocalPath, filename);
|
|
4723
|
+
}
|
|
4724
|
+
else {
|
|
4725
|
+
savePath = filename;
|
|
4726
|
+
}
|
|
4727
|
+
const dir = path.dirname(savePath);
|
|
4728
|
+
if (!fs.existsSync(dir)) {
|
|
4729
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
4730
|
+
}
|
|
4731
|
+
fs.writeFileSync(savePath, buffer);
|
|
4732
|
+
return { buffer, filename, mimeType, savedPath: savePath };
|
|
4733
|
+
}
|
|
4734
|
+
return { buffer, filename, mimeType };
|
|
3925
4735
|
}
|
|
3926
4736
|
/**
|
|
3927
4737
|
* List all events for the currently authenticated user
|
|
@@ -4147,6 +4957,8 @@ async function handleToolCall(params) {
|
|
|
4147
4957
|
if (GITLAB_AUTH_COOKIE_PATH) {
|
|
4148
4958
|
await ensureSessionForRequest();
|
|
4149
4959
|
}
|
|
4960
|
+
// Lazy OAuth token refresh: only validate/refresh when a tool is actually called
|
|
4961
|
+
await ensureValidOAuthToken();
|
|
4150
4962
|
logger.info(params.name);
|
|
4151
4963
|
switch (params.name) {
|
|
4152
4964
|
case "execute_graphql": {
|
|
@@ -4375,8 +5187,22 @@ async function handleToolCall(params) {
|
|
|
4375
5187
|
case "get_merge_request": {
|
|
4376
5188
|
const args = GetMergeRequestSchema.parse(params.arguments);
|
|
4377
5189
|
const mergeRequest = await getMergeRequest(args.project_id, args.merge_request_iid, args.source_branch);
|
|
5190
|
+
const deploymentSummary = await buildMergeRequestDeploymentSummary(args.project_id, mergeRequest);
|
|
5191
|
+
const commitAdditionSummary = await buildMergeRequestCommitAdditionSummary(args.project_id, mergeRequest);
|
|
5192
|
+
const approvalSummary = await buildMergeRequestApprovalSummary(args.project_id, mergeRequest.iid);
|
|
5193
|
+
const mergeRequestWithDeploymentSummary = {
|
|
5194
|
+
...mergeRequest,
|
|
5195
|
+
deployment_summary: deploymentSummary,
|
|
5196
|
+
commit_addition_summary: commitAdditionSummary,
|
|
5197
|
+
approval_summary: approvalSummary,
|
|
5198
|
+
};
|
|
4378
5199
|
return {
|
|
4379
|
-
content: [
|
|
5200
|
+
content: [
|
|
5201
|
+
{
|
|
5202
|
+
type: "text",
|
|
5203
|
+
text: JSON.stringify(mergeRequestWithDeploymentSummary, null, 2),
|
|
5204
|
+
},
|
|
5205
|
+
],
|
|
4380
5206
|
};
|
|
4381
5207
|
}
|
|
4382
5208
|
case "get_merge_request_diffs": {
|
|
@@ -4573,8 +5399,8 @@ async function handleToolCall(params) {
|
|
|
4573
5399
|
}
|
|
4574
5400
|
case "create_draft_note": {
|
|
4575
5401
|
const args = CreateDraftNoteSchema.parse(params.arguments);
|
|
4576
|
-
const { project_id, merge_request_iid, body, position, resolve_discussion } = args;
|
|
4577
|
-
const draftNote = await createDraftNote(project_id, merge_request_iid, body, position, resolve_discussion);
|
|
5402
|
+
const { project_id, merge_request_iid, body, in_reply_to_discussion_id, position, resolve_discussion } = args;
|
|
5403
|
+
const draftNote = await createDraftNote(project_id, merge_request_iid, body, in_reply_to_discussion_id, position, resolve_discussion);
|
|
4578
5404
|
return {
|
|
4579
5405
|
content: [{ type: "text", text: JSON.stringify(draftNote, null, 2) }],
|
|
4580
5406
|
};
|
|
@@ -4835,6 +5661,36 @@ async function handleToolCall(params) {
|
|
|
4835
5661
|
],
|
|
4836
5662
|
};
|
|
4837
5663
|
}
|
|
5664
|
+
case "list_deployments": {
|
|
5665
|
+
const args = ListDeploymentsSchema.parse(params.arguments);
|
|
5666
|
+
const { project_id, ...options } = args;
|
|
5667
|
+
const deployments = await listDeployments(project_id, options);
|
|
5668
|
+
return {
|
|
5669
|
+
content: [{ type: "text", text: JSON.stringify(deployments, null, 2) }],
|
|
5670
|
+
};
|
|
5671
|
+
}
|
|
5672
|
+
case "get_deployment": {
|
|
5673
|
+
const { project_id, deployment_id } = GetDeploymentSchema.parse(params.arguments);
|
|
5674
|
+
const deployment = await getDeployment(project_id, deployment_id);
|
|
5675
|
+
return {
|
|
5676
|
+
content: [{ type: "text", text: JSON.stringify(deployment, null, 2) }],
|
|
5677
|
+
};
|
|
5678
|
+
}
|
|
5679
|
+
case "list_environments": {
|
|
5680
|
+
const args = ListEnvironmentsSchema.parse(params.arguments);
|
|
5681
|
+
const { project_id, ...options } = args;
|
|
5682
|
+
const environments = await listEnvironments(project_id, options);
|
|
5683
|
+
return {
|
|
5684
|
+
content: [{ type: "text", text: JSON.stringify(environments, null, 2) }],
|
|
5685
|
+
};
|
|
5686
|
+
}
|
|
5687
|
+
case "get_environment": {
|
|
5688
|
+
const { project_id, environment_id } = GetEnvironmentSchema.parse(params.arguments);
|
|
5689
|
+
const environment = await getEnvironment(project_id, environment_id);
|
|
5690
|
+
return {
|
|
5691
|
+
content: [{ type: "text", text: JSON.stringify(environment, null, 2) }],
|
|
5692
|
+
};
|
|
5693
|
+
}
|
|
4838
5694
|
case "list_pipeline_jobs": {
|
|
4839
5695
|
const { project_id, pipeline_id, ...options } = ListPipelineJobsSchema.parse(params.arguments);
|
|
4840
5696
|
const jobs = await listPipelineJobs(project_id, pipeline_id, options);
|
|
@@ -4884,8 +5740,8 @@ async function handleToolCall(params) {
|
|
|
4884
5740
|
};
|
|
4885
5741
|
}
|
|
4886
5742
|
case "create_pipeline": {
|
|
4887
|
-
const { project_id, ref, variables } = CreatePipelineSchema.parse(params.arguments);
|
|
4888
|
-
const pipeline = await createPipeline(project_id, ref, variables);
|
|
5743
|
+
const { project_id, ref, variables, inputs } = CreatePipelineSchema.parse(params.arguments);
|
|
5744
|
+
const pipeline = await createPipeline(project_id, ref, variables, inputs);
|
|
4889
5745
|
return {
|
|
4890
5746
|
content: [
|
|
4891
5747
|
{
|
|
@@ -4955,6 +5811,42 @@ async function handleToolCall(params) {
|
|
|
4955
5811
|
],
|
|
4956
5812
|
};
|
|
4957
5813
|
}
|
|
5814
|
+
case "list_job_artifacts": {
|
|
5815
|
+
const { project_id, job_id, ...options } = ListJobArtifactsSchema.parse(params.arguments);
|
|
5816
|
+
const artifacts = await listJobArtifacts(project_id, job_id, options);
|
|
5817
|
+
return {
|
|
5818
|
+
content: [
|
|
5819
|
+
{
|
|
5820
|
+
type: "text",
|
|
5821
|
+
text: JSON.stringify(artifacts, null, 2),
|
|
5822
|
+
},
|
|
5823
|
+
],
|
|
5824
|
+
};
|
|
5825
|
+
}
|
|
5826
|
+
case "download_job_artifacts": {
|
|
5827
|
+
const { project_id, job_id, local_path } = DownloadJobArtifactsSchema.parse(params.arguments);
|
|
5828
|
+
const filePath = await downloadJobArtifacts(project_id, job_id, local_path);
|
|
5829
|
+
return {
|
|
5830
|
+
content: [
|
|
5831
|
+
{
|
|
5832
|
+
type: "text",
|
|
5833
|
+
text: JSON.stringify({ success: true, file_path: filePath }, null, 2),
|
|
5834
|
+
},
|
|
5835
|
+
],
|
|
5836
|
+
};
|
|
5837
|
+
}
|
|
5838
|
+
case "get_job_artifact_file": {
|
|
5839
|
+
const { project_id, job_id, artifact_path } = GetJobArtifactFileSchema.parse(params.arguments);
|
|
5840
|
+
const fileContent = await getJobArtifactFile(project_id, job_id, artifact_path);
|
|
5841
|
+
return {
|
|
5842
|
+
content: [
|
|
5843
|
+
{
|
|
5844
|
+
type: "text",
|
|
5845
|
+
text: fileContent,
|
|
5846
|
+
},
|
|
5847
|
+
],
|
|
5848
|
+
};
|
|
5849
|
+
}
|
|
4958
5850
|
case "list_merge_requests": {
|
|
4959
5851
|
const args = ListMergeRequestsSchema.parse(params.arguments);
|
|
4960
5852
|
const mergeRequests = await listMergeRequests(args.project_id, args);
|
|
@@ -5110,10 +6002,26 @@ async function handleToolCall(params) {
|
|
|
5110
6002
|
}
|
|
5111
6003
|
case "download_attachment": {
|
|
5112
6004
|
const args = DownloadAttachmentSchema.parse(params.arguments);
|
|
5113
|
-
const
|
|
6005
|
+
const result = await downloadAttachment(args.project_id, args.secret, args.filename, args.local_path);
|
|
6006
|
+
if (result.mimeType && !args.local_path) {
|
|
6007
|
+
// Return image inline as base64 so the AI can see it
|
|
6008
|
+
const base64 = result.buffer.toString("base64");
|
|
6009
|
+
return {
|
|
6010
|
+
content: [
|
|
6011
|
+
{ type: "image", data: base64, mimeType: result.mimeType },
|
|
6012
|
+
{
|
|
6013
|
+
type: "text",
|
|
6014
|
+
text: JSON.stringify({ filename: result.filename, mimeType: result.mimeType }, null, 2),
|
|
6015
|
+
},
|
|
6016
|
+
],
|
|
6017
|
+
};
|
|
6018
|
+
}
|
|
5114
6019
|
return {
|
|
5115
6020
|
content: [
|
|
5116
|
-
{
|
|
6021
|
+
{
|
|
6022
|
+
type: "text",
|
|
6023
|
+
text: JSON.stringify({ success: true, file_path: result.savedPath }, null, 2),
|
|
6024
|
+
},
|
|
5117
6025
|
],
|
|
5118
6026
|
};
|
|
5119
6027
|
}
|
|
@@ -5685,9 +6593,10 @@ async function runServer() {
|
|
|
5685
6593
|
logger.info("Using OAuth authentication...");
|
|
5686
6594
|
try {
|
|
5687
6595
|
const gitlabBaseUrl = GITLAB_API_URL.replace(/\/api\/v4$/, "");
|
|
5688
|
-
|
|
6596
|
+
const oauthResult = await initializeOAuthClient(gitlabBaseUrl);
|
|
6597
|
+
oauthClient = oauthResult.client;
|
|
6598
|
+
OAUTH_ACCESS_TOKEN = oauthResult.accessToken;
|
|
5689
6599
|
logger.info("OAuth authentication successful");
|
|
5690
|
-
// Note: Headers are automatically generated by buildAuthHeaders() using OAUTH_ACCESS_TOKEN
|
|
5691
6600
|
}
|
|
5692
6601
|
catch (error) {
|
|
5693
6602
|
logger.error("OAuth authentication failed:", error);
|