@zereight/mcp-gitlab 2.0.30 → 2.0.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/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 { initializeOAuth } from "./oauth.js";
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, ListWebhooksSchema, ListWebhookEventsSchema, GetWebhookEventSchema, } from "./schemas.js";
54
54
  import { randomUUID } from "node:crypto";
55
55
  import { pino } from "pino";
56
56
  const logger = pino({
@@ -226,9 +226,10 @@ function validateConfiguration() {
226
226
  const remoteAuth = getConfig("remote-auth", "REMOTE_AUTHORIZATION") === "true";
227
227
  const useOAuth = getConfig("use-oauth", "GITLAB_USE_OAUTH") === "true";
228
228
  const hasToken = !!getConfig("token", "GITLAB_PERSONAL_ACCESS_TOKEN");
229
+ const hasJobToken = !!getConfig("job-token", "GITLAB_JOB_TOKEN");
229
230
  const hasCookie = !!getConfig("cookie-path", "GITLAB_AUTH_COOKIE_PATH");
230
- if (!remoteAuth && !useOAuth && !hasToken && !hasCookie) {
231
- errors.push("Either --token, --cookie-path, --use-oauth=true, or --remote-auth=true must be set (or use environment variables)");
231
+ if (!remoteAuth && !useOAuth && !hasToken && !hasJobToken && !hasCookie) {
232
+ errors.push("Either --token, --job-token, --cookie-path, --use-oauth=true, or --remote-auth=true must be set (or use environment variables)");
232
233
  }
233
234
  const enableDynamicApiUrl = getConfig("enable-dynamic-api-url", "ENABLE_DYNAMIC_API_URL") === "true";
234
235
  if (enableDynamicApiUrl && !remoteAuth) {
@@ -242,7 +243,30 @@ function validateConfiguration() {
242
243
  logger.info("Configuration validation passed");
243
244
  }
244
245
  const GITLAB_PERSONAL_ACCESS_TOKEN = getConfig("token", "GITLAB_PERSONAL_ACCESS_TOKEN");
246
+ const GITLAB_JOB_TOKEN = getConfig("job-token", "GITLAB_JOB_TOKEN");
245
247
  let OAUTH_ACCESS_TOKEN = null;
248
+ let oauthClient = null;
249
+ /**
250
+ * Ensure the OAuth token is valid before making an API call.
251
+ * Refreshes the token lazily (only when a tool is actually called).
252
+ * This avoids background timers that cause issues with multiple instances.
253
+ */
254
+ async function ensureValidOAuthToken() {
255
+ if (!oauthClient)
256
+ return;
257
+ if (oauthClient.hasValidToken())
258
+ return;
259
+ try {
260
+ logger.info("OAuth token expired or missing, refreshing...");
261
+ const freshToken = await oauthClient.getAccessToken();
262
+ OAUTH_ACCESS_TOKEN = freshToken;
263
+ logger.info("OAuth token refreshed successfully");
264
+ }
265
+ catch (error) {
266
+ logger.error("Failed to refresh OAuth token:", error);
267
+ throw error;
268
+ }
269
+ }
246
270
  const GITLAB_AUTH_COOKIE_PATH = getConfig("cookie-path", "GITLAB_AUTH_COOKIE_PATH");
247
271
  const USE_OAUTH = getConfig("use-oauth", "GITLAB_USE_OAUTH") === "true";
248
272
  const IS_OLD = getConfig("is-old", "GITLAB_IS_OLD") === "true";
@@ -457,7 +481,7 @@ const BASE_HEADERS = {
457
481
  /**
458
482
  * Build authentication headers dynamically based on context
459
483
  * In REMOTE_AUTHORIZATION mode, reads from AsyncLocalStorage session context
460
- * Otherwise, uses environment token
484
+ * Otherwise, uses environment token (OAuth token is refreshed lazily before each tool call)
461
485
  */
462
486
  function buildAuthHeaders() {
463
487
  if (REMOTE_AUTHORIZATION) {
@@ -470,6 +494,10 @@ function buildAuthHeaders() {
470
494
  }
471
495
  return {}; // No auth headers if no session context
472
496
  }
497
+ // CI job tokens use a dedicated header (not Bearer/Private-Token)
498
+ if (GITLAB_JOB_TOKEN) {
499
+ return { "JOB-TOKEN": String(GITLAB_JOB_TOKEN) };
500
+ }
473
501
  // Standard mode: prioritize OAuth token, then fall back to environment token
474
502
  const token = OAUTH_ACCESS_TOKEN || GITLAB_PERSONAL_ACCESS_TOKEN;
475
503
  if (IS_OLD && token) {
@@ -572,7 +600,7 @@ const allTools = [
572
600
  },
573
601
  {
574
602
  name: "get_merge_request_approval_state",
575
- description: "Get the approval state of a merge request including approval rules and who has approved",
603
+ description: "Get merge request approval details including approvers (uses approval_state when available, falls back to approvals endpoint)",
576
604
  inputSchema: toJSONSchema(GetMergeRequestApprovalStateSchema),
577
605
  },
578
606
  {
@@ -627,7 +655,7 @@ const allTools = [
627
655
  },
628
656
  {
629
657
  name: "get_merge_request",
630
- description: "Get details of a merge request (Either mergeRequestIid or branchName must be provided)",
658
+ description: "Get details of a merge request with compact deployment, commit addition, and approval summaries (Either mergeRequestIid or branchName must be provided)",
631
659
  inputSchema: toJSONSchema(GetMergeRequestSchema),
632
660
  },
633
661
  {
@@ -915,6 +943,26 @@ const allTools = [
915
943
  description: "Get details of a specific pipeline in a GitLab project",
916
944
  inputSchema: toJSONSchema(GetPipelineSchema),
917
945
  },
946
+ {
947
+ name: "list_deployments",
948
+ description: "List deployments in a GitLab project with filtering options",
949
+ inputSchema: toJSONSchema(ListDeploymentsSchema),
950
+ },
951
+ {
952
+ name: "get_deployment",
953
+ description: "Get details of a specific deployment in a GitLab project",
954
+ inputSchema: toJSONSchema(GetDeploymentSchema),
955
+ },
956
+ {
957
+ name: "list_environments",
958
+ description: "List environments in a GitLab project",
959
+ inputSchema: toJSONSchema(ListEnvironmentsSchema),
960
+ },
961
+ {
962
+ name: "get_environment",
963
+ description: "Get details of a specific environment in a GitLab project",
964
+ inputSchema: toJSONSchema(GetEnvironmentSchema),
965
+ },
918
966
  {
919
967
  name: "list_pipeline_jobs",
920
968
  description: "List all jobs in a specific pipeline",
@@ -965,6 +1013,21 @@ const allTools = [
965
1013
  description: "Cancel a running pipeline job",
966
1014
  inputSchema: toJSONSchema(CancelPipelineJobSchema),
967
1015
  },
1016
+ {
1017
+ name: "list_job_artifacts",
1018
+ description: "List artifact files in a job's artifacts archive. Returns file names, paths, types, and sizes.",
1019
+ inputSchema: toJSONSchema(ListJobArtifactsSchema),
1020
+ },
1021
+ {
1022
+ name: "download_job_artifacts",
1023
+ description: "Download the entire artifact archive (zip) for a job to a local path. Returns the saved file path.",
1024
+ inputSchema: toJSONSchema(DownloadJobArtifactsSchema),
1025
+ },
1026
+ {
1027
+ name: "get_job_artifact_file",
1028
+ description: "Get the content of a single file from a job's artifacts by its path within the archive",
1029
+ inputSchema: toJSONSchema(GetJobArtifactFileSchema),
1030
+ },
968
1031
  {
969
1032
  name: "list_merge_requests",
970
1033
  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.",
@@ -1095,6 +1158,21 @@ const allTools = [
1095
1158
  description: "Download a release asset file by direct asset path",
1096
1159
  inputSchema: toJSONSchema(DownloadReleaseAssetSchema),
1097
1160
  },
1161
+ {
1162
+ name: "list_webhooks",
1163
+ description: "List all configured webhooks for a GitLab project or group. Provide either project_id or group_id.",
1164
+ inputSchema: toJSONSchema(ListWebhooksSchema),
1165
+ },
1166
+ {
1167
+ name: "list_webhook_events",
1168
+ description: "List recent webhook events (past 7 days) for a project or group webhook. Use summary mode for overview, then get_webhook_event for full details.",
1169
+ inputSchema: toJSONSchema(ListWebhookEventsSchema),
1170
+ },
1171
+ {
1172
+ name: "get_webhook_event",
1173
+ description: "Get full details of a specific webhook event by ID, including request/response payloads. Searches up to 500 most recent events.",
1174
+ inputSchema: toJSONSchema(GetWebhookEventSchema),
1175
+ },
1098
1176
  ];
1099
1177
  // Define which tools are read-only
1100
1178
  const readOnlyTools = new Set([
@@ -1122,10 +1200,17 @@ const readOnlyTools = new Set([
1122
1200
  "list_project_members",
1123
1201
  "get_pipeline",
1124
1202
  "list_pipelines",
1203
+ "list_deployments",
1204
+ "get_deployment",
1205
+ "list_environments",
1206
+ "get_environment",
1125
1207
  "list_pipeline_jobs",
1126
1208
  "list_pipeline_trigger_jobs",
1127
1209
  "get_pipeline_job",
1128
1210
  "get_pipeline_job_output",
1211
+ "list_job_artifacts",
1212
+ "download_job_artifacts",
1213
+ "get_job_artifact_file",
1129
1214
  "list_labels",
1130
1215
  "get_label",
1131
1216
  "list_group_projects",
@@ -1150,6 +1235,9 @@ const readOnlyTools = new Set([
1150
1235
  "get_release",
1151
1236
  "download_release_asset",
1152
1237
  "get_merge_request_approval_state",
1238
+ "list_webhooks",
1239
+ "list_webhook_events",
1240
+ "get_webhook_event",
1153
1241
  ]);
1154
1242
  // Define which tools are related to wiki and can be toggled by USE_GITLAB_WIKI
1155
1243
  const wikiToolNames = new Set([
@@ -1176,6 +1264,10 @@ const milestoneToolNames = new Set([
1176
1264
  const pipelineToolNames = new Set([
1177
1265
  "list_pipelines",
1178
1266
  "get_pipeline",
1267
+ "list_deployments",
1268
+ "get_deployment",
1269
+ "list_environments",
1270
+ "get_environment",
1179
1271
  "list_pipeline_jobs",
1180
1272
  "list_pipeline_trigger_jobs",
1181
1273
  "get_pipeline_job",
@@ -1186,6 +1278,9 @@ const pipelineToolNames = new Set([
1186
1278
  "play_pipeline_job",
1187
1279
  "retry_pipeline_job",
1188
1280
  "cancel_pipeline_job",
1281
+ "list_job_artifacts",
1282
+ "download_job_artifacts",
1283
+ "get_job_artifact_file",
1189
1284
  ]);
1190
1285
  const TOOLSET_DEFINITIONS = [
1191
1286
  {
@@ -1295,10 +1390,14 @@ const TOOLSET_DEFINITIONS = [
1295
1390
  },
1296
1391
  {
1297
1392
  id: "pipelines",
1298
- isDefault: false,
1393
+ isDefault: true,
1299
1394
  tools: new Set([
1300
1395
  "list_pipelines",
1301
1396
  "get_pipeline",
1397
+ "list_deployments",
1398
+ "get_deployment",
1399
+ "list_environments",
1400
+ "get_environment",
1302
1401
  "list_pipeline_jobs",
1303
1402
  "list_pipeline_trigger_jobs",
1304
1403
  "get_pipeline_job",
@@ -1309,11 +1408,14 @@ const TOOLSET_DEFINITIONS = [
1309
1408
  "play_pipeline_job",
1310
1409
  "retry_pipeline_job",
1311
1410
  "cancel_pipeline_job",
1411
+ "list_job_artifacts",
1412
+ "download_job_artifacts",
1413
+ "get_job_artifact_file",
1312
1414
  ]),
1313
1415
  },
1314
1416
  {
1315
1417
  id: "milestones",
1316
- isDefault: false,
1418
+ isDefault: true,
1317
1419
  tools: new Set([
1318
1420
  "list_milestones",
1319
1421
  "get_milestone",
@@ -1328,7 +1430,7 @@ const TOOLSET_DEFINITIONS = [
1328
1430
  },
1329
1431
  {
1330
1432
  id: "wiki",
1331
- isDefault: false,
1433
+ isDefault: true,
1332
1434
  tools: new Set([
1333
1435
  "list_wiki_pages",
1334
1436
  "get_wiki_page",
@@ -1361,6 +1463,15 @@ const TOOLSET_DEFINITIONS = [
1361
1463
  "download_attachment",
1362
1464
  ]),
1363
1465
  },
1466
+ {
1467
+ id: "webhooks",
1468
+ isDefault: false,
1469
+ tools: new Set([
1470
+ "list_webhooks",
1471
+ "list_webhook_events",
1472
+ "get_webhook_event",
1473
+ ]),
1474
+ },
1364
1475
  ];
1365
1476
  // Derived lookup: tool name → toolset ID
1366
1477
  const TOOLSET_BY_TOOL_NAME = new Map();
@@ -1440,6 +1551,7 @@ if (GITLAB_TOOLSETS_RAW && (USE_PIPELINE || USE_MILESTONE || USE_GITLAB_WIKI)) {
1440
1551
  logger.warn("GITLAB_TOOLSETS is set alongside legacy flags (USE_PIPELINE, USE_MILESTONE, USE_GITLAB_WIKI). " +
1441
1552
  "Legacy flags add tools additively on top of the toolset selection and may produce unexpected results.");
1442
1553
  }
1554
+ const MERGE_REQUEST_DEPLOYMENT_SUMMARY_LIMIT = 10;
1443
1555
  /**
1444
1556
  * Smart URL handling for GitLab API
1445
1557
  *
@@ -1615,12 +1727,12 @@ async function getDefaultBranchRef(projectId) {
1615
1727
  * @returns {Promise<GitLabContent>} The file content
1616
1728
  */
1617
1729
  async function getFileContents(projectId, filePath, ref) {
1618
- projectId = decodeURIComponent(projectId); // Decode project ID
1619
- const effectiveProjectId = getEffectiveProjectId(projectId);
1730
+ const decodedProjectId = projectId ? decodeURIComponent(projectId) : "";
1731
+ const effectiveProjectId = getEffectiveProjectId(decodedProjectId);
1620
1732
  const encodedPath = encodeURIComponent(filePath);
1621
1733
  // Fall back to default branch if ref is not provided
1622
1734
  if (!ref) {
1623
- ref = await getDefaultBranchRef(projectId);
1735
+ ref = await getDefaultBranchRef(decodedProjectId);
1624
1736
  }
1625
1737
  const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/repository/files/${encodedPath}`);
1626
1738
  url.searchParams.append("ref", ref);
@@ -2454,6 +2566,7 @@ async function getMergeRequest(projectId, mergeRequestIid, branchName) {
2454
2566
  let url;
2455
2567
  if (mergeRequestIid) {
2456
2568
  url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}`);
2569
+ url.searchParams.append("include_diverged_commits_count", "true");
2457
2570
  }
2458
2571
  else if (branchName) {
2459
2572
  url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests?source_branch=${encodeURIComponent(branchName)}`);
@@ -2468,10 +2581,219 @@ async function getMergeRequest(projectId, mergeRequestIid, branchName) {
2468
2581
  const data = await response.json();
2469
2582
  // If response is an array (Comes from branchName search), return the first item if exist
2470
2583
  if (Array.isArray(data) && data.length > 0) {
2471
- return GitLabMergeRequestSchema.parse(data[0]);
2584
+ const mergeRequest = GitLabMergeRequestSchema.parse(data[0]);
2585
+ return getMergeRequest(projectId, mergeRequest.iid, undefined);
2472
2586
  }
2473
2587
  return GitLabMergeRequestSchema.parse(data);
2474
2588
  }
2589
+ async function getMergeRequestSourceCommitCount(projectId, mergeRequestIid) {
2590
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/commits`);
2591
+ url.searchParams.append("per_page", "100");
2592
+ let totalCount = 0;
2593
+ let page = 1;
2594
+ while (true) {
2595
+ url.searchParams.set("page", String(page));
2596
+ const response = await fetch(url.toString(), {
2597
+ ...getFetchConfig(),
2598
+ });
2599
+ await handleGitLabError(response);
2600
+ const data = await response.json();
2601
+ if (!Array.isArray(data)) {
2602
+ throw new Error("Unexpected merge request commits response format");
2603
+ }
2604
+ totalCount += data.length;
2605
+ const nextPage = response.headers.get("x-next-page");
2606
+ if (!nextPage) {
2607
+ break;
2608
+ }
2609
+ page = Number.parseInt(nextPage, 10);
2610
+ if (Number.isNaN(page) || page <= 0) {
2611
+ break;
2612
+ }
2613
+ }
2614
+ return totalCount;
2615
+ }
2616
+ async function getProjectMergeMethod(projectId) {
2617
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}`);
2618
+ const response = await fetch(url.toString(), {
2619
+ ...getFetchConfig(),
2620
+ });
2621
+ await handleGitLabError(response);
2622
+ const data = await response.json();
2623
+ const mergeMethod = z
2624
+ .object({
2625
+ merge_method: z.string().nullable().optional(),
2626
+ })
2627
+ .parse(data).merge_method;
2628
+ return typeof mergeMethod === "string" ? mergeMethod : null;
2629
+ }
2630
+ function estimateMergeCommitCount(mergeMethod, sourceCommitCount) {
2631
+ if (sourceCommitCount === 0) {
2632
+ return 0;
2633
+ }
2634
+ if (mergeMethod === "merge") {
2635
+ return 1;
2636
+ }
2637
+ if (mergeMethod === "ff" || mergeMethod === "rebase_merge") {
2638
+ return 0;
2639
+ }
2640
+ return null;
2641
+ }
2642
+ async function buildMergeRequestCommitAdditionSummary(projectId, mergeRequest) {
2643
+ try {
2644
+ const sourceCommitCount = await getMergeRequestSourceCommitCount(projectId, mergeRequest.iid);
2645
+ const mergeMethod = await getProjectMergeMethod(projectId);
2646
+ const mergeCommitCount = estimateMergeCommitCount(mergeMethod, sourceCommitCount);
2647
+ const summary = mergeCommitCount === null
2648
+ ? null
2649
+ : `${sourceCommitCount} commits and ${mergeCommitCount} merge commit${mergeCommitCount === 1 ? "" : "s"} will be added to ${mergeRequest.target_branch}.`;
2650
+ return {
2651
+ target_branch: mergeRequest.target_branch,
2652
+ source_commits_count: sourceCommitCount,
2653
+ merge_method: mergeMethod,
2654
+ merge_commit_count: mergeCommitCount,
2655
+ summary,
2656
+ };
2657
+ }
2658
+ catch (error) {
2659
+ const unavailableReason = error instanceof Error ? error.message : String(error);
2660
+ return {
2661
+ target_branch: mergeRequest.target_branch,
2662
+ source_commits_count: null,
2663
+ merge_method: null,
2664
+ merge_commit_count: null,
2665
+ summary: null,
2666
+ unavailable_reason: unavailableReason,
2667
+ };
2668
+ }
2669
+ }
2670
+ async function buildMergeRequestApprovalSummary(projectId, mergeRequestIid) {
2671
+ try {
2672
+ const approvalState = await getMergeRequestApprovalState(projectId, mergeRequestIid);
2673
+ const approvedByUsers = approvalState.approved_by || [];
2674
+ const approvedByUsernames = approvalState.approved_by_usernames || approvedByUsers.map(user => user.username);
2675
+ const inferredApproved = inferMergeRequestApproved(approvalState.rules);
2676
+ return {
2677
+ approved: approvalState.approved ?? inferredApproved,
2678
+ user_has_approved: approvalState.user_has_approved ?? null,
2679
+ user_can_approve: approvalState.user_can_approve ?? null,
2680
+ approved_by: approvedByUsers,
2681
+ approved_by_usernames: approvedByUsernames,
2682
+ rules_count: approvalState.rules?.length ?? null,
2683
+ source_endpoint: approvalState.source_endpoint ?? null,
2684
+ };
2685
+ }
2686
+ catch (error) {
2687
+ const unavailableReason = error instanceof Error ? error.message : String(error);
2688
+ return {
2689
+ approved: null,
2690
+ user_has_approved: null,
2691
+ user_can_approve: null,
2692
+ approved_by: [],
2693
+ approved_by_usernames: [],
2694
+ rules_count: null,
2695
+ source_endpoint: null,
2696
+ unavailable_reason: unavailableReason,
2697
+ };
2698
+ }
2699
+ }
2700
+ function toMergeRequestDeploymentSummaryRecord(deployment) {
2701
+ return {
2702
+ id: deployment.id,
2703
+ status: deployment.status,
2704
+ ref: deployment.ref,
2705
+ sha: deployment.sha,
2706
+ created_at: deployment.created_at,
2707
+ updated_at: deployment.updated_at,
2708
+ finished_at: deployment.finished_at,
2709
+ web_url: deployment.web_url,
2710
+ environment: deployment.environment
2711
+ ? {
2712
+ id: deployment.environment.id,
2713
+ name: deployment.environment.name,
2714
+ slug: deployment.environment.slug,
2715
+ external_url: deployment.environment.external_url,
2716
+ state: deployment.environment.state,
2717
+ tier: deployment.environment.tier,
2718
+ }
2719
+ : undefined,
2720
+ deployable: deployment.deployable === null
2721
+ ? null
2722
+ : deployment.deployable
2723
+ ? {
2724
+ id: deployment.deployable.id,
2725
+ name: deployment.deployable.name,
2726
+ status: deployment.deployable.status,
2727
+ stage: deployment.deployable.stage,
2728
+ web_url: deployment.deployable.web_url,
2729
+ pipeline: deployment.deployable.pipeline
2730
+ ? {
2731
+ id: deployment.deployable.pipeline.id,
2732
+ status: deployment.deployable.pipeline.status,
2733
+ ref: deployment.deployable.pipeline.ref,
2734
+ sha: deployment.deployable.pipeline.sha,
2735
+ web_url: deployment.deployable.pipeline.web_url,
2736
+ }
2737
+ : undefined,
2738
+ }
2739
+ : undefined,
2740
+ };
2741
+ }
2742
+ function sortDeploymentsByCreatedAtDesc(deployments) {
2743
+ return [...deployments].sort((a, b) => {
2744
+ const aTime = Date.parse(a.created_at);
2745
+ const bTime = Date.parse(b.created_at);
2746
+ if (Number.isNaN(aTime) || Number.isNaN(bTime)) {
2747
+ return b.created_at.localeCompare(a.created_at);
2748
+ }
2749
+ return bTime - aTime;
2750
+ });
2751
+ }
2752
+ async function buildMergeRequestDeploymentSummary(projectId, mergeRequest) {
2753
+ const lookupSha = mergeRequest.merge_commit_sha ?? mergeRequest.diff_refs?.head_sha ?? null;
2754
+ if (!lookupSha) {
2755
+ return {
2756
+ lookup_sha: null,
2757
+ sort: "created_at_desc",
2758
+ limit: MERGE_REQUEST_DEPLOYMENT_SUMMARY_LIMIT,
2759
+ total_count: 0,
2760
+ returned_count: 0,
2761
+ records: [],
2762
+ };
2763
+ }
2764
+ try {
2765
+ const deployments = await listDeployments(projectId, {
2766
+ sha: lookupSha,
2767
+ order_by: "created_at",
2768
+ sort: "desc",
2769
+ per_page: 100,
2770
+ });
2771
+ const sortedDeployments = sortDeploymentsByCreatedAtDesc(deployments);
2772
+ const records = sortedDeployments
2773
+ .slice(0, MERGE_REQUEST_DEPLOYMENT_SUMMARY_LIMIT)
2774
+ .map(toMergeRequestDeploymentSummaryRecord);
2775
+ return {
2776
+ lookup_sha: lookupSha,
2777
+ sort: "created_at_desc",
2778
+ limit: MERGE_REQUEST_DEPLOYMENT_SUMMARY_LIMIT,
2779
+ total_count: sortedDeployments.length,
2780
+ returned_count: records.length,
2781
+ records,
2782
+ };
2783
+ }
2784
+ catch (error) {
2785
+ const unavailableReason = error instanceof Error ? error.message : String(error);
2786
+ return {
2787
+ lookup_sha: lookupSha,
2788
+ sort: "created_at_desc",
2789
+ limit: MERGE_REQUEST_DEPLOYMENT_SUMMARY_LIMIT,
2790
+ total_count: 0,
2791
+ returned_count: 0,
2792
+ records: [],
2793
+ unavailable_reason: unavailableReason,
2794
+ };
2795
+ }
2796
+ }
2475
2797
  /**
2476
2798
  * Get merge request changes/diffs
2477
2799
  * MR 변경사항 조회 함수 (Function to retrieve merge request changes)
@@ -2637,7 +2959,7 @@ async function approveMergeRequest(projectId, mergeRequestIid, sha, approvalPass
2637
2959
  body: JSON.stringify(body),
2638
2960
  });
2639
2961
  await handleGitLabError(response);
2640
- return GitLabMergeRequestApprovalStateSchema.parse(await response.json());
2962
+ return parseApprovalsResponse(await response.json());
2641
2963
  }
2642
2964
  /**
2643
2965
  * Unapprove a previously approved merge request
@@ -2655,7 +2977,7 @@ async function unapproveMergeRequest(projectId, mergeRequestIid) {
2655
2977
  body: JSON.stringify({}),
2656
2978
  });
2657
2979
  await handleGitLabError(response);
2658
- return GitLabMergeRequestApprovalStateSchema.parse(await response.json());
2980
+ return parseApprovalsResponse(await response.json());
2659
2981
  }
2660
2982
  /**
2661
2983
  * Get the approval state of a merge request
@@ -2666,13 +2988,70 @@ async function unapproveMergeRequest(projectId, mergeRequestIid) {
2666
2988
  */
2667
2989
  async function getMergeRequestApprovalState(projectId, mergeRequestIid) {
2668
2990
  projectId = decodeURIComponent(projectId);
2669
- const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/approval_state`);
2670
- const response = await fetch(url.toString(), {
2991
+ const approvalStateUrl = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/approval_state`);
2992
+ const approvalStateResponse = await fetch(approvalStateUrl.toString(), {
2671
2993
  ...getFetchConfig(),
2672
2994
  method: "GET",
2673
2995
  });
2674
- await handleGitLabError(response);
2675
- return GitLabMergeRequestApprovalStateSchema.parse(await response.json());
2996
+ if (approvalStateResponse.status === 404) {
2997
+ return getMergeRequestApprovalsFallback(projectId, mergeRequestIid);
2998
+ }
2999
+ await handleGitLabError(approvalStateResponse);
3000
+ const parsedApprovalState = GitLabMergeRequestApprovalStateSchema.parse(await approvalStateResponse.json());
3001
+ const approvedByUsers = getUniqueApprovalUsers((parsedApprovalState.rules || []).flatMap(rule => rule.approved_by || []));
3002
+ const approvedByUsernames = approvedByUsers.map(user => user.username);
3003
+ return {
3004
+ ...parsedApprovalState,
3005
+ approved_by: approvedByUsers,
3006
+ approved_by_usernames: approvedByUsernames,
3007
+ source_endpoint: "approval_state",
3008
+ };
3009
+ }
3010
+ async function getMergeRequestApprovalsFallback(projectId, mergeRequestIid) {
3011
+ const approvalsUrl = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/approvals`);
3012
+ const approvalsResponse = await fetch(approvalsUrl.toString(), {
3013
+ ...getFetchConfig(),
3014
+ method: "GET",
3015
+ });
3016
+ await handleGitLabError(approvalsResponse);
3017
+ return parseApprovalsResponse(await approvalsResponse.json());
3018
+ }
3019
+ /**
3020
+ * Parse the response from POST /approve and POST /unapprove endpoints.
3021
+ * These endpoints return the approvals format (approved_by contains nested
3022
+ * { user: {...} } objects), which must be converted to the flat format
3023
+ * used by GitLabMergeRequestApprovalStateSchema.
3024
+ */
3025
+ function parseApprovalsResponse(responseJson) {
3026
+ const parsedApprovals = GitLabMergeRequestApprovalsResponseSchema.parse(responseJson);
3027
+ const approvedByUsers = getUniqueApprovalUsers((parsedApprovals.approved_by || []).map(approvedByEntry => approvedByEntry.user));
3028
+ const approvedByUsernames = approvedByUsers.map(user => user.username);
3029
+ return GitLabMergeRequestApprovalStateSchema.parse({
3030
+ approved: parsedApprovals.approved,
3031
+ user_has_approved: parsedApprovals.user_has_approved,
3032
+ user_can_approve: parsedApprovals.user_can_approve,
3033
+ approved_by: approvedByUsers,
3034
+ approved_by_usernames: approvedByUsernames,
3035
+ source_endpoint: "approvals",
3036
+ });
3037
+ }
3038
+ function getUniqueApprovalUsers(users) {
3039
+ const uniqueUsers = new Map();
3040
+ for (const user of users) {
3041
+ if (!uniqueUsers.has(user.id)) {
3042
+ uniqueUsers.set(user.id, user);
3043
+ }
3044
+ }
3045
+ return [...uniqueUsers.values()];
3046
+ }
3047
+ function inferMergeRequestApproved(rules) {
3048
+ if (!rules || rules.length === 0) {
3049
+ return null;
3050
+ }
3051
+ if (rules.some(rule => typeof rule.approved !== "boolean")) {
3052
+ return null;
3053
+ }
3054
+ return rules.every(rule => rule.approved === true);
2676
3055
  }
2677
3056
  /**
2678
3057
  * Create a new note (comment) on an issue or merge request
@@ -3287,6 +3666,89 @@ async function listGroupProjects(options) {
3287
3666
  const projects = await response.json();
3288
3667
  return GitLabProjectSchema.array().parse(projects);
3289
3668
  }
3669
+ // Webhook API helper functions
3670
+ /**
3671
+ * Build the base URL for webhooks (project or group)
3672
+ */
3673
+ function buildWebhookBaseUrl(projectId, groupId) {
3674
+ if (projectId) {
3675
+ projectId = decodeURIComponent(projectId);
3676
+ return `${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/hooks`;
3677
+ }
3678
+ const decodedGroupId = decodeURIComponent(groupId);
3679
+ return `${getEffectiveApiUrl()}/groups/${encodeURIComponent(decodedGroupId)}/hooks`;
3680
+ }
3681
+ /**
3682
+ * List webhooks for a project or group
3683
+ */
3684
+ async function listWebhooks(options) {
3685
+ const url = new URL(buildWebhookBaseUrl(options.project_id, options.group_id));
3686
+ if (options.page)
3687
+ url.searchParams.append("page", options.page.toString());
3688
+ if (options.per_page)
3689
+ url.searchParams.append("per_page", options.per_page.toString());
3690
+ const response = await fetch(url.toString(), { ...getFetchConfig() });
3691
+ await handleGitLabError(response);
3692
+ return (await response.json());
3693
+ }
3694
+ /**
3695
+ * Summarize webhook events by stripping heavy payload fields
3696
+ */
3697
+ function summarizeWebhookEvents(events) {
3698
+ return events.map(event => ({
3699
+ id: event.id,
3700
+ url: event.url,
3701
+ trigger: event.trigger,
3702
+ response_status: event.response_status,
3703
+ execution_duration: event.execution_duration,
3704
+ }));
3705
+ }
3706
+ /**
3707
+ * Fetch a single page of webhook events
3708
+ */
3709
+ async function fetchWebhookEventsPage(baseUrl, page, perPage, status) {
3710
+ const url = new URL(baseUrl);
3711
+ url.searchParams.set("page", page.toString());
3712
+ url.searchParams.set("per_page", perPage.toString());
3713
+ if (status !== undefined)
3714
+ url.searchParams.append("status", String(status));
3715
+ const response = await fetch(url.toString(), { ...getFetchConfig() });
3716
+ await handleGitLabError(response);
3717
+ return (await response.json());
3718
+ }
3719
+ /**
3720
+ * List webhook events for a project or group webhook
3721
+ */
3722
+ async function listWebhookEvents(options) {
3723
+ const eventsUrl = `${buildWebhookBaseUrl(options.project_id, options.group_id)}/${options.hook_id}/events`;
3724
+ const events = await fetchWebhookEventsPage(eventsUrl, options.page ?? 1, options.per_page ?? 20, options.status);
3725
+ return options.summary ? summarizeWebhookEvents(events) : events;
3726
+ }
3727
+ /**
3728
+ * Get a specific webhook event by ID (searches up to 500 recent events)
3729
+ */
3730
+ async function getWebhookEvent(options) {
3731
+ const eventsUrl = `${buildWebhookBaseUrl(options.project_id, options.group_id)}/${options.hook_id}/events`;
3732
+ // GitLab enforces max per_page=20 for webhook events
3733
+ const perPage = 20;
3734
+ if (options.page) {
3735
+ // Direct page lookup — single API call
3736
+ const events = await fetchWebhookEventsPage(eventsUrl, options.page, perPage);
3737
+ const match = events.find(e => e.id === options.event_id);
3738
+ return match ?? null;
3739
+ }
3740
+ // Auto-paginate up to 500 events
3741
+ const maxPages = 25;
3742
+ for (let page = 1; page <= maxPages; page++) {
3743
+ const events = await fetchWebhookEventsPage(eventsUrl, page, perPage);
3744
+ const match = events.find(e => e.id === options.event_id);
3745
+ if (match)
3746
+ return match;
3747
+ if (events.length < perPage)
3748
+ break;
3749
+ }
3750
+ return null;
3751
+ }
3290
3752
  // Wiki API helper functions
3291
3753
  /**
3292
3754
  * List wiki pages in a project
@@ -3409,6 +3871,90 @@ async function getPipeline(projectId, pipelineId) {
3409
3871
  const data = await response.json();
3410
3872
  return GitLabPipelineSchema.parse(data);
3411
3873
  }
3874
+ /**
3875
+ * List deployments in a GitLab project
3876
+ *
3877
+ * @param {string} projectId - The ID or URL-encoded path of the project
3878
+ * @param {ListDeploymentsOptions} options - Options for filtering deployments
3879
+ * @returns {Promise<GitLabDeployment[]>} List of deployments
3880
+ */
3881
+ async function listDeployments(projectId, options = {}) {
3882
+ projectId = decodeURIComponent(projectId);
3883
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/deployments`);
3884
+ Object.entries(options).forEach(([key, value]) => {
3885
+ if (value !== undefined) {
3886
+ url.searchParams.append(key, value.toString());
3887
+ }
3888
+ });
3889
+ const response = await fetch(url.toString(), {
3890
+ ...getFetchConfig(),
3891
+ });
3892
+ await handleGitLabError(response);
3893
+ const data = await response.json();
3894
+ return z.array(GitLabDeploymentSchema).parse(data);
3895
+ }
3896
+ /**
3897
+ * Get details of a specific deployment
3898
+ *
3899
+ * @param {string} projectId - The ID or URL-encoded path of the project
3900
+ * @param {number | string} deploymentId - The ID of the deployment
3901
+ * @returns {Promise<GitLabDeployment>} Deployment details
3902
+ */
3903
+ async function getDeployment(projectId, deploymentId) {
3904
+ projectId = decodeURIComponent(projectId);
3905
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/deployments/${deploymentId}`);
3906
+ const response = await fetch(url.toString(), {
3907
+ ...getFetchConfig(),
3908
+ });
3909
+ if (response.status === 404) {
3910
+ throw new Error(`Deployment not found`);
3911
+ }
3912
+ await handleGitLabError(response);
3913
+ const data = await response.json();
3914
+ return GitLabDeploymentSchema.parse(data);
3915
+ }
3916
+ /**
3917
+ * List environments in a GitLab project
3918
+ *
3919
+ * @param {string} projectId - The ID or URL-encoded path of the project
3920
+ * @param {ListEnvironmentsOptions} options - Options for filtering environments
3921
+ * @returns {Promise<GitLabEnvironment[]>} List of environments
3922
+ */
3923
+ async function listEnvironments(projectId, options = {}) {
3924
+ projectId = decodeURIComponent(projectId);
3925
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/environments`);
3926
+ Object.entries(options).forEach(([key, value]) => {
3927
+ if (value !== undefined) {
3928
+ url.searchParams.append(key, value.toString());
3929
+ }
3930
+ });
3931
+ const response = await fetch(url.toString(), {
3932
+ ...getFetchConfig(),
3933
+ });
3934
+ await handleGitLabError(response);
3935
+ const data = await response.json();
3936
+ return z.array(GitLabEnvironmentSchema).parse(data);
3937
+ }
3938
+ /**
3939
+ * Get details of a specific environment
3940
+ *
3941
+ * @param {string} projectId - The ID or URL-encoded path of the project
3942
+ * @param {number | string} environmentId - The ID of the environment
3943
+ * @returns {Promise<GitLabEnvironment>} Environment details
3944
+ */
3945
+ async function getEnvironment(projectId, environmentId) {
3946
+ projectId = decodeURIComponent(projectId);
3947
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/environments/${environmentId}`);
3948
+ const response = await fetch(url.toString(), {
3949
+ ...getFetchConfig(),
3950
+ });
3951
+ if (response.status === 404) {
3952
+ throw new Error(`Environment not found`);
3953
+ }
3954
+ await handleGitLabError(response);
3955
+ const data = await response.json();
3956
+ return GitLabEnvironmentSchema.parse(data);
3957
+ }
3412
3958
  /**
3413
3959
  * List all jobs in a specific pipeline
3414
3960
  *
@@ -3533,21 +4079,107 @@ async function getPipelineJobOutput(projectId, jobId, limit, offset) {
3533
4079
  }
3534
4080
  return fullTrace;
3535
4081
  }
4082
+ /**
4083
+ * List artifact files in a job's artifacts archive
4084
+ *
4085
+ * @param {string} projectId - The ID or URL-encoded path of the project
4086
+ * @param {string} jobId - The ID of the job
4087
+ * @param {Object} options - Options for listing artifacts
4088
+ * @returns {Promise<GitLabArtifactEntry[]>} List of artifact entries
4089
+ */
4090
+ async function listJobArtifacts(projectId, jobId, options = {}) {
4091
+ projectId = decodeURIComponent(projectId);
4092
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/jobs/${jobId}/artifacts/tree`);
4093
+ Object.entries(options).forEach(([key, value]) => {
4094
+ if (value !== undefined) {
4095
+ if (typeof value === "boolean") {
4096
+ url.searchParams.append(key, value ? "true" : "false");
4097
+ }
4098
+ else {
4099
+ url.searchParams.append(key, value.toString());
4100
+ }
4101
+ }
4102
+ });
4103
+ const response = await fetch(url.toString(), {
4104
+ ...getFetchConfig(),
4105
+ });
4106
+ if (response.status === 404) {
4107
+ throw new Error(`Job artifacts not found. The job may not have produced artifacts or the job ID is invalid.`);
4108
+ }
4109
+ await handleGitLabError(response);
4110
+ const data = await response.json();
4111
+ return z.array(GitLabArtifactEntrySchema).parse(data);
4112
+ }
4113
+ /**
4114
+ * Download the entire artifact archive for a job and save to disk
4115
+ *
4116
+ * @param {string} projectId - The ID or URL-encoded path of the project
4117
+ * @param {string} jobId - The ID of the job
4118
+ * @param {string} localPath - Optional local directory to save the archive
4119
+ * @returns {Promise<string>} The path where the artifact archive was saved
4120
+ */
4121
+ async function downloadJobArtifacts(projectId, jobId, localPath) {
4122
+ projectId = decodeURIComponent(projectId);
4123
+ const effectiveProjectId = getEffectiveProjectId(projectId);
4124
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/jobs/${jobId}/artifacts`);
4125
+ const response = await fetch(url.toString(), {
4126
+ ...getFetchConfig(),
4127
+ });
4128
+ if (response.status === 404) {
4129
+ throw new Error(`Job artifacts not found. The job may not have produced artifacts or the job ID is invalid.`);
4130
+ }
4131
+ await handleGitLabError(response);
4132
+ const buffer = await response.arrayBuffer();
4133
+ const filename = `artifacts_job_${jobId}.zip`;
4134
+ const savePath = localPath ? path.join(localPath, filename) : filename;
4135
+ fs.mkdirSync(path.dirname(savePath), { recursive: true });
4136
+ fs.writeFileSync(savePath, Buffer.from(buffer));
4137
+ return savePath;
4138
+ }
4139
+ /**
4140
+ * Download a single file from a job's artifacts
4141
+ *
4142
+ * @param {string} projectId - The ID or URL-encoded path of the project
4143
+ * @param {string} jobId - The ID of the job
4144
+ * @param {string} artifactPath - Path to the file within the artifacts archive
4145
+ * @returns {Promise<string>} The file content as text
4146
+ */
4147
+ async function getJobArtifactFile(projectId, jobId, artifactPath) {
4148
+ projectId = decodeURIComponent(projectId);
4149
+ const effectiveProjectId = getEffectiveProjectId(projectId);
4150
+ const encodedArtifactPath = artifactPath
4151
+ .split("/")
4152
+ .map(segment => encodeURIComponent(segment))
4153
+ .join("/");
4154
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/jobs/${jobId}/artifacts/${encodedArtifactPath}`);
4155
+ const response = await fetch(url.toString(), {
4156
+ ...getFetchConfig(),
4157
+ });
4158
+ if (response.status === 404) {
4159
+ throw new Error(`Artifact file not found: ${artifactPath}`);
4160
+ }
4161
+ await handleGitLabError(response);
4162
+ return await response.text();
4163
+ }
3536
4164
  /**
3537
4165
  * Create a new pipeline
3538
4166
  *
3539
4167
  * @param {string} projectId - The ID or URL-encoded path of the project
3540
4168
  * @param {string} ref - The branch or tag to run the pipeline on
3541
4169
  * @param {Array} variables - Optional variables for the pipeline
4170
+ * @param {Record<string, string>} inputs - Optional input parameters for the pipeline
3542
4171
  * @returns {Promise<GitLabPipeline>} The created pipeline
3543
4172
  */
3544
- async function createPipeline(projectId, ref, variables) {
4173
+ async function createPipeline(projectId, ref, variables, inputs) {
3545
4174
  projectId = decodeURIComponent(projectId); // Decode project ID
3546
4175
  const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipeline`);
3547
4176
  const body = { ref };
3548
4177
  if (variables && variables.length > 0) {
3549
4178
  body.variables = variables;
3550
4179
  }
4180
+ if (inputs && Object.keys(inputs).length > 0) {
4181
+ body.inputs = inputs;
4182
+ }
3551
4183
  const response = await fetch(url.toString(), {
3552
4184
  method: "POST",
3553
4185
  headers: {
@@ -4450,6 +5082,8 @@ async function handleToolCall(params) {
4450
5082
  if (GITLAB_AUTH_COOKIE_PATH) {
4451
5083
  await ensureSessionForRequest();
4452
5084
  }
5085
+ // Lazy OAuth token refresh: only validate/refresh when a tool is actually called
5086
+ await ensureValidOAuthToken();
4453
5087
  logger.info(params.name);
4454
5088
  switch (params.name) {
4455
5089
  case "execute_graphql": {
@@ -4678,8 +5312,22 @@ async function handleToolCall(params) {
4678
5312
  case "get_merge_request": {
4679
5313
  const args = GetMergeRequestSchema.parse(params.arguments);
4680
5314
  const mergeRequest = await getMergeRequest(args.project_id, args.merge_request_iid, args.source_branch);
5315
+ const deploymentSummary = await buildMergeRequestDeploymentSummary(args.project_id, mergeRequest);
5316
+ const commitAdditionSummary = await buildMergeRequestCommitAdditionSummary(args.project_id, mergeRequest);
5317
+ const approvalSummary = await buildMergeRequestApprovalSummary(args.project_id, mergeRequest.iid);
5318
+ const mergeRequestWithDeploymentSummary = {
5319
+ ...mergeRequest,
5320
+ deployment_summary: deploymentSummary,
5321
+ commit_addition_summary: commitAdditionSummary,
5322
+ approval_summary: approvalSummary,
5323
+ };
4681
5324
  return {
4682
- content: [{ type: "text", text: JSON.stringify(mergeRequest, null, 2) }],
5325
+ content: [
5326
+ {
5327
+ type: "text",
5328
+ text: JSON.stringify(mergeRequestWithDeploymentSummary, null, 2),
5329
+ },
5330
+ ],
4683
5331
  };
4684
5332
  }
4685
5333
  case "get_merge_request_diffs": {
@@ -5138,6 +5786,36 @@ async function handleToolCall(params) {
5138
5786
  ],
5139
5787
  };
5140
5788
  }
5789
+ case "list_deployments": {
5790
+ const args = ListDeploymentsSchema.parse(params.arguments);
5791
+ const { project_id, ...options } = args;
5792
+ const deployments = await listDeployments(project_id, options);
5793
+ return {
5794
+ content: [{ type: "text", text: JSON.stringify(deployments, null, 2) }],
5795
+ };
5796
+ }
5797
+ case "get_deployment": {
5798
+ const { project_id, deployment_id } = GetDeploymentSchema.parse(params.arguments);
5799
+ const deployment = await getDeployment(project_id, deployment_id);
5800
+ return {
5801
+ content: [{ type: "text", text: JSON.stringify(deployment, null, 2) }],
5802
+ };
5803
+ }
5804
+ case "list_environments": {
5805
+ const args = ListEnvironmentsSchema.parse(params.arguments);
5806
+ const { project_id, ...options } = args;
5807
+ const environments = await listEnvironments(project_id, options);
5808
+ return {
5809
+ content: [{ type: "text", text: JSON.stringify(environments, null, 2) }],
5810
+ };
5811
+ }
5812
+ case "get_environment": {
5813
+ const { project_id, environment_id } = GetEnvironmentSchema.parse(params.arguments);
5814
+ const environment = await getEnvironment(project_id, environment_id);
5815
+ return {
5816
+ content: [{ type: "text", text: JSON.stringify(environment, null, 2) }],
5817
+ };
5818
+ }
5141
5819
  case "list_pipeline_jobs": {
5142
5820
  const { project_id, pipeline_id, ...options } = ListPipelineJobsSchema.parse(params.arguments);
5143
5821
  const jobs = await listPipelineJobs(project_id, pipeline_id, options);
@@ -5187,8 +5865,8 @@ async function handleToolCall(params) {
5187
5865
  };
5188
5866
  }
5189
5867
  case "create_pipeline": {
5190
- const { project_id, ref, variables } = CreatePipelineSchema.parse(params.arguments);
5191
- const pipeline = await createPipeline(project_id, ref, variables);
5868
+ const { project_id, ref, variables, inputs } = CreatePipelineSchema.parse(params.arguments);
5869
+ const pipeline = await createPipeline(project_id, ref, variables, inputs);
5192
5870
  return {
5193
5871
  content: [
5194
5872
  {
@@ -5258,6 +5936,42 @@ async function handleToolCall(params) {
5258
5936
  ],
5259
5937
  };
5260
5938
  }
5939
+ case "list_job_artifacts": {
5940
+ const { project_id, job_id, ...options } = ListJobArtifactsSchema.parse(params.arguments);
5941
+ const artifacts = await listJobArtifacts(project_id, job_id, options);
5942
+ return {
5943
+ content: [
5944
+ {
5945
+ type: "text",
5946
+ text: JSON.stringify(artifacts, null, 2),
5947
+ },
5948
+ ],
5949
+ };
5950
+ }
5951
+ case "download_job_artifacts": {
5952
+ const { project_id, job_id, local_path } = DownloadJobArtifactsSchema.parse(params.arguments);
5953
+ const filePath = await downloadJobArtifacts(project_id, job_id, local_path);
5954
+ return {
5955
+ content: [
5956
+ {
5957
+ type: "text",
5958
+ text: JSON.stringify({ success: true, file_path: filePath }, null, 2),
5959
+ },
5960
+ ],
5961
+ };
5962
+ }
5963
+ case "get_job_artifact_file": {
5964
+ const { project_id, job_id, artifact_path } = GetJobArtifactFileSchema.parse(params.arguments);
5965
+ const fileContent = await getJobArtifactFile(project_id, job_id, artifact_path);
5966
+ return {
5967
+ content: [
5968
+ {
5969
+ type: "text",
5970
+ text: fileContent,
5971
+ },
5972
+ ],
5973
+ };
5974
+ }
5261
5975
  case "list_merge_requests": {
5262
5976
  const args = ListMergeRequestsSchema.parse(params.arguments);
5263
5977
  const mergeRequests = await listMergeRequests(args.project_id, args);
@@ -5513,6 +6227,40 @@ async function handleToolCall(params) {
5513
6227
  content: [{ type: "text", text: assetContent }],
5514
6228
  };
5515
6229
  }
6230
+ case "list_webhooks": {
6231
+ const args = ListWebhooksSchema.parse(params.arguments);
6232
+ const webhooks = await listWebhooks(args);
6233
+ return {
6234
+ content: [{ type: "text", text: JSON.stringify(webhooks, null, 2) }],
6235
+ };
6236
+ }
6237
+ case "list_webhook_events": {
6238
+ const args = ListWebhookEventsSchema.parse(params.arguments);
6239
+ const events = await listWebhookEvents(args);
6240
+ return {
6241
+ content: [{ type: "text", text: JSON.stringify(events, null, 2) }],
6242
+ };
6243
+ }
6244
+ case "get_webhook_event": {
6245
+ const args = GetWebhookEventSchema.parse(params.arguments);
6246
+ const event = await getWebhookEvent(args);
6247
+ if (!event) {
6248
+ const searchScope = args.page
6249
+ ? `on page ${args.page}`
6250
+ : "in the 500 most recent events";
6251
+ return {
6252
+ content: [
6253
+ {
6254
+ type: "text",
6255
+ text: JSON.stringify({ error: `Webhook event ${args.event_id} not found ${searchScope}` }, null, 2),
6256
+ },
6257
+ ],
6258
+ };
6259
+ }
6260
+ return {
6261
+ content: [{ type: "text", text: JSON.stringify(event, null, 2) }],
6262
+ };
6263
+ }
5516
6264
  default:
5517
6265
  throw new Error(`Unknown tool: ${params.name}`);
5518
6266
  }
@@ -6004,9 +6752,10 @@ async function runServer() {
6004
6752
  logger.info("Using OAuth authentication...");
6005
6753
  try {
6006
6754
  const gitlabBaseUrl = GITLAB_API_URL.replace(/\/api\/v4$/, "");
6007
- OAUTH_ACCESS_TOKEN = await initializeOAuth(gitlabBaseUrl);
6755
+ const oauthResult = await initializeOAuthClient(gitlabBaseUrl);
6756
+ oauthClient = oauthResult.client;
6757
+ OAUTH_ACCESS_TOKEN = oauthResult.accessToken;
6008
6758
  logger.info("OAuth authentication successful");
6009
- // Note: Headers are automatically generated by buildAuthHeaders() using OAUTH_ACCESS_TOKEN
6010
6759
  }
6011
6760
  catch (error) {
6012
6761
  logger.error("OAuth authentication failed:", error);