@zereight/mcp-gitlab 2.0.34 → 2.0.35

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
@@ -37,20 +37,23 @@ import { fileURLToPath, URL } from "node:url";
37
37
  import { z } from "zod";
38
38
  import { zodToJsonSchema } from "zod-to-json-schema";
39
39
  import { initializeOAuthClient } from "./oauth.js";
40
+ import { createGitLabOAuthProvider } from "./oauth-proxy.js";
41
+ import { mcpAuthRouter } from "@modelcontextprotocol/sdk/server/auth/router.js";
42
+ import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
40
43
  import { GitLabClientPool } from "./gitlab-client-pool.js";
41
44
  // Add type imports for proxy agents
42
45
  import { Agent } from "node:http";
43
46
  import { Agent as HttpsAgent } from "node:https";
44
47
  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, GetDeploymentSchema, GetEnvironmentSchema, GetNamespaceSchema,
48
+ CreateMergeRequestNoteSchema, CreateMergeRequestDiscussionNoteSchema, CreateMergeRequestSchema, CreateMergeRequestThreadSchema, CreateNoteSchema, CreateOrUpdateFileSchema, CreatePipelineSchema, CreateProjectMilestoneSchema, CreateRepositorySchema, CreateWikiPageSchema, CreateGroupWikiPageSchema, DeleteDraftNoteSchema, DeleteGroupWikiPageSchema, 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
49
  // pipeline job schemas
47
50
  GetPipelineJobOutputSchema, GetPipelineSchema, GetProjectMilestoneSchema, GetProjectSchema, GetRepositoryTreeSchema, GetUsersSchema, GetWikiPageSchema, GitLabCommitSchema, GitLabCompareResultSchema, GitLabContentSchema, GitLabCreateUpdateFileResponseSchema, GitLabDiffSchema,
48
51
  // Discussion Schemas
49
52
  GitLabDiscussionNoteSchema, // Added
50
53
  GitLabDiscussionSchema,
51
54
  // Draft Notes Schemas
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, GetMergeRequestConflictsSchema, 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";
55
+ GitLabDraftNoteSchema, GitLabForkSchema, GitLabIssueLinkSchema, GitLabIssueSchema, GitLabIssueWithLinkDetailsSchema, GitLabMarkdownUploadSchema, GitLabMergeRequestSchema, GitLabMilestonesSchema, GitLabNamespaceExistsResponseSchema, GitLabNamespaceSchema, GitLabPipelineJobSchema, GitLabDeploymentSchema, GitLabEnvironmentSchema, GitLabPipelineSchema, GitLabPipelineTriggerJobSchema, GitLabProjectMemberSchema, GitLabProjectSchema, GitLabReferenceSchema, GitLabRepositorySchema, GitLabSearchBlobResultSchema, GitLabSearchResponseSchema, GitLabTreeItemSchema, GitLabTreeSchema, GitLabUserSchema, GitLabUsersResponseSchema, GitLabWikiPageSchema, GroupIteration, ListCommitsSchema, ListDraftNotesSchema, ListGroupIterationsSchema, ListGroupProjectsSchema, ListIssueDiscussionsSchema, ListIssueLinksSchema, ListIssuesSchema, ListLabelsSchema, ListMergeRequestDiffsSchema, // Added
56
+ GetMergeRequestFileDiffSchema, ListMergeRequestChangedFilesSchema, ListMergeRequestDiscussionsSchema, ListMergeRequestsSchema, ListMergeRequestVersionsSchema, GetMergeRequestVersionSchema, GitLabMergeRequestVersionSchema, GitLabMergeRequestVersionDetailSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelinesSchema, ListDeploymentsSchema, ListEnvironmentsSchema, ListPipelineTriggerJobsSchema, ListProjectMembersSchema, ListProjectMilestonesSchema, ListProjectsSchema, ListWikiPagesSchema, GetGroupWikiPageSchema, ListGroupWikiPagesSchema, UpdateGroupWikiPageSchema, MarkdownUploadSchema, DownloadAttachmentSchema, DownloadJobArtifactsSchema, GetJobArtifactFileSchema, GitLabArtifactEntrySchema, ListJobArtifactsSchema, MergeMergeRequestSchema, ApproveMergeRequestSchema, UnapproveMergeRequestSchema, GetMergeRequestApprovalStateSchema, GetMergeRequestConflictsSchema, GitLabMergeRequestApprovalsResponseSchema, GitLabMergeRequestApprovalStateSchema, MyIssuesSchema, PaginatedDiscussionsResponseSchema, PromoteProjectMilestoneSchema, PublishDraftNoteSchema, PlayPipelineJobSchema, PushFilesSchema, RetryPipelineJobSchema, RetryPipelineSchema, SearchCodeSchema, SearchGroupCodeSchema, SearchProjectCodeSchema, 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, GetWorkItemSchema, ListWorkItemsSchema, CreateWorkItemSchema, UpdateWorkItemSchema, ConvertWorkItemTypeSchema, ListWorkItemStatusesSchema, ListWorkItemNotesSchema, CreateWorkItemNoteSchema, MoveWorkItemSchema, ListCustomFieldDefinitionsSchema, GetTimelineEventsSchema, CreateTimelineEventSchema, ListWebhooksSchema, ListWebhookEventsSchema, GetWebhookEventSchema, } from "./schemas.js";
54
57
  import { randomUUID } from "node:crypto";
55
58
  import { pino } from "pino";
56
59
  const logger = pino({
@@ -228,8 +231,37 @@ function validateConfiguration() {
228
231
  const hasToken = !!getConfig("token", "GITLAB_PERSONAL_ACCESS_TOKEN");
229
232
  const hasJobToken = !!getConfig("job-token", "GITLAB_JOB_TOKEN");
230
233
  const hasCookie = !!getConfig("cookie-path", "GITLAB_AUTH_COOKIE_PATH");
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)");
234
+ const mcpOAuth = getConfig("mcp-oauth", "GITLAB_MCP_OAUTH") === "true";
235
+ const mcpServerUrl = getConfig("mcp-server-url", "MCP_SERVER_URL");
236
+ if (!remoteAuth && !useOAuth && !hasToken && !hasJobToken && !hasCookie && !mcpOAuth) {
237
+ errors.push("Either --token, --job-token, --cookie-path, --use-oauth=true, --remote-auth=true, or --mcp-oauth=true must be set (or use environment variables)");
238
+ }
239
+ if (mcpOAuth) {
240
+ if (!mcpServerUrl) {
241
+ errors.push("MCP_SERVER_URL is required when GITLAB_MCP_OAUTH=true (e.g. https://mcp.example.com)");
242
+ }
243
+ else {
244
+ try {
245
+ const u = new URL(mcpServerUrl);
246
+ const isInsecure = u.protocol !== "https:";
247
+ const isLocalhost = u.hostname === "localhost" || u.hostname === "127.0.0.1";
248
+ const allowInsecure = process.env.MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL === "true";
249
+ if (isInsecure && !isLocalhost && !allowInsecure) {
250
+ errors.push("MCP_SERVER_URL must use HTTPS in production " +
251
+ "(set MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL=true for local dev)");
252
+ }
253
+ }
254
+ catch {
255
+ errors.push(`MCP_SERVER_URL is not a valid URL: ${mcpServerUrl}`);
256
+ }
257
+ }
258
+ if (!getConfig("api-url", "GITLAB_API_URL")) {
259
+ errors.push("GITLAB_API_URL is required when GITLAB_MCP_OAUTH=true");
260
+ }
261
+ if (!getConfig("oauth-app-id", "GITLAB_OAUTH_APP_ID")) {
262
+ errors.push("GITLAB_OAUTH_APP_ID is required when GITLAB_MCP_OAUTH=true " +
263
+ "(create an OAuth application in GitLab Admin with the required scopes)");
264
+ }
233
265
  }
234
266
  const enableDynamicApiUrl = getConfig("enable-dynamic-api-url", "ENABLE_DYNAMIC_API_URL") === "true";
235
267
  if (enableDynamicApiUrl && !remoteAuth) {
@@ -283,7 +315,8 @@ const GITLAB_DENIED_TOOLS_REGEX = (() => {
283
315
  }
284
316
  // Reject patterns with nested quantifiers that can cause catastrophic backtracking (ReDoS)
285
317
  // e.g., (a+)+, (a*)+, (a+)*, (a{1,})+
286
- const NESTED_QUANTIFIER_PATTERN = /(\(.*[+*?].*\)|\[.*\])[+*?]|\(\?[^:)]/;
318
+ // Note: lookahead (?!), (?=), lookbehind (?<), and named groups (?<name>) are safe and allowed
319
+ const NESTED_QUANTIFIER_PATTERN = /(\(.*[+*?].*\)|\[.*\])[+*?]/;
287
320
  if (NESTED_QUANTIFIER_PATTERN.test(pattern)) {
288
321
  logger.error(`GITLAB_DENIED_TOOLS_REGEX contains potentially unsafe nested quantifiers. Ignoring.`);
289
322
  return undefined;
@@ -307,6 +340,9 @@ const GITLAB_TOOLS_RAW = getConfig("tools", "GITLAB_TOOLS");
307
340
  const SSE = getConfig("sse", "SSE") === "true";
308
341
  const STREAMABLE_HTTP = getConfig("streamable-http", "STREAMABLE_HTTP") === "true";
309
342
  const REMOTE_AUTHORIZATION = getConfig("remote-auth", "REMOTE_AUTHORIZATION") === "true";
343
+ const GITLAB_MCP_OAUTH = getConfig("mcp-oauth", "GITLAB_MCP_OAUTH") === "true";
344
+ const MCP_SERVER_URL = getConfig("mcp-server-url", "MCP_SERVER_URL");
345
+ const GITLAB_OAUTH_APP_ID = getConfig("oauth-app-id", "GITLAB_OAUTH_APP_ID");
310
346
  const ENABLE_DYNAMIC_API_URL = getConfig("enable-dynamic-api-url", "ENABLE_DYNAMIC_API_URL") === "true";
311
347
  const SESSION_TIMEOUT_SECONDS = Number.parseInt(getConfig("session-timeout", "SESSION_TIMEOUT_SECONDS", "3600"), 10);
312
348
  const HOST = getConfig("host", "HOST") || "127.0.0.1";
@@ -482,11 +518,11 @@ const BASE_HEADERS = {
482
518
  };
483
519
  /**
484
520
  * Build authentication headers dynamically based on context
485
- * In REMOTE_AUTHORIZATION mode, reads from AsyncLocalStorage session context
521
+ * In REMOTE_AUTHORIZATION or GITLAB_MCP_OAUTH mode, reads from AsyncLocalStorage session context
486
522
  * Otherwise, uses environment token (OAuth token is refreshed lazily before each tool call)
487
523
  */
488
524
  function buildAuthHeaders() {
489
- if (REMOTE_AUTHORIZATION) {
525
+ if (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) {
490
526
  const ctx = sessionAuthStore.getStore();
491
527
  logger.debug({ context: ctx }, "buildAuthHeaders: session context");
492
528
  if (ctx?.token) {
@@ -670,11 +706,32 @@ const allTools = [
670
706
  description: "Get the changes/diffs of a merge request (Either mergeRequestIid or branchName must be provided)",
671
707
  inputSchema: toJSONSchema(GetMergeRequestDiffsSchema),
672
708
  },
709
+ {
710
+ name: "list_merge_request_changed_files",
711
+ description: "STEP 1 of code review workflow. " +
712
+ "Returns ONLY the list of changed file paths in a merge request — WITHOUT diff content. " +
713
+ "Call this first to get file paths, then call get_merge_request_file_diff with multiple files in a single batched call (recommended 3-5 files per call). " +
714
+ "This avoids loading the entire diff payload at once and reduces API calls. " +
715
+ "Supports excluded_file_patterns filtering using regex. " +
716
+ "Returns: new_path, old_path, new_file, deleted_file, renamed_file flags for each file. " +
717
+ "(Either mergeRequestIid or branchName must be provided)",
718
+ inputSchema: toJSONSchema(ListMergeRequestChangedFilesSchema),
719
+ },
673
720
  {
674
721
  name: "list_merge_request_diffs",
675
722
  description: "List merge request diffs with pagination support (Either mergeRequestIid or branchName must be provided)",
676
723
  inputSchema: toJSONSchema(ListMergeRequestDiffsSchema),
677
724
  },
725
+ {
726
+ name: "get_merge_request_file_diff",
727
+ description: "STEP 2 of code review workflow. " +
728
+ "Get diffs for one or more files from a merge request. " +
729
+ "Call list_merge_request_changed_files first to get file paths, then pass them as an array to fetch their diffs efficiently. " +
730
+ "Batching multiple files (recommended 3-5) is supported and preferred over individual requests. " +
731
+ "Returns an array of results - one per requested file path. Files not found are returned with error messages. " +
732
+ "(Either mergeRequestIid or branchName must be provided)",
733
+ inputSchema: toJSONSchema(GetMergeRequestFileDiffSchema),
734
+ },
678
735
  {
679
736
  name: "list_merge_request_versions",
680
737
  description: "List all versions of a merge request",
@@ -935,6 +992,31 @@ const allTools = [
935
992
  description: "Delete a wiki page from a GitLab project",
936
993
  inputSchema: toJSONSchema(DeleteWikiPageSchema),
937
994
  },
995
+ {
996
+ name: "list_group_wiki_pages",
997
+ description: "List wiki pages in a GitLab group",
998
+ inputSchema: toJSONSchema(ListGroupWikiPagesSchema),
999
+ },
1000
+ {
1001
+ name: "get_group_wiki_page",
1002
+ description: "Get details of a specific group wiki page",
1003
+ inputSchema: toJSONSchema(GetGroupWikiPageSchema),
1004
+ },
1005
+ {
1006
+ name: "create_group_wiki_page",
1007
+ description: "Create a new wiki page in a GitLab group",
1008
+ inputSchema: toJSONSchema(CreateGroupWikiPageSchema),
1009
+ },
1010
+ {
1011
+ name: "update_group_wiki_page",
1012
+ description: "Update an existing wiki page in a GitLab group",
1013
+ inputSchema: toJSONSchema(UpdateGroupWikiPageSchema),
1014
+ },
1015
+ {
1016
+ name: "delete_group_wiki_page",
1017
+ description: "Delete a wiki page from a GitLab group",
1018
+ inputSchema: toJSONSchema(DeleteGroupWikiPageSchema),
1019
+ },
938
1020
  {
939
1021
  name: "get_repository_tree",
940
1022
  description: "Get the repository tree for a GitLab project (list files and directories)",
@@ -1165,6 +1247,68 @@ const allTools = [
1165
1247
  description: "Download a release asset file by direct asset path",
1166
1248
  inputSchema: toJSONSchema(DownloadReleaseAssetSchema),
1167
1249
  },
1250
+ // --- Work item tools (GraphQL-based) ---
1251
+ {
1252
+ name: "get_work_item",
1253
+ description: "Get a single work item with full details including status, hierarchy (parent/children), type, labels, assignees, and all widgets.",
1254
+ inputSchema: toJSONSchema(GetWorkItemSchema),
1255
+ },
1256
+ {
1257
+ name: "list_work_items",
1258
+ description: "List work items in a project with filters (type, state, search, assignees, labels). Returns items with status and hierarchy info.",
1259
+ inputSchema: toJSONSchema(ListWorkItemsSchema),
1260
+ },
1261
+ {
1262
+ name: "create_work_item",
1263
+ description: "Create a new work item (issue, task, incident, test_case, epic, key_result, objective, requirement, ticket). Supports setting title, description, labels, assignees, weight, parent, health status, start/due dates, milestone, and confidentiality.",
1264
+ inputSchema: toJSONSchema(CreateWorkItemSchema),
1265
+ },
1266
+ {
1267
+ name: "update_work_item",
1268
+ description: "Update a work item. Can modify title, description, labels, assignees, weight, state, status, parent hierarchy, children, health status, start/due dates, milestone, confidentiality, linked items, and custom fields.",
1269
+ inputSchema: toJSONSchema(UpdateWorkItemSchema),
1270
+ },
1271
+ {
1272
+ name: "convert_work_item_type",
1273
+ description: "Convert a work item to a different type (e.g. issue to task, task to incident).",
1274
+ inputSchema: toJSONSchema(ConvertWorkItemTypeSchema),
1275
+ },
1276
+ {
1277
+ name: "list_work_item_statuses",
1278
+ description: "List available statuses for a work item type in a project. Requires GitLab Premium/Ultimate with configurable statuses.",
1279
+ inputSchema: toJSONSchema(ListWorkItemStatusesSchema),
1280
+ },
1281
+ {
1282
+ name: "list_custom_field_definitions",
1283
+ description: "List available custom field definitions for a work item type in a project. Returns field names, types, and IDs needed for setting custom fields via update_work_item.",
1284
+ inputSchema: toJSONSchema(ListCustomFieldDefinitionsSchema),
1285
+ },
1286
+ {
1287
+ name: "move_work_item",
1288
+ description: "Move a work item (issue, task, etc.) to a different project. Uses GitLab GraphQL issueMove mutation.",
1289
+ inputSchema: toJSONSchema(MoveWorkItemSchema),
1290
+ },
1291
+ {
1292
+ name: "list_work_item_notes",
1293
+ description: "List notes and discussions on a work item. Returns threaded discussions with author, body, timestamps, and system/internal flags.",
1294
+ inputSchema: toJSONSchema(ListWorkItemNotesSchema),
1295
+ },
1296
+ {
1297
+ name: "create_work_item_note",
1298
+ description: "Add a note/comment to a work item. Supports Markdown, internal notes, and threaded replies.",
1299
+ inputSchema: toJSONSchema(CreateWorkItemNoteSchema),
1300
+ },
1301
+ // --- Incident timeline event tools ---
1302
+ {
1303
+ name: "get_timeline_events",
1304
+ description: "List timeline events for an incident. Returns chronological events with notes, timestamps, and tags (Start time, End time, Impact detected, etc.).",
1305
+ inputSchema: toJSONSchema(GetTimelineEventsSchema),
1306
+ },
1307
+ {
1308
+ name: "create_timeline_event",
1309
+ description: "Create a timeline event on an incident. Supports tags: 'Start time', 'End time', 'Impact detected', 'Response initiated', 'Impact mitigated', 'Cause identified'.",
1310
+ inputSchema: toJSONSchema(CreateTimelineEventSchema),
1311
+ },
1168
1312
  {
1169
1313
  name: "list_webhooks",
1170
1314
  description: "List all configured webhooks for a GitLab project or group. Provide either project_id or group_id.",
@@ -1180,17 +1324,42 @@ const allTools = [
1180
1324
  description: "Get full details of a specific webhook event by ID, including request/response payloads. Searches up to 500 most recent events.",
1181
1325
  inputSchema: toJSONSchema(GetWebhookEventSchema),
1182
1326
  },
1327
+ {
1328
+ name: "search_code",
1329
+ description: "Search for code across all projects on the GitLab instance (requires advanced search or exact code search to be enabled). If exact code search (Zoekt) is enabled, the search query supports rich syntax including file:, lang:, sym: filters.",
1330
+ inputSchema: toJSONSchema(SearchCodeSchema),
1331
+ },
1332
+ {
1333
+ name: "search_project_code",
1334
+ description: "Search for code within a specific GitLab project (requires advanced search or exact code search to be enabled). If exact code search (Zoekt) is enabled, the search query supports rich syntax including file:, lang:, sym: filters.",
1335
+ inputSchema: toJSONSchema(SearchProjectCodeSchema),
1336
+ },
1337
+ {
1338
+ name: "search_group_code",
1339
+ description: "Search for code within a specific GitLab group (requires advanced search or exact code search to be enabled). If exact code search (Zoekt) is enabled, the search query supports rich syntax including file:, lang:, sym: filters.",
1340
+ inputSchema: toJSONSchema(SearchGroupCodeSchema),
1341
+ },
1183
1342
  ];
1184
1343
  // Define which tools are read-only
1185
1344
  const readOnlyTools = new Set([
1186
1345
  "search_repositories",
1346
+ "search_code",
1347
+ "search_project_code",
1348
+ "search_group_code",
1187
1349
  "execute_graphql",
1188
1350
  "get_file_contents",
1189
1351
  "get_merge_request",
1190
1352
  "get_merge_request_diffs",
1353
+ "list_merge_request_changed_files",
1354
+ "list_merge_request_diffs",
1355
+ "get_merge_request_file_diff",
1191
1356
  "list_merge_request_versions",
1192
1357
  "get_merge_request_version",
1193
1358
  "get_branch_diffs",
1359
+ "get_merge_request_note",
1360
+ "get_merge_request_notes",
1361
+ "get_draft_note",
1362
+ "list_draft_notes",
1194
1363
  "mr_discussions",
1195
1364
  "list_issues",
1196
1365
  "my_issues",
@@ -1229,6 +1398,8 @@ const readOnlyTools = new Set([
1229
1398
  "get_milestone_burndown_events",
1230
1399
  "list_wiki_pages",
1231
1400
  "get_wiki_page",
1401
+ "list_group_wiki_pages",
1402
+ "get_group_wiki_page",
1232
1403
  "get_users",
1233
1404
  "list_commits",
1234
1405
  "get_commit",
@@ -1242,6 +1413,12 @@ const readOnlyTools = new Set([
1242
1413
  "get_release",
1243
1414
  "download_release_asset",
1244
1415
  "get_merge_request_approval_state",
1416
+ "get_work_item",
1417
+ "list_work_items",
1418
+ "list_work_item_statuses",
1419
+ "list_custom_field_definitions",
1420
+ "list_work_item_notes",
1421
+ "get_timeline_events",
1245
1422
  "get_merge_request_conflicts",
1246
1423
  "list_webhooks",
1247
1424
  "list_webhook_events",
@@ -1254,6 +1431,11 @@ const wikiToolNames = new Set([
1254
1431
  "create_wiki_page",
1255
1432
  "update_wiki_page",
1256
1433
  "delete_wiki_page",
1434
+ "list_group_wiki_pages",
1435
+ "get_group_wiki_page",
1436
+ "create_group_wiki_page",
1437
+ "update_group_wiki_page",
1438
+ "delete_group_wiki_page",
1257
1439
  "upload_wiki_attachment",
1258
1440
  ]);
1259
1441
  // Define which tools are related to milestones and can be toggled by USE_MILESTONE
@@ -1302,7 +1484,9 @@ const TOOLSET_DEFINITIONS = [
1302
1484
  "get_merge_request_conflicts",
1303
1485
  "get_merge_request",
1304
1486
  "get_merge_request_diffs",
1487
+ "list_merge_request_changed_files",
1305
1488
  "list_merge_request_diffs",
1489
+ "get_merge_request_file_diff",
1306
1490
  "list_merge_request_versions",
1307
1491
  "get_merge_request_version",
1308
1492
  "update_merge_request",
@@ -1446,6 +1630,11 @@ const TOOLSET_DEFINITIONS = [
1446
1630
  "create_wiki_page",
1447
1631
  "update_wiki_page",
1448
1632
  "delete_wiki_page",
1633
+ "list_group_wiki_pages",
1634
+ "get_group_wiki_page",
1635
+ "create_group_wiki_page",
1636
+ "update_group_wiki_page",
1637
+ "delete_group_wiki_page",
1449
1638
  ]),
1450
1639
  },
1451
1640
  {
@@ -1472,6 +1661,24 @@ const TOOLSET_DEFINITIONS = [
1472
1661
  "download_attachment",
1473
1662
  ]),
1474
1663
  },
1664
+ {
1665
+ id: "workitems",
1666
+ isDefault: false,
1667
+ tools: new Set([
1668
+ "get_work_item",
1669
+ "list_work_items",
1670
+ "create_work_item",
1671
+ "update_work_item",
1672
+ "convert_work_item_type",
1673
+ "list_work_item_statuses",
1674
+ "list_custom_field_definitions",
1675
+ "move_work_item",
1676
+ "list_work_item_notes",
1677
+ "create_work_item_note",
1678
+ "get_timeline_events",
1679
+ "create_timeline_event",
1680
+ ]),
1681
+ },
1475
1682
  {
1476
1683
  id: "webhooks",
1477
1684
  isDefault: false,
@@ -1481,6 +1688,11 @@ const TOOLSET_DEFINITIONS = [
1481
1688
  "get_webhook_event",
1482
1689
  ]),
1483
1690
  },
1691
+ {
1692
+ id: "search",
1693
+ isDefault: false,
1694
+ tools: new Set(["search_code", "search_project_code", "search_group_code"]),
1695
+ },
1484
1696
  ];
1485
1697
  // Derived lookup: tool name → toolset ID
1486
1698
  const TOOLSET_BY_TOOL_NAME = new Map();
@@ -1610,7 +1822,20 @@ if (REMOTE_AUTHORIZATION) {
1610
1822
  }
1611
1823
  logger.info("Remote authorization enabled: tokens will be read from HTTP headers");
1612
1824
  }
1613
- else if (!USE_OAUTH && !GITLAB_PERSONAL_ACCESS_TOKEN && !GITLAB_JOB_TOKEN && !GITLAB_AUTH_COOKIE_PATH) {
1825
+ if (GITLAB_MCP_OAUTH) {
1826
+ if (SSE) {
1827
+ logger.error("GITLAB_MCP_OAUTH=true is not compatible with SSE transport mode");
1828
+ logger.error("Please use STREAMABLE_HTTP=true instead");
1829
+ process.exit(1);
1830
+ }
1831
+ if (!STREAMABLE_HTTP) {
1832
+ logger.error("GITLAB_MCP_OAUTH=true requires STREAMABLE_HTTP=true");
1833
+ logger.error("Set STREAMABLE_HTTP=true to enable MCP OAuth");
1834
+ process.exit(1);
1835
+ }
1836
+ logger.info("MCP OAuth enabled: GitLab OAuth proxy active");
1837
+ }
1838
+ if (!REMOTE_AUTHORIZATION && !GITLAB_MCP_OAUTH && !USE_OAUTH && !GITLAB_PERSONAL_ACCESS_TOKEN && !GITLAB_JOB_TOKEN && !GITLAB_AUTH_COOKIE_PATH) {
1614
1839
  // Standard mode: token must be in environment (unless using OAuth)
1615
1840
  logger.error("GITLAB_PERSONAL_ACCESS_TOKEN environment variable is not set");
1616
1841
  logger.info("Either set GITLAB_PERSONAL_ACCESS_TOKEN or enable OAuth with GITLAB_USE_OAUTH=true");
@@ -1772,28 +1997,19 @@ async function getFileContents(projectId, filePath, ref) {
1772
1997
  }
1773
1998
  return parsedData;
1774
1999
  }
1775
- /**
1776
- * Create a new issue in a GitLab project
1777
- * 이슈 생성 (Create an issue)
1778
- *
1779
- * @param {string} projectId - The ID or URL-encoded path of the project
1780
- * @param {z.infer<typeof CreateIssueOptionsSchema>} options - Issue creation options
1781
- * @returns {Promise<GitLabIssue>} The created issue
1782
- */
1783
2000
  async function createIssue(projectId, options) {
1784
2001
  projectId = decodeURIComponent(projectId); // Decode project ID
1785
2002
  const effectiveProjectId = getEffectiveProjectId(projectId);
1786
2003
  const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/issues`);
2004
+ // Build request body, converting labels array to comma-separated string
2005
+ const body = { ...options };
2006
+ if (body.labels && Array.isArray(body.labels)) {
2007
+ body.labels = body.labels.join(",");
2008
+ }
1787
2009
  const response = await fetch(url.toString(), {
1788
2010
  ...getFetchConfig(),
1789
2011
  method: "POST",
1790
- body: JSON.stringify({
1791
- title: options.title,
1792
- description: options.description,
1793
- assignee_ids: options.assignee_ids,
1794
- milestone_id: options.milestone_id,
1795
- labels: options.labels?.join(","),
1796
- }),
2012
+ body: JSON.stringify(body),
1797
2013
  });
1798
2014
  // Handle bad request
1799
2015
  if (response.status === 400) {
@@ -1943,6 +2159,1392 @@ async function deleteIssue(projectId, issueIid) {
1943
2159
  });
1944
2160
  await handleGitLabError(response);
1945
2161
  }
2162
+ // --- GraphQL helper ---
2163
+ /**
2164
+ * Execute a GraphQL query against the GitLab instance.
2165
+ * Reusable helper for work item operations.
2166
+ */
2167
+ async function executeGraphQL(query, variables = {}) {
2168
+ const apiUrl = new URL(getEffectiveApiUrl());
2169
+ const restPath = apiUrl.pathname || "";
2170
+ const idx = restPath.lastIndexOf("/api/v4");
2171
+ const prefix = idx >= 0 ? restPath.slice(0, idx) : "";
2172
+ const graphqlUrl = process.env.GITLAB_GRAPHQL_URL || `${apiUrl.origin}${prefix}/api/graphql`;
2173
+ const response = await fetch(graphqlUrl, {
2174
+ ...getFetchConfig(),
2175
+ method: "POST",
2176
+ headers: {
2177
+ ...BASE_HEADERS,
2178
+ ...buildAuthHeaders(),
2179
+ },
2180
+ body: JSON.stringify({ query, variables }),
2181
+ });
2182
+ if (!response.ok) {
2183
+ const errorBody = await response.text();
2184
+ throw new Error(`GraphQL request failed (${response.status}): ${errorBody}`);
2185
+ }
2186
+ const json = await response.json();
2187
+ if (json.errors && json.errors.length > 0) {
2188
+ throw new Error(`GraphQL errors: ${json.errors.map((e) => e.message).join(", ")}`);
2189
+ }
2190
+ return json.data;
2191
+ }
2192
+ /**
2193
+ * Resolve a project path and issue IID to a work item GraphQL GID.
2194
+ */
2195
+ async function resolveWorkItemGID(projectId, issueIid) {
2196
+ projectId = decodeURIComponent(projectId);
2197
+ const effectiveProjectId = getEffectiveProjectId(projectId);
2198
+ // First get the project path via REST (needed for GraphQL namespace query)
2199
+ const projectUrl = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}`);
2200
+ const projectResponse = await fetch(projectUrl.toString(), {
2201
+ ...getFetchConfig(),
2202
+ });
2203
+ await handleGitLabError(projectResponse);
2204
+ const project = await projectResponse.json();
2205
+ const projectPath = project.path_with_namespace;
2206
+ // Resolve work item GID via GraphQL
2207
+ const data = await executeGraphQL(`query($path: ID!, $iid: String!) {
2208
+ namespace(fullPath: $path) {
2209
+ workItem(iid: $iid) {
2210
+ id
2211
+ }
2212
+ }
2213
+ }`, { path: projectPath, iid: String(issueIid) });
2214
+ if (!data.namespace?.workItem?.id) {
2215
+ throw new Error(`Work item #${issueIid} not found in project ${projectPath}`);
2216
+ }
2217
+ return { workItemGID: data.namespace.workItem.id, projectPath };
2218
+ }
2219
+ /**
2220
+ * Resolve label names and usernames to GitLab GIDs in a single GraphQL call.
2221
+ */
2222
+ async function resolveNamesToIds(projectPath, labelNames, usernames) {
2223
+ if (!labelNames?.length && !usernames?.length) {
2224
+ return { labelIds: [], userIds: [] };
2225
+ }
2226
+ const data = await executeGraphQL(`query($path: ID!, $usernames: [String!]!) {
2227
+ project(fullPath: $path) { labels(includeAncestorGroups: true, first: 250) { nodes { id title } } }
2228
+ users(usernames: $usernames) { nodes { id username } }
2229
+ }`, { path: projectPath, usernames: usernames || [] });
2230
+ const labelIds = (labelNames || []).map(name => {
2231
+ const label = data.project.labels.nodes.find(l => l.title === name);
2232
+ if (!label)
2233
+ throw new Error(`Label '${name}' not found in project`);
2234
+ return label.id;
2235
+ });
2236
+ const userIds = (usernames || []).map(name => {
2237
+ const user = data.users.nodes.find(u => u.username === name);
2238
+ if (!user)
2239
+ throw new Error(`User '${name}' not found`);
2240
+ return user.id;
2241
+ });
2242
+ return { labelIds, userIds };
2243
+ }
2244
+ // --- Work item type conversion ---
2245
+ /**
2246
+ * Map user-facing type names to GitLab WorkItemType names for GraphQL queries.
2247
+ */
2248
+ const WORK_ITEM_TYPE_NAMES = {
2249
+ issue: "Issue",
2250
+ task: "Task",
2251
+ incident: "Incident",
2252
+ test_case: "Test Case",
2253
+ epic: "Epic",
2254
+ key_result: "Key Result",
2255
+ objective: "Objective",
2256
+ requirement: "Requirement",
2257
+ ticket: "Ticket",
2258
+ };
2259
+ /**
2260
+ * Get the GraphQL GID for a work item type by querying the project's available types.
2261
+ */
2262
+ async function resolveWorkItemTypeGID(projectPath, typeName) {
2263
+ const targetName = WORK_ITEM_TYPE_NAMES[typeName];
2264
+ if (!targetName) {
2265
+ throw new Error(`Unknown work item type: ${typeName}`);
2266
+ }
2267
+ const data = await executeGraphQL(`query($path: ID!) {
2268
+ namespace(fullPath: $path) {
2269
+ workItemTypes {
2270
+ nodes {
2271
+ id
2272
+ name
2273
+ }
2274
+ }
2275
+ }
2276
+ }`, { path: projectPath });
2277
+ const typeNode = data.namespace?.workItemTypes?.nodes?.find((n) => n.name === targetName);
2278
+ if (!typeNode) {
2279
+ throw new Error(`Work item type '${targetName}' not found in project ${projectPath}`);
2280
+ }
2281
+ return typeNode.id;
2282
+ }
2283
+ /**
2284
+ * Convert an issue to a different work item type using GraphQL.
2285
+ */
2286
+ async function convertIssueType(projectId, issueIid, newType) {
2287
+ const { workItemGID, projectPath } = await resolveWorkItemGID(projectId, issueIid);
2288
+ const workItemTypeGID = await resolveWorkItemTypeGID(projectPath, newType);
2289
+ const data = await executeGraphQL(`mutation($id: WorkItemID!, $typeId: WorkItemsTypeID!) {
2290
+ workItemConvert(input: { id: $id, workItemTypeId: $typeId }) {
2291
+ workItem {
2292
+ id
2293
+ workItemType { name }
2294
+ }
2295
+ errors
2296
+ }
2297
+ }`, { id: workItemGID, typeId: workItemTypeGID });
2298
+ if (data.workItemConvert.errors?.length > 0) {
2299
+ throw new Error(`Conversion failed: ${data.workItemConvert.errors.join(", ")}`);
2300
+ }
2301
+ return {
2302
+ id: data.workItemConvert.workItem.id,
2303
+ type: data.workItemConvert.workItem.workItemType.name,
2304
+ };
2305
+ }
2306
+ // --- Work item hierarchy ---
2307
+ /**
2308
+ * Set a parent for a work item (issue hierarchy).
2309
+ */
2310
+ async function setIssueParent(projectId, issueIid, parentProjectId, parentIssueIid) {
2311
+ const { workItemGID } = await resolveWorkItemGID(projectId, issueIid);
2312
+ const { workItemGID: parentGID } = await resolveWorkItemGID(parentProjectId, parentIssueIid);
2313
+ const data = await executeGraphQL(`mutation($id: WorkItemID!, $parentId: WorkItemID!) {
2314
+ workItemUpdate(input: { id: $id, hierarchyWidget: { parentId: $parentId } }) {
2315
+ workItem { id }
2316
+ errors
2317
+ }
2318
+ }`, { id: workItemGID, parentId: parentGID });
2319
+ if (data.workItemUpdate.errors?.length > 0) {
2320
+ throw new Error(`Failed to set parent: ${data.workItemUpdate.errors.join(", ")}`);
2321
+ }
2322
+ return { id: workItemGID, parentId: parentGID };
2323
+ }
2324
+ /**
2325
+ * Remove the parent from a work item.
2326
+ */
2327
+ async function removeIssueParent(projectId, issueIid) {
2328
+ const { workItemGID } = await resolveWorkItemGID(projectId, issueIid);
2329
+ const data = await executeGraphQL(`mutation($id: WorkItemID!) {
2330
+ workItemUpdate(input: { id: $id, hierarchyWidget: { parentId: null } }) {
2331
+ workItem { id }
2332
+ errors
2333
+ }
2334
+ }`, { id: workItemGID });
2335
+ if (data.workItemUpdate.errors?.length > 0) {
2336
+ throw new Error(`Failed to remove parent: ${data.workItemUpdate.errors.join(", ")}`);
2337
+ }
2338
+ }
2339
+ /**
2340
+ * List children of a work item (hierarchy widget).
2341
+ */
2342
+ async function listIssueChildren(projectId, issueIid) {
2343
+ projectId = decodeURIComponent(projectId);
2344
+ const effectiveProjectId = getEffectiveProjectId(projectId);
2345
+ // Get project path
2346
+ const projectUrl = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}`);
2347
+ const projectResponse = await fetch(projectUrl.toString(), {
2348
+ ...getFetchConfig(),
2349
+ });
2350
+ await handleGitLabError(projectResponse);
2351
+ const project = await projectResponse.json();
2352
+ const data = await executeGraphQL(`query($path: ID!, $iid: String!) {
2353
+ namespace(fullPath: $path) {
2354
+ workItem(iid: $iid) {
2355
+ id
2356
+ title
2357
+ widgets {
2358
+ __typename
2359
+ ... on WorkItemWidgetHierarchy {
2360
+ parent {
2361
+ id
2362
+ title
2363
+ webUrl
2364
+ workItemType { name }
2365
+ }
2366
+ children {
2367
+ nodes {
2368
+ id
2369
+ title
2370
+ state
2371
+ webUrl
2372
+ workItemType { name }
2373
+ }
2374
+ }
2375
+ }
2376
+ }
2377
+ }
2378
+ }
2379
+ }`, { path: project.path_with_namespace, iid: String(issueIid) });
2380
+ if (!data.namespace?.workItem) {
2381
+ throw new Error(`Work item #${issueIid} not found`);
2382
+ }
2383
+ // Extract hierarchy widget
2384
+ const hierarchyWidget = data.namespace.workItem.widgets?.find((w) => w.__typename === "WorkItemWidgetHierarchy");
2385
+ return {
2386
+ id: data.namespace.workItem.id,
2387
+ title: data.namespace.workItem.title,
2388
+ parent: hierarchyWidget?.parent || null,
2389
+ children: hierarchyWidget?.children?.nodes || [],
2390
+ };
2391
+ }
2392
+ /**
2393
+ * Add a child to a parent work item.
2394
+ */
2395
+ async function addIssueChild(projectId, issueIid, childProjectId, childIssueIid) {
2396
+ const { workItemGID: parentGID } = await resolveWorkItemGID(projectId, issueIid);
2397
+ const { workItemGID: childGID } = await resolveWorkItemGID(childProjectId, childIssueIid);
2398
+ const data = await executeGraphQL(`mutation($id: WorkItemID!, $childId: WorkItemID!) {
2399
+ workItemUpdate(input: { id: $id, hierarchyWidget: { childrenIds: [$childId] } }) {
2400
+ workItem { id }
2401
+ errors
2402
+ }
2403
+ }`, { id: parentGID, childId: childGID });
2404
+ if (data.workItemUpdate.errors?.length > 0) {
2405
+ throw new Error(`Failed to add child: ${data.workItemUpdate.errors.join(", ")}`);
2406
+ }
2407
+ return { parentId: parentGID, childId: childGID };
2408
+ }
2409
+ /**
2410
+ * Remove a child from a parent work item by setting the child's parent to null.
2411
+ */
2412
+ async function removeIssueChild(projectId, issueIid, childProjectId, childIssueIid) {
2413
+ // Removing a child is done by removing the parent from the child
2414
+ await removeIssueParent(childProjectId, childIssueIid);
2415
+ }
2416
+ // --- Work item status ---
2417
+ /**
2418
+ * List available statuses for a work item type in a project.
2419
+ * Requires Premium/Ultimate with configurable statuses enabled.
2420
+ */
2421
+ async function listIssueStatuses(projectId, workItemType = "issue") {
2422
+ projectId = decodeURIComponent(projectId);
2423
+ const effectiveProjectId = getEffectiveProjectId(projectId);
2424
+ // Get project path
2425
+ const projectUrl = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}`);
2426
+ const projectResponse = await fetch(projectUrl.toString(), {
2427
+ ...getFetchConfig(),
2428
+ });
2429
+ await handleGitLabError(projectResponse);
2430
+ const project = await projectResponse.json();
2431
+ const typeName = WORK_ITEM_TYPE_NAMES[workItemType] || "Issue";
2432
+ const data = await executeGraphQL(`query($path: ID!, $typeName: IssueType) {
2433
+ namespace(fullPath: $path) {
2434
+ workItemTypes(name: $typeName) {
2435
+ nodes {
2436
+ id
2437
+ name
2438
+ supportedConversionTypes { id name }
2439
+ widgetDefinitions {
2440
+ __typename
2441
+ ... on WorkItemWidgetDefinitionStatus {
2442
+ allowedStatuses {
2443
+ id
2444
+ name
2445
+ iconName
2446
+ color
2447
+ position
2448
+ }
2449
+ }
2450
+ ... on WorkItemWidgetDefinitionHierarchy {
2451
+ allowedChildTypes { nodes { id name } }
2452
+ allowedParentTypes { nodes { id name } }
2453
+ }
2454
+ }
2455
+ }
2456
+ }
2457
+ }
2458
+ }`, { path: project.path_with_namespace, typeName: typeName.replace(/ /g, "_").toUpperCase() });
2459
+ const typeNodes = data.namespace?.workItemTypes?.nodes;
2460
+ if (!typeNodes || typeNodes.length === 0) {
2461
+ throw new Error(`Work item type '${typeName}' not found in project`);
2462
+ }
2463
+ const typeNode = typeNodes[0];
2464
+ // Extract statuses from the status widget definition
2465
+ const statusWidget = typeNode.widgetDefinitions?.find((w) => w.__typename === "WorkItemWidgetDefinitionStatus");
2466
+ const statuses = statusWidget?.allowedStatuses || [];
2467
+ // Extract hierarchy info
2468
+ const hierarchyWidget = typeNode.widgetDefinitions?.find((w) => w.__typename === "WorkItemWidgetDefinitionHierarchy");
2469
+ const result = {
2470
+ work_item_type: typeNode.name,
2471
+ statuses_available: statuses.length > 0,
2472
+ statuses,
2473
+ };
2474
+ // Add supported conversion types
2475
+ const conversionTypes = typeNode.supportedConversionTypes || [];
2476
+ if (conversionTypes.length > 0) {
2477
+ result.supported_conversion_types = conversionTypes.map((t) => t.name);
2478
+ }
2479
+ // Add allowed child/parent types
2480
+ const childTypes = hierarchyWidget?.allowedChildTypes?.nodes || [];
2481
+ const parentTypes = hierarchyWidget?.allowedParentTypes?.nodes || [];
2482
+ if (childTypes.length > 0) {
2483
+ result.allowed_child_types = childTypes.map((t) => t.name);
2484
+ }
2485
+ if (parentTypes.length > 0) {
2486
+ result.allowed_parent_types = parentTypes.map((t) => t.name);
2487
+ }
2488
+ return result;
2489
+ }
2490
+ /**
2491
+ * List available custom field definitions for a work item type.
2492
+ */
2493
+ async function listCustomFieldDefinitions(projectId, workItemType = "issue") {
2494
+ const projectPath = await resolveProjectPath(projectId);
2495
+ const typeName = WORK_ITEM_TYPE_NAMES[workItemType] || "Issue";
2496
+ const data = await executeGraphQL(`query($path: ID!, $typeName: IssueType) {
2497
+ namespace(fullPath: $path) {
2498
+ workItemTypes(name: $typeName) {
2499
+ nodes {
2500
+ id
2501
+ name
2502
+ widgetDefinitions {
2503
+ __typename
2504
+ ... on WorkItemWidgetDefinitionCustomFields {
2505
+ customFieldValues {
2506
+ customField {
2507
+ id
2508
+ name
2509
+ fieldType
2510
+ selectOptions { id value }
2511
+ workItemTypes { id name }
2512
+ }
2513
+ }
2514
+ }
2515
+ }
2516
+ }
2517
+ }
2518
+ }
2519
+ }`, { path: projectPath, typeName: typeName.replace(/ /g, "_").toUpperCase() });
2520
+ const typeNodes = data.namespace?.workItemTypes?.nodes;
2521
+ if (!typeNodes || typeNodes.length === 0) {
2522
+ throw new Error(`Work item type '${typeName}' not found in project`);
2523
+ }
2524
+ const typeNode = typeNodes[0];
2525
+ const customFieldsWidget = typeNode.widgetDefinitions?.find((w) => w.__typename === "WorkItemWidgetDefinitionCustomFields");
2526
+ const fields = (customFieldsWidget?.customFieldValues || []).map((cfv) => {
2527
+ const cf = cfv.customField;
2528
+ const field = {
2529
+ id: cf?.id,
2530
+ name: cf?.name,
2531
+ type: cf?.fieldType,
2532
+ };
2533
+ const options = cf?.selectOptions || [];
2534
+ if (options.length > 0)
2535
+ field.selectOptions = options;
2536
+ const types = (cf?.workItemTypes || []).map((t) => t.name);
2537
+ if (types.length > 0)
2538
+ field.workItemTypes = types;
2539
+ return field;
2540
+ });
2541
+ return {
2542
+ work_item_type: typeNode.name,
2543
+ custom_fields: fields,
2544
+ };
2545
+ }
2546
+ /**
2547
+ * Move a work item to a different project.
2548
+ */
2549
+ async function moveWorkItem(projectId, iid, targetProjectId) {
2550
+ const projectPath = await resolveProjectPath(projectId);
2551
+ const targetPath = await resolveProjectPath(targetProjectId);
2552
+ const data = await executeGraphQL(`mutation($projectPath: ID!, $iid: String!, $targetProjectPath: ID!) {
2553
+ issueMove(input: { projectPath: $projectPath, iid: $iid, targetProjectPath: $targetProjectPath }) {
2554
+ issue { id iid webUrl }
2555
+ errors
2556
+ }
2557
+ }`, { projectPath: projectPath, iid: String(iid), targetProjectPath: targetPath });
2558
+ if (data.issueMove.errors?.length > 0) {
2559
+ throw new Error(`Failed to move work item: ${data.issueMove.errors.join(", ")}`);
2560
+ }
2561
+ return data.issueMove.issue;
2562
+ }
2563
+ /**
2564
+ * List notes/discussions on a work item.
2565
+ */
2566
+ async function listWorkItemNotes(projectId, iid, options = {}) {
2567
+ const projectPath = await resolveProjectPath(projectId);
2568
+ const data = await executeGraphQL(`query($path: ID!, $iid: String!, $pageSize: Int, $after: String, $sort: WorkItemDiscussionsSort) {
2569
+ namespace(fullPath: $path) {
2570
+ workItem(iid: $iid) {
2571
+ id
2572
+ widgets(onlyTypes: [NOTES]) {
2573
+ ... on WorkItemWidgetNotes {
2574
+ discussionLocked
2575
+ discussions(first: $pageSize, after: $after, filter: ALL_NOTES, sort: $sort) {
2576
+ pageInfo { hasNextPage endCursor }
2577
+ nodes {
2578
+ id
2579
+ resolved
2580
+ resolvable
2581
+ notes {
2582
+ nodes {
2583
+ id
2584
+ body
2585
+ system
2586
+ internal
2587
+ createdAt
2588
+ lastEditedAt
2589
+ author { username }
2590
+ }
2591
+ }
2592
+ }
2593
+ }
2594
+ }
2595
+ }
2596
+ }
2597
+ }
2598
+ }`, {
2599
+ path: projectPath,
2600
+ iid: String(iid),
2601
+ pageSize: options.page_size || 20,
2602
+ after: options.after || null,
2603
+ sort: options.sort || "CREATED_ASC",
2604
+ });
2605
+ const workItem = data.namespace?.workItem;
2606
+ if (!workItem) {
2607
+ throw new Error(`Work item #${iid} not found in project ${projectPath}`);
2608
+ }
2609
+ const notesWidget = workItem.widgets?.find((w) => w.discussions);
2610
+ const discussions = notesWidget?.discussions;
2611
+ // Flatten to lean output
2612
+ const items = (discussions?.nodes || []).map((d) => {
2613
+ const notes = (d.notes?.nodes || []).map((n) => {
2614
+ const note = {
2615
+ id: n.id,
2616
+ author: n.author?.username,
2617
+ body: n.body,
2618
+ createdAt: n.createdAt,
2619
+ };
2620
+ if (n.system)
2621
+ note.system = true;
2622
+ if (n.internal)
2623
+ note.internal = true;
2624
+ if (n.lastEditedAt)
2625
+ note.lastEditedAt = n.lastEditedAt;
2626
+ return note;
2627
+ });
2628
+ const discussion = { id: d.id, notes };
2629
+ if (d.resolved)
2630
+ discussion.resolved = true;
2631
+ if (d.resolvable)
2632
+ discussion.resolvable = true;
2633
+ return discussion;
2634
+ });
2635
+ return {
2636
+ discussions: items,
2637
+ pageInfo: discussions?.pageInfo || {},
2638
+ };
2639
+ }
2640
+ /**
2641
+ * Create a note on a work item.
2642
+ */
2643
+ async function createWorkItemNote(projectId, iid, body, options = {}) {
2644
+ const { workItemGID } = await resolveWorkItemGID(projectId, iid);
2645
+ const varDefs = ["$noteableId: NoteableID!", "$body: String!"];
2646
+ const inputParts = ["noteableId: $noteableId", "body: $body"];
2647
+ const variables = { noteableId: workItemGID, body };
2648
+ if (options.internal) {
2649
+ varDefs.push("$internal: Boolean");
2650
+ inputParts.push("internal: $internal");
2651
+ variables.internal = true;
2652
+ }
2653
+ if (options.discussion_id) {
2654
+ varDefs.push("$discussionId: DiscussionID");
2655
+ inputParts.push("discussionId: $discussionId");
2656
+ variables.discussionId = options.discussion_id;
2657
+ }
2658
+ const data = await executeGraphQL(`mutation(${varDefs.join(", ")}) {
2659
+ createNote(input: { ${inputParts.join(", ")} }) {
2660
+ note {
2661
+ id
2662
+ body
2663
+ discussion { id }
2664
+ }
2665
+ errors
2666
+ }
2667
+ }`, variables);
2668
+ if (data.createNote.errors?.length > 0) {
2669
+ throw new Error(`Failed to create note: ${data.createNote.errors.join(", ")}`);
2670
+ }
2671
+ return data.createNote.note;
2672
+ }
2673
+ // --- Incident Timeline Events ---
2674
+ /**
2675
+ * List timeline events for an incident.
2676
+ */
2677
+ async function getTimelineEvents(projectId, incidentIid) {
2678
+ const { workItemGID, projectPath } = await resolveWorkItemGID(projectId, incidentIid);
2679
+ // Timeline events expect gid://gitlab/Issue/... not gid://gitlab/WorkItem/...
2680
+ const incidentGID = workItemGID.replace("/WorkItem/", "/Issue/");
2681
+ const data = await executeGraphQL(`query($fullPath: ID!, $incidentId: IssueID!) {
2682
+ project(fullPath: $fullPath) {
2683
+ incidentManagementTimelineEvents(incidentId: $incidentId) {
2684
+ nodes {
2685
+ id
2686
+ note
2687
+ noteHtml
2688
+ action
2689
+ occurredAt
2690
+ createdAt
2691
+ timelineEventTags {
2692
+ nodes {
2693
+ id
2694
+ name
2695
+ }
2696
+ }
2697
+ }
2698
+ }
2699
+ }
2700
+ }`, { fullPath: projectPath, incidentId: incidentGID });
2701
+ const events = data.project?.incidentManagementTimelineEvents?.nodes || [];
2702
+ return events.map((e) => {
2703
+ const event = {
2704
+ id: e.id,
2705
+ note: e.note,
2706
+ action: e.action,
2707
+ occurredAt: e.occurredAt,
2708
+ createdAt: e.createdAt,
2709
+ };
2710
+ if (e.noteHtml)
2711
+ event.noteHtml = e.noteHtml;
2712
+ const tags = (e.timelineEventTags?.nodes || []).map((t) => t.name);
2713
+ if (tags.length > 0)
2714
+ event.tags = tags;
2715
+ return event;
2716
+ });
2717
+ }
2718
+ /**
2719
+ * Create a timeline event on an incident.
2720
+ */
2721
+ async function createTimelineEvent(projectId, incidentIid, note, occurredAt, tagNames) {
2722
+ const { workItemGID } = await resolveWorkItemGID(projectId, incidentIid);
2723
+ // Timeline events expect gid://gitlab/Issue/... not gid://gitlab/WorkItem/...
2724
+ const incidentGID = workItemGID.replace("/WorkItem/", "/Issue/");
2725
+ const variables = {
2726
+ input: {
2727
+ incidentId: incidentGID,
2728
+ note,
2729
+ occurredAt,
2730
+ },
2731
+ };
2732
+ if (tagNames && tagNames.length > 0) {
2733
+ variables.input.timelineEventTagNames = tagNames;
2734
+ }
2735
+ const data = await executeGraphQL(`mutation CreateTimelineEvent($input: TimelineEventCreateInput!) {
2736
+ timelineEventCreate(input: $input) {
2737
+ timelineEvent {
2738
+ id
2739
+ note
2740
+ noteHtml
2741
+ action
2742
+ occurredAt
2743
+ createdAt
2744
+ timelineEventTags {
2745
+ nodes {
2746
+ id
2747
+ name
2748
+ }
2749
+ }
2750
+ }
2751
+ errors
2752
+ }
2753
+ }`, variables);
2754
+ if (data.timelineEventCreate.errors?.length > 0) {
2755
+ throw new Error(`Failed to create timeline event: ${data.timelineEventCreate.errors.join(", ")}`);
2756
+ }
2757
+ const e = data.timelineEventCreate.timelineEvent;
2758
+ const result = {
2759
+ id: e.id,
2760
+ note: e.note,
2761
+ action: e.action,
2762
+ occurredAt: e.occurredAt,
2763
+ createdAt: e.createdAt,
2764
+ };
2765
+ if (e.noteHtml)
2766
+ result.noteHtml = e.noteHtml;
2767
+ const tags = (e.timelineEventTags?.nodes || []).map((t) => t.name);
2768
+ if (tags.length > 0)
2769
+ result.tags = tags;
2770
+ return result;
2771
+ }
2772
+ /**
2773
+ * Update the severity of an incident.
2774
+ * Accepts projectPath directly to avoid redundant REST calls when called from updateWorkItem.
2775
+ */
2776
+ async function updateIncidentSeverity(projectPath, incidentIid, severity) {
2777
+ const data = await executeGraphQL(`mutation($projectPath: ID!, $severity: IssuableSeverity!, $iid: String!) {
2778
+ issueSetSeverity(input: { iid: $iid, severity: $severity, projectPath: $projectPath }) {
2779
+ errors
2780
+ issue {
2781
+ iid
2782
+ id
2783
+ severity
2784
+ }
2785
+ }
2786
+ }`, { projectPath, severity, iid: String(incidentIid) });
2787
+ if (data.issueSetSeverity.errors?.length > 0) {
2788
+ throw new Error(`Failed to set severity: ${data.issueSetSeverity.errors.join(", ")}`);
2789
+ }
2790
+ return data.issueSetSeverity.issue;
2791
+ }
2792
+ /**
2793
+ * Update the escalation status of an incident.
2794
+ * Accepts projectPath directly to avoid redundant REST calls when called from updateWorkItem.
2795
+ */
2796
+ async function updateIncidentEscalationStatus(projectPath, incidentIid, status) {
2797
+ const data = await executeGraphQL(`mutation($projectPath: ID!, $status: IssueEscalationStatus!, $iid: String!) {
2798
+ issueSetEscalationStatus(input: { projectPath: $projectPath, status: $status, iid: $iid }) {
2799
+ errors
2800
+ issue {
2801
+ id
2802
+ escalationStatus
2803
+ }
2804
+ }
2805
+ }`, { projectPath, status, iid: String(incidentIid) });
2806
+ if (data.issueSetEscalationStatus.errors?.length > 0) {
2807
+ throw new Error(`Failed to set escalation status: ${data.issueSetEscalationStatus.errors.join(", ")}`);
2808
+ }
2809
+ return data.issueSetEscalationStatus.issue;
2810
+ }
2811
+ /**
2812
+ * Set the status of a work item.
2813
+ */
2814
+ async function setIssueStatus(projectId, issueIid, status) {
2815
+ const { workItemGID } = await resolveWorkItemGID(projectId, issueIid);
2816
+ const data = await executeGraphQL(`mutation($id: WorkItemID!, $status: WorkItemsStatusesStatusID!) {
2817
+ workItemUpdate(input: { id: $id, statusWidget: { status: $status } }) {
2818
+ workItem {
2819
+ id
2820
+ widgets {
2821
+ __typename
2822
+ ... on WorkItemWidgetStatus {
2823
+ status { id name category color }
2824
+ }
2825
+ }
2826
+ }
2827
+ errors
2828
+ }
2829
+ }`, { id: workItemGID, status });
2830
+ if (data.workItemUpdate.errors?.length > 0) {
2831
+ throw new Error(`Failed to set status: ${data.workItemUpdate.errors.join(", ")}`);
2832
+ }
2833
+ // Extract the current status from the response
2834
+ const statusWidget = data.workItemUpdate.workItem?.widgets?.find((w) => w.__typename === "WorkItemWidgetStatus");
2835
+ return {
2836
+ id: data.workItemUpdate.workItem.id,
2837
+ status: statusWidget?.status || null,
2838
+ };
2839
+ }
2840
+ /**
2841
+ * Resolve a project ID (numeric or path) to its full path_with_namespace.
2842
+ */
2843
+ async function resolveProjectPath(projectId) {
2844
+ projectId = decodeURIComponent(projectId);
2845
+ const effectiveProjectId = getEffectiveProjectId(projectId);
2846
+ const projectUrl = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}`);
2847
+ const projectResponse = await fetch(projectUrl.toString(), {
2848
+ ...getFetchConfig(),
2849
+ });
2850
+ await handleGitLabError(projectResponse);
2851
+ const project = await projectResponse.json();
2852
+ return project.path_with_namespace;
2853
+ }
2854
+ /**
2855
+ * Get a single work item with all widget data.
2856
+ */
2857
+ async function getWorkItem(projectId, iid) {
2858
+ const projectPath = await resolveProjectPath(projectId);
2859
+ const data = await executeGraphQL(`query($path: ID!, $iid: String!) {
2860
+ namespace(fullPath: $path) {
2861
+ workItem(iid: $iid) {
2862
+ id
2863
+ iid
2864
+ title
2865
+ state
2866
+ description
2867
+ webUrl
2868
+ confidential
2869
+ author { username }
2870
+ createdAt
2871
+ closedAt
2872
+ workItemType { name }
2873
+ widgets {
2874
+ __typename
2875
+ ... on WorkItemWidgetHierarchy {
2876
+ hasChildren hasParent
2877
+ parent { id iid title webUrl workItemType { name } namespace { fullPath } }
2878
+ children { nodes { id iid title state webUrl workItemType { name } namespace { fullPath } } }
2879
+ }
2880
+ ... on WorkItemWidgetStatus { status { id name category color iconName position } }
2881
+ ... on WorkItemWidgetCustomFields {
2882
+ customFieldValues {
2883
+ __typename
2884
+ customField { id name fieldType }
2885
+ ... on WorkItemNumberFieldValue { value }
2886
+ ... on WorkItemTextFieldValue { value }
2887
+ ... on WorkItemSelectFieldValue {
2888
+ selectedOptions { id value }
2889
+ }
2890
+ }
2891
+ }
2892
+ ... on WorkItemWidgetLabels { labels { nodes { id title color } } }
2893
+ ... on WorkItemWidgetAssignees { assignees { nodes { id username name } } }
2894
+ ... on WorkItemWidgetWeight { weight rolledUpWeight rolledUpCompletedWeight }
2895
+ ... on WorkItemWidgetHealthStatus { healthStatus }
2896
+ ... on WorkItemWidgetStartAndDueDate { startDate dueDate }
2897
+ ... on WorkItemWidgetMilestone { milestone { id title } }
2898
+ ... on WorkItemWidgetLinkedItems {
2899
+ blocked blockedByCount blockingCount
2900
+ linkedItems { nodes { linkType workItem { id iid title state webUrl workItemType { name } namespace { fullPath } } } }
2901
+ }
2902
+ ... on WorkItemWidgetTimeTracking {
2903
+ timeEstimate totalTimeSpent
2904
+ }
2905
+ ... on WorkItemWidgetDevelopment {
2906
+ willAutoCloseByMergeRequest
2907
+ relatedBranches { nodes { name } }
2908
+ relatedMergeRequests {
2909
+ nodes { iid title webUrl state sourceBranch }
2910
+ }
2911
+ closingMergeRequests {
2912
+ nodes {
2913
+ mergeRequest { iid title webUrl state sourceBranch }
2914
+ }
2915
+ }
2916
+ featureFlags { nodes { name active } }
2917
+ }
2918
+ ... on WorkItemWidgetIteration {
2919
+ iteration { id title startDate dueDate webUrl iterationCadence { id title } }
2920
+ }
2921
+ ... on WorkItemWidgetProgress { progress }
2922
+ ... on WorkItemWidgetColor { color textColor }
2923
+ }
2924
+ }
2925
+ }
2926
+ }`, { path: projectPath, iid: String(iid) });
2927
+ if (!data.namespace?.workItem) {
2928
+ throw new Error(`Work item #${iid} not found in project ${projectPath}`);
2929
+ }
2930
+ const wi = data.namespace.workItem;
2931
+ const widgets = wi.widgets || [];
2932
+ // Flatten widget data into a clean response
2933
+ const hierarchyWidget = widgets.find((w) => w.__typename === "WorkItemWidgetHierarchy");
2934
+ const statusWidget = widgets.find((w) => w.__typename === "WorkItemWidgetStatus");
2935
+ const labelsWidget = widgets.find((w) => w.__typename === "WorkItemWidgetLabels");
2936
+ const assigneesWidget = widgets.find((w) => w.__typename === "WorkItemWidgetAssignees");
2937
+ const weightWidget = widgets.find((w) => w.__typename === "WorkItemWidgetWeight");
2938
+ const healthStatusWidget = widgets.find((w) => w.__typename === "WorkItemWidgetHealthStatus");
2939
+ const datesWidget = widgets.find((w) => w.__typename === "WorkItemWidgetStartAndDueDate");
2940
+ const milestoneWidget = widgets.find((w) => w.__typename === "WorkItemWidgetMilestone");
2941
+ const linkedItemsWidget = widgets.find((w) => w.__typename === "WorkItemWidgetLinkedItems");
2942
+ const timeTrackingWidget = widgets.find((w) => w.__typename === "WorkItemWidgetTimeTracking");
2943
+ const developmentWidget = widgets.find((w) => w.__typename === "WorkItemWidgetDevelopment");
2944
+ const customFieldsWidget = widgets.find((w) => w.__typename === "WorkItemWidgetCustomFields");
2945
+ // Build response, omitting null/empty values to keep output lean
2946
+ const result = {
2947
+ id: wi.id,
2948
+ iid: wi.iid,
2949
+ title: wi.title,
2950
+ state: wi.state,
2951
+ type: wi.workItemType?.name,
2952
+ webUrl: wi.webUrl,
2953
+ };
2954
+ if (wi.description)
2955
+ result.description = wi.description;
2956
+ if (wi.confidential)
2957
+ result.confidential = true;
2958
+ if (wi.author?.username)
2959
+ result.author = wi.author.username;
2960
+ if (wi.createdAt)
2961
+ result.createdAt = wi.createdAt;
2962
+ if (wi.closedAt)
2963
+ result.closedAt = wi.closedAt;
2964
+ if (statusWidget?.status)
2965
+ result.status = { name: statusWidget.status.name, id: statusWidget.status.id, category: statusWidget.status.category };
2966
+ const labels = (labelsWidget?.labels?.nodes || []).map((l) => l.title);
2967
+ if (labels.length > 0)
2968
+ result.labels = labels;
2969
+ const assignees = (assigneesWidget?.assignees?.nodes || []).map((a) => a.username);
2970
+ if (assignees.length > 0)
2971
+ result.assignees = assignees;
2972
+ if (weightWidget?.weight != null) {
2973
+ result.weight = weightWidget.weight;
2974
+ if (weightWidget.rolledUpWeight != null)
2975
+ result.rolledUpWeight = weightWidget.rolledUpWeight;
2976
+ if (weightWidget.rolledUpCompletedWeight != null)
2977
+ result.rolledUpCompletedWeight = weightWidget.rolledUpCompletedWeight;
2978
+ }
2979
+ if (healthStatusWidget?.healthStatus)
2980
+ result.healthStatus = healthStatusWidget.healthStatus;
2981
+ if (datesWidget?.startDate)
2982
+ result.startDate = datesWidget.startDate;
2983
+ if (datesWidget?.dueDate)
2984
+ result.dueDate = datesWidget.dueDate;
2985
+ if (milestoneWidget?.milestone)
2986
+ result.milestone = { id: milestoneWidget.milestone.id, title: milestoneWidget.milestone.title };
2987
+ const iterationWidget = widgets.find((w) => w.__typename === "WorkItemWidgetIteration");
2988
+ if (iterationWidget?.iteration) {
2989
+ result.iteration = {
2990
+ id: iterationWidget.iteration.id,
2991
+ title: iterationWidget.iteration.title,
2992
+ startDate: iterationWidget.iteration.startDate,
2993
+ dueDate: iterationWidget.iteration.dueDate,
2994
+ };
2995
+ }
2996
+ const progressWidget = widgets.find((w) => w.__typename === "WorkItemWidgetProgress");
2997
+ if (progressWidget?.progress != null)
2998
+ result.progress = progressWidget.progress;
2999
+ const colorWidget = widgets.find((w) => w.__typename === "WorkItemWidgetColor");
3000
+ if (colorWidget?.color)
3001
+ result.color = colorWidget.color;
3002
+ if (hierarchyWidget?.parent)
3003
+ result.parent = { iid: hierarchyWidget.parent.iid, title: hierarchyWidget.parent.title, type: hierarchyWidget.parent.workItemType?.name, project: hierarchyWidget.parent.namespace?.fullPath, webUrl: hierarchyWidget.parent.webUrl };
3004
+ const children = hierarchyWidget?.children?.nodes || [];
3005
+ if (children.length > 0)
3006
+ result.children = children.map((c) => ({ iid: c.iid, title: c.title, state: c.state, type: c.workItemType?.name, project: c.namespace?.fullPath, webUrl: c.webUrl }));
3007
+ if (linkedItemsWidget?.blocked)
3008
+ result.blocked = true;
3009
+ if (linkedItemsWidget?.blockedByCount > 0)
3010
+ result.blockedByCount = linkedItemsWidget.blockedByCount;
3011
+ if (linkedItemsWidget?.blockingCount > 0)
3012
+ result.blockingCount = linkedItemsWidget.blockingCount;
3013
+ const linkedNodes = linkedItemsWidget?.linkedItems?.nodes || [];
3014
+ if (linkedNodes.length > 0) {
3015
+ result.linkedItems = linkedNodes.map((n) => ({
3016
+ linkType: n.linkType,
3017
+ iid: n.workItem?.iid,
3018
+ title: n.workItem?.title,
3019
+ state: n.workItem?.state,
3020
+ type: n.workItem?.workItemType?.name,
3021
+ project: n.workItem?.namespace?.fullPath,
3022
+ webUrl: n.workItem?.webUrl,
3023
+ }));
3024
+ }
3025
+ if (timeTrackingWidget?.timeEstimate > 0)
3026
+ result.timeEstimate = timeTrackingWidget.timeEstimate;
3027
+ if (timeTrackingWidget?.totalTimeSpent > 0)
3028
+ result.totalTimeSpent = timeTrackingWidget.totalTimeSpent;
3029
+ // Development: only include if there's actual data
3030
+ const relatedMRs = developmentWidget?.relatedMergeRequests?.nodes || [];
3031
+ const closingMRs = (developmentWidget?.closingMergeRequests?.nodes || []).map((n) => n.mergeRequest);
3032
+ const branches = developmentWidget?.relatedBranches?.nodes || [];
3033
+ const flags = developmentWidget?.featureFlags?.nodes || [];
3034
+ if (relatedMRs.length > 0 || closingMRs.length > 0 || branches.length > 0 || flags.length > 0) {
3035
+ const dev = {};
3036
+ if (relatedMRs.length > 0)
3037
+ dev.relatedMergeRequests = relatedMRs;
3038
+ if (closingMRs.length > 0)
3039
+ dev.closingMergeRequests = closingMRs;
3040
+ if (branches.length > 0)
3041
+ dev.relatedBranches = branches.map((b) => b.name);
3042
+ if (flags.length > 0)
3043
+ dev.featureFlags = flags;
3044
+ result.development = dev;
3045
+ }
3046
+ const cfValues = (customFieldsWidget?.customFieldValues || []).filter((cfv) => cfv.value != null || cfv.selectedOptions != null);
3047
+ if (cfValues.length > 0) {
3048
+ result.customFields = cfValues.map((cfv) => ({
3049
+ name: cfv.customField?.name,
3050
+ type: cfv.customField?.fieldType,
3051
+ value: cfv.value ?? cfv.selectedOptions ?? null,
3052
+ }));
3053
+ }
3054
+ return result;
3055
+ }
3056
+ /**
3057
+ * List work items in a project with filters.
3058
+ */
3059
+ async function listWorkItems(projectId, options) {
3060
+ const projectPath = await resolveProjectPath(projectId);
3061
+ // Map type names to GraphQL enum values
3062
+ const typeMap = {
3063
+ issue: "ISSUE",
3064
+ task: "TASK",
3065
+ incident: "INCIDENT",
3066
+ test_case: "TEST_CASE",
3067
+ epic: "EPIC",
3068
+ key_result: "KEY_RESULT",
3069
+ objective: "OBJECTIVE",
3070
+ requirement: "REQUIREMENT",
3071
+ ticket: "TICKET",
3072
+ };
3073
+ const variables = {
3074
+ path: projectPath,
3075
+ first: options.first || 20,
3076
+ };
3077
+ if (options.types && options.types.length > 0) {
3078
+ variables.types = options.types.map((t) => typeMap[t] || t.replace(/ /g, "_").toUpperCase());
3079
+ }
3080
+ if (options.state) {
3081
+ variables.state = options.state === "opened" ? "opened" : "closed";
3082
+ }
3083
+ if (options.search) {
3084
+ variables.search = options.search;
3085
+ }
3086
+ if (options.assignee_usernames && options.assignee_usernames.length > 0) {
3087
+ variables.assigneeUsernames = options.assignee_usernames;
3088
+ }
3089
+ if (options.label_names && options.label_names.length > 0) {
3090
+ variables.labelName = options.label_names;
3091
+ }
3092
+ if (options.after) {
3093
+ variables.after = options.after;
3094
+ }
3095
+ const data = await executeGraphQL(`query($path: ID!, $types: [IssueType!], $state: IssuableState, $search: String, $assigneeUsernames: [String!], $labelName: [String!], $first: Int, $after: String) {
3096
+ project(fullPath: $path) {
3097
+ workItems(types: $types, state: $state, search: $search, assigneeUsernames: $assigneeUsernames, labelName: $labelName, first: $first, after: $after) {
3098
+ nodes {
3099
+ id iid title state webUrl workItemType { name }
3100
+ widgets {
3101
+ __typename
3102
+ ... on WorkItemWidgetStatus { status { id name category color } }
3103
+ ... on WorkItemWidgetLabels { labels { nodes { title } } }
3104
+ ... on WorkItemWidgetAssignees { assignees { nodes { username } } }
3105
+ ... on WorkItemWidgetWeight { weight }
3106
+ ... on WorkItemWidgetHealthStatus { healthStatus }
3107
+ ... on WorkItemWidgetStartAndDueDate { startDate dueDate }
3108
+ ... on WorkItemWidgetMilestone { milestone { id title } }
3109
+ }
3110
+ }
3111
+ pageInfo { hasNextPage endCursor }
3112
+ }
3113
+ }
3114
+ }`, variables);
3115
+ const workItems = data.project?.workItems?.nodes || [];
3116
+ const pageInfo = data.project?.workItems?.pageInfo || {};
3117
+ // Flatten widget data for each item
3118
+ const items = workItems.map((wi) => {
3119
+ const widgets = wi.widgets || [];
3120
+ const statusWidget = widgets.find((w) => w.__typename === "WorkItemWidgetStatus");
3121
+ const labelsWidget = widgets.find((w) => w.__typename === "WorkItemWidgetLabels");
3122
+ const assigneesWidget = widgets.find((w) => w.__typename === "WorkItemWidgetAssignees");
3123
+ const weightWidget = widgets.find((w) => w.__typename === "WorkItemWidgetWeight");
3124
+ const healthStatusWidget = widgets.find((w) => w.__typename === "WorkItemWidgetHealthStatus");
3125
+ const datesWidget = widgets.find((w) => w.__typename === "WorkItemWidgetStartAndDueDate");
3126
+ const milestoneWidget = widgets.find((w) => w.__typename === "WorkItemWidgetMilestone");
3127
+ const item = {
3128
+ iid: wi.iid,
3129
+ title: wi.title,
3130
+ state: wi.state,
3131
+ type: wi.workItemType?.name,
3132
+ webUrl: wi.webUrl,
3133
+ };
3134
+ if (statusWidget?.status)
3135
+ item.status = statusWidget.status.name;
3136
+ const labels = (labelsWidget?.labels?.nodes || []).map((l) => l.title);
3137
+ if (labels.length > 0)
3138
+ item.labels = labels;
3139
+ const assignees = (assigneesWidget?.assignees?.nodes || []).map((a) => a.username);
3140
+ if (assignees.length > 0)
3141
+ item.assignees = assignees;
3142
+ if (weightWidget?.weight != null)
3143
+ item.weight = weightWidget.weight;
3144
+ if (healthStatusWidget?.healthStatus)
3145
+ item.healthStatus = healthStatusWidget.healthStatus;
3146
+ if (datesWidget?.startDate)
3147
+ item.startDate = datesWidget.startDate;
3148
+ if (datesWidget?.dueDate)
3149
+ item.dueDate = datesWidget.dueDate;
3150
+ if (milestoneWidget?.milestone)
3151
+ item.milestone = milestoneWidget.milestone.title;
3152
+ return item;
3153
+ });
3154
+ return { items, pageInfo };
3155
+ }
3156
+ /**
3157
+ * Create a new work item using GraphQL.
3158
+ */
3159
+ async function createWorkItem(projectId, options) {
3160
+ const projectPath = await resolveProjectPath(projectId);
3161
+ const typeName = options.type || "issue";
3162
+ const typeGID = await resolveWorkItemTypeGID(projectPath, typeName);
3163
+ // Build the input dynamically - only include widgets that have values
3164
+ const inputFields = [
3165
+ "$projectPath: ID!",
3166
+ "$title: String!",
3167
+ "$typeId: WorkItemsTypeID!",
3168
+ ];
3169
+ const inputValues = [
3170
+ "namespacePath: $projectPath",
3171
+ "title: $title",
3172
+ "workItemTypeId: $typeId",
3173
+ ];
3174
+ const variables = {
3175
+ projectPath,
3176
+ title: options.title,
3177
+ typeId: typeGID,
3178
+ };
3179
+ if (options.description !== undefined) {
3180
+ inputFields.push("$description: String!");
3181
+ inputValues.push("descriptionWidget: { description: $description }");
3182
+ variables.description = options.description;
3183
+ }
3184
+ // Resolve label names and usernames to GIDs in a single GraphQL call
3185
+ const { labelIds, userIds } = await resolveNamesToIds(projectPath, options.labels, options.assignee_usernames);
3186
+ if (labelIds.length > 0) {
3187
+ inputFields.push("$labelIds: [LabelID!]!");
3188
+ inputValues.push("labelsWidget: { labelIds: $labelIds }");
3189
+ variables.labelIds = labelIds;
3190
+ }
3191
+ if (options.weight !== undefined) {
3192
+ inputFields.push("$weight: Int");
3193
+ inputValues.push("weightWidget: { weight: $weight }");
3194
+ variables.weight = options.weight;
3195
+ }
3196
+ // Resolve parent GID if provided
3197
+ if (options.parent_iid !== undefined) {
3198
+ const { workItemGID: parentGID } = await resolveWorkItemGID(projectId, options.parent_iid);
3199
+ inputFields.push("$parentId: WorkItemID");
3200
+ inputValues.push("hierarchyWidget: { parentId: $parentId }");
3201
+ variables.parentId = parentGID;
3202
+ }
3203
+ if (userIds.length > 0) {
3204
+ inputFields.push("$assigneeIds: [UserID!]!");
3205
+ inputValues.push("assigneesWidget: { assigneeIds: $assigneeIds }");
3206
+ variables.assigneeIds = userIds;
3207
+ }
3208
+ if (options.health_status !== undefined) {
3209
+ inputFields.push("$healthStatus: HealthStatus");
3210
+ inputValues.push("healthStatusWidget: { healthStatus: $healthStatus }");
3211
+ variables.healthStatus = options.health_status;
3212
+ }
3213
+ // Start and due date widget - combine into one widget
3214
+ if (options.start_date !== undefined || options.due_date !== undefined) {
3215
+ const dateParts = [];
3216
+ if (options.start_date !== undefined) {
3217
+ inputFields.push("$startDate: Date");
3218
+ dateParts.push("startDate: $startDate");
3219
+ variables.startDate = options.start_date;
3220
+ }
3221
+ if (options.due_date !== undefined) {
3222
+ inputFields.push("$dueDate: Date");
3223
+ dateParts.push("dueDate: $dueDate");
3224
+ variables.dueDate = options.due_date;
3225
+ }
3226
+ inputValues.push(`startAndDueDateWidget: { ${dateParts.join(", ")} }`);
3227
+ }
3228
+ if (options.milestone_id !== undefined) {
3229
+ // Convert numeric ID to GID format if needed
3230
+ const milestoneGID = options.milestone_id.startsWith("gid://")
3231
+ ? options.milestone_id
3232
+ : `gid://gitlab/Milestone/${options.milestone_id}`;
3233
+ inputFields.push("$milestoneId: MilestoneID");
3234
+ inputValues.push("milestoneWidget: { milestoneId: $milestoneId }");
3235
+ variables.milestoneId = milestoneGID;
3236
+ }
3237
+ if (options.iteration_id !== undefined) {
3238
+ const iterationGID = options.iteration_id.startsWith("gid://")
3239
+ ? options.iteration_id
3240
+ : `gid://gitlab/Iteration/${options.iteration_id}`;
3241
+ inputFields.push("$iterationId: IterationID");
3242
+ inputValues.push("iterationWidget: { iterationId: $iterationId }");
3243
+ variables.iterationId = iterationGID;
3244
+ }
3245
+ if (options.confidential !== undefined) {
3246
+ inputFields.push("$confidential: Boolean");
3247
+ inputValues.push("confidential: $confidential");
3248
+ variables.confidential = options.confidential;
3249
+ }
3250
+ const mutation = `mutation(${inputFields.join(", ")}) {
3251
+ workItemCreate(input: { ${inputValues.join(", ")} }) {
3252
+ workItem {
3253
+ id
3254
+ iid
3255
+ title
3256
+ webUrl
3257
+ workItemType { name }
3258
+ }
3259
+ errors
3260
+ }
3261
+ }`;
3262
+ const data = await executeGraphQL(mutation, variables);
3263
+ if (data.workItemCreate.errors?.length > 0) {
3264
+ throw new Error(`Failed to create work item: ${data.workItemCreate.errors.join(", ")}`);
3265
+ }
3266
+ const wi = data.workItemCreate.workItem;
3267
+ return {
3268
+ id: wi.id,
3269
+ iid: wi.iid,
3270
+ title: wi.title,
3271
+ type: wi.workItemType?.name,
3272
+ webUrl: wi.webUrl,
3273
+ };
3274
+ }
3275
+ /**
3276
+ * Update a work item - consolidated handler for title, description, labels, assignees,
3277
+ * weight, state, status, parent, and children operations.
3278
+ */
3279
+ async function updateWorkItem(projectId, iid, options) {
3280
+ const { workItemGID, projectPath } = await resolveWorkItemGID(projectId, iid);
3281
+ // Build the main workItemUpdate mutation dynamically
3282
+ const inputParts = ["id: $id"];
3283
+ const varDefs = ["$id: WorkItemID!"];
3284
+ const variables = { id: workItemGID };
3285
+ if (options.title !== undefined) {
3286
+ varDefs.push("$title: String");
3287
+ inputParts.push("title: $title");
3288
+ variables.title = options.title;
3289
+ }
3290
+ if (options.description !== undefined) {
3291
+ varDefs.push("$description: String!");
3292
+ inputParts.push("descriptionWidget: { description: $description }");
3293
+ variables.description = options.description;
3294
+ }
3295
+ // Resolve label names and usernames to GIDs in a single GraphQL call
3296
+ const allLabelNames = [...(options.add_labels || []), ...(options.remove_labels || [])];
3297
+ const needsResolve = allLabelNames.length > 0 || options.assignee_usernames?.length;
3298
+ const { labelIds: resolvedLabelIds, userIds } = needsResolve
3299
+ ? await resolveNamesToIds(projectPath, allLabelNames.length > 0 ? allLabelNames : undefined, options.assignee_usernames)
3300
+ : { labelIds: [], userIds: [] };
3301
+ if (options.add_labels || options.remove_labels) {
3302
+ const labelParts = [];
3303
+ let offset = 0;
3304
+ if (options.add_labels && options.add_labels.length > 0) {
3305
+ const addIds = resolvedLabelIds.slice(0, options.add_labels.length);
3306
+ offset = options.add_labels.length;
3307
+ varDefs.push("$addLabelIds: [LabelID!]");
3308
+ labelParts.push("addLabelIds: $addLabelIds");
3309
+ variables.addLabelIds = addIds;
3310
+ }
3311
+ if (options.remove_labels && options.remove_labels.length > 0) {
3312
+ const removeIds = resolvedLabelIds.slice(offset);
3313
+ varDefs.push("$removeLabelIds: [LabelID!]");
3314
+ labelParts.push("removeLabelIds: $removeLabelIds");
3315
+ variables.removeLabelIds = removeIds;
3316
+ }
3317
+ if (labelParts.length > 0) {
3318
+ inputParts.push(`labelsWidget: { ${labelParts.join(", ")} }`);
3319
+ }
3320
+ }
3321
+ if (userIds.length > 0) {
3322
+ varDefs.push("$assigneeIds: [UserID!]!");
3323
+ inputParts.push("assigneesWidget: { assigneeIds: $assigneeIds }");
3324
+ variables.assigneeIds = userIds;
3325
+ }
3326
+ if (options.state_event !== undefined) {
3327
+ varDefs.push("$stateEvent: WorkItemStateEvent");
3328
+ inputParts.push("stateEvent: $stateEvent");
3329
+ variables.stateEvent = options.state_event === "close" ? "CLOSE" : "REOPEN";
3330
+ }
3331
+ if (options.weight !== undefined) {
3332
+ varDefs.push("$weight: Int");
3333
+ inputParts.push("weightWidget: { weight: $weight }");
3334
+ variables.weight = options.weight;
3335
+ }
3336
+ if (options.status !== undefined) {
3337
+ varDefs.push("$status: WorkItemsStatusesStatusID");
3338
+ inputParts.push("statusWidget: { status: $status }");
3339
+ variables.status = options.status;
3340
+ }
3341
+ if (options.health_status !== undefined) {
3342
+ varDefs.push("$healthStatus: HealthStatus");
3343
+ inputParts.push("healthStatusWidget: { healthStatus: $healthStatus }");
3344
+ variables.healthStatus = options.health_status;
3345
+ }
3346
+ // Start and due date widget - combine into one widget
3347
+ if (options.start_date !== undefined || options.due_date !== undefined) {
3348
+ const dateParts = [];
3349
+ if (options.start_date !== undefined) {
3350
+ varDefs.push("$startDate: Date");
3351
+ dateParts.push("startDate: $startDate");
3352
+ variables.startDate = options.start_date;
3353
+ }
3354
+ if (options.due_date !== undefined) {
3355
+ varDefs.push("$dueDate: Date");
3356
+ dateParts.push("dueDate: $dueDate");
3357
+ variables.dueDate = options.due_date;
3358
+ }
3359
+ inputParts.push(`startAndDueDateWidget: { ${dateParts.join(", ")} }`);
3360
+ }
3361
+ if (options.milestone_id !== undefined) {
3362
+ // Convert numeric ID to GID format if needed
3363
+ const milestoneGID = options.milestone_id.startsWith("gid://")
3364
+ ? options.milestone_id
3365
+ : `gid://gitlab/Milestone/${options.milestone_id}`;
3366
+ varDefs.push("$milestoneId: MilestoneID");
3367
+ inputParts.push("milestoneWidget: { milestoneId: $milestoneId }");
3368
+ variables.milestoneId = milestoneGID;
3369
+ }
3370
+ if (options.iteration_id !== undefined) {
3371
+ const iterationGID = options.iteration_id.startsWith("gid://")
3372
+ ? options.iteration_id
3373
+ : `gid://gitlab/Iteration/${options.iteration_id}`;
3374
+ varDefs.push("$iterationId: IterationID");
3375
+ inputParts.push("iterationWidget: { iterationId: $iterationId }");
3376
+ variables.iterationId = iterationGID;
3377
+ }
3378
+ if (options.confidential !== undefined) {
3379
+ varDefs.push("$confidential: Boolean");
3380
+ inputParts.push("confidential: $confidential");
3381
+ variables.confidential = options.confidential;
3382
+ }
3383
+ // Custom fields widget
3384
+ if (options.custom_fields && options.custom_fields.length > 0) {
3385
+ const cfValues = options.custom_fields.map(cf => {
3386
+ const cfId = cf.custom_field_id.startsWith("gid://")
3387
+ ? cf.custom_field_id
3388
+ : `gid://gitlab/IssuablesCustomField/${cf.custom_field_id}`;
3389
+ const val = { customFieldId: cfId };
3390
+ if (cf.text_value !== undefined)
3391
+ val.textValue = cf.text_value;
3392
+ if (cf.number_value !== undefined)
3393
+ val.numberValue = cf.number_value;
3394
+ if (cf.selected_option_ids !== undefined)
3395
+ val.selectedOptionIds = cf.selected_option_ids;
3396
+ if (cf.date_value !== undefined)
3397
+ val.dateValue = cf.date_value;
3398
+ return val;
3399
+ });
3400
+ varDefs.push("$customFieldsWidget: [WorkItemWidgetCustomFieldValueInputType!]");
3401
+ inputParts.push("customFieldsWidget: $customFieldsWidget");
3402
+ variables.customFieldsWidget = cfValues;
3403
+ }
3404
+ // Hierarchy: set parent or remove parent
3405
+ if (options.remove_parent) {
3406
+ inputParts.push("hierarchyWidget: { parentId: null }");
3407
+ }
3408
+ else if (options.parent_iid !== undefined) {
3409
+ const parentProjectId = options.parent_project_id || projectId;
3410
+ const { workItemGID: parentGID } = await resolveWorkItemGID(parentProjectId, options.parent_iid);
3411
+ varDefs.push("$parentId: WorkItemID");
3412
+ inputParts.push("hierarchyWidget: { parentId: $parentId }");
3413
+ variables.parentId = parentGID;
3414
+ }
3415
+ // Execute the main update mutation
3416
+ const mutation = `mutation(${varDefs.join(", ")}) {
3417
+ workItemUpdate(input: { ${inputParts.join(", ")} }) {
3418
+ workItem {
3419
+ id
3420
+ iid
3421
+ title
3422
+ state
3423
+ webUrl
3424
+ workItemType { name }
3425
+ widgets {
3426
+ __typename
3427
+ ... on WorkItemWidgetStatus { status { id name category color } }
3428
+ ... on WorkItemWidgetLabels { labels { nodes { title } } }
3429
+ ... on WorkItemWidgetAssignees { assignees { nodes { username } } }
3430
+ ... on WorkItemWidgetWeight { weight }
3431
+ ... on WorkItemWidgetHierarchy {
3432
+ parent { id title workItemType { name } }
3433
+ }
3434
+ ... on WorkItemWidgetHealthStatus { healthStatus }
3435
+ ... on WorkItemWidgetStartAndDueDate { startDate dueDate }
3436
+ ... on WorkItemWidgetMilestone { milestone { id title } }
3437
+ }
3438
+ }
3439
+ errors
3440
+ }
3441
+ }`;
3442
+ const data = await executeGraphQL(mutation, variables);
3443
+ if (data.workItemUpdate.errors?.length > 0) {
3444
+ throw new Error(`Failed to update work item: ${data.workItemUpdate.errors.join(", ")}`);
3445
+ }
3446
+ // Handle children_to_add: use separate workItemUpdate call with hierarchyWidget.childrenIds
3447
+ if (options.children_to_add && options.children_to_add.length > 0) {
3448
+ const childGIDs = [];
3449
+ for (const child of options.children_to_add) {
3450
+ const { workItemGID: childGID } = await resolveWorkItemGID(child.project_id, child.iid);
3451
+ childGIDs.push(childGID);
3452
+ }
3453
+ const addData = await executeGraphQL(`mutation($id: WorkItemID!, $childrenIds: [WorkItemID!]!) {
3454
+ workItemUpdate(input: { id: $id, hierarchyWidget: { childrenIds: $childrenIds } }) {
3455
+ errors
3456
+ }
3457
+ }`, { id: workItemGID, childrenIds: childGIDs });
3458
+ if (addData.workItemUpdate.errors?.length > 0) {
3459
+ throw new Error(`Failed to add children: ${addData.workItemUpdate.errors.join(", ")}`);
3460
+ }
3461
+ }
3462
+ // Handle children_to_remove: remove parent from each child
3463
+ if (options.children_to_remove && options.children_to_remove.length > 0) {
3464
+ for (const child of options.children_to_remove) {
3465
+ await removeIssueParent(child.project_id, child.iid);
3466
+ }
3467
+ }
3468
+ // Handle linked_items_to_add: use workItemAddLinkedItems mutation
3469
+ if (options.linked_items_to_add && options.linked_items_to_add.length > 0) {
3470
+ // Group by link_type since each mutation call needs a single linkType
3471
+ const groupedByType = {};
3472
+ for (const item of options.linked_items_to_add) {
3473
+ const linkType = item.link_type || "RELATED";
3474
+ if (!groupedByType[linkType])
3475
+ groupedByType[linkType] = [];
3476
+ const { workItemGID: targetGID } = await resolveWorkItemGID(item.project_id, item.iid);
3477
+ groupedByType[linkType].push(targetGID);
3478
+ }
3479
+ for (const [linkType, targetGIDs] of Object.entries(groupedByType)) {
3480
+ const addLinkedData = await executeGraphQL(`mutation($id: WorkItemID!, $workItemsIds: [WorkItemID!]!, $linkType: WorkItemRelatedLinkType!) {
3481
+ workItemAddLinkedItems(input: { id: $id, workItemsIds: $workItemsIds, linkType: $linkType }) {
3482
+ errors
3483
+ }
3484
+ }`, { id: workItemGID, workItemsIds: targetGIDs, linkType });
3485
+ if (addLinkedData.workItemAddLinkedItems.errors?.length > 0) {
3486
+ throw new Error(`Failed to add linked items: ${addLinkedData.workItemAddLinkedItems.errors.join(", ")}`);
3487
+ }
3488
+ }
3489
+ }
3490
+ // Handle linked_items_to_remove: use workItemRemoveLinkedItems mutation
3491
+ if (options.linked_items_to_remove && options.linked_items_to_remove.length > 0) {
3492
+ const targetGIDs = [];
3493
+ for (const item of options.linked_items_to_remove) {
3494
+ const { workItemGID: targetGID } = await resolveWorkItemGID(item.project_id, item.iid);
3495
+ targetGIDs.push(targetGID);
3496
+ }
3497
+ const removeLinkedData = await executeGraphQL(`mutation($id: WorkItemID!, $workItemsIds: [WorkItemID!]!) {
3498
+ workItemRemoveLinkedItems(input: { id: $id, workItemsIds: $workItemsIds }) {
3499
+ errors
3500
+ }
3501
+ }`, { id: workItemGID, workItemsIds: targetGIDs });
3502
+ if (removeLinkedData.workItemRemoveLinkedItems.errors?.length > 0) {
3503
+ throw new Error(`Failed to remove linked items: ${removeLinkedData.workItemRemoveLinkedItems.errors.join(", ")}`);
3504
+ }
3505
+ }
3506
+ // Handle incident-specific fields via separate mutations
3507
+ if (options.severity !== undefined) {
3508
+ await updateIncidentSeverity(projectPath, iid, options.severity);
3509
+ }
3510
+ if (options.escalation_status !== undefined) {
3511
+ await updateIncidentEscalationStatus(projectPath, iid, options.escalation_status);
3512
+ }
3513
+ // Flatten the response
3514
+ const wi = data.workItemUpdate.workItem;
3515
+ const widgets = wi?.widgets || [];
3516
+ const statusW = widgets.find((w) => w.__typename === "WorkItemWidgetStatus");
3517
+ const labelsW = widgets.find((w) => w.__typename === "WorkItemWidgetLabels");
3518
+ const assigneesW = widgets.find((w) => w.__typename === "WorkItemWidgetAssignees");
3519
+ const weightW = widgets.find((w) => w.__typename === "WorkItemWidgetWeight");
3520
+ const hierarchyW = widgets.find((w) => w.__typename === "WorkItemWidgetHierarchy");
3521
+ const healthStatusW = widgets.find((w) => w.__typename === "WorkItemWidgetHealthStatus");
3522
+ const datesW = widgets.find((w) => w.__typename === "WorkItemWidgetStartAndDueDate");
3523
+ const milestoneW = widgets.find((w) => w.__typename === "WorkItemWidgetMilestone");
3524
+ return {
3525
+ id: wi.id,
3526
+ iid: wi.iid,
3527
+ title: wi.title,
3528
+ state: wi.state,
3529
+ type: wi.workItemType?.name,
3530
+ webUrl: wi.webUrl,
3531
+ status: statusW?.status || null,
3532
+ labels: (labelsW?.labels?.nodes || []).map((l) => l.title),
3533
+ assignees: (assigneesW?.assignees?.nodes || []).map((a) => a.username),
3534
+ weight: weightW?.weight ?? null,
3535
+ parent: hierarchyW?.parent || null,
3536
+ healthStatus: healthStatusW?.healthStatus || null,
3537
+ startDate: datesW?.startDate || null,
3538
+ dueDate: datesW?.dueDate || null,
3539
+ milestone: milestoneW?.milestone || null,
3540
+ children_added: options.children_to_add?.length || 0,
3541
+ children_removed: options.children_to_remove?.length || 0,
3542
+ linked_items_added: options.linked_items_to_add?.length || 0,
3543
+ linked_items_removed: options.linked_items_to_remove?.length || 0,
3544
+ ...(options.severity !== undefined && { severity: options.severity }),
3545
+ ...(options.escalation_status !== undefined && { escalation_status: options.escalation_status }),
3546
+ };
3547
+ }
1946
3548
  /**
1947
3549
  * List all issue links for a specific issue
1948
3550
  * 이슈 관계 목록 조회
@@ -2550,6 +4152,52 @@ async function searchProjects(query, page = 1, perPage = 20) {
2550
4152
  items: projects,
2551
4153
  });
2552
4154
  }
4155
+ /**
4156
+ * Search for code blobs using GitLab Search API
4157
+ * Supports global, project-level, and group-level search
4158
+ */
4159
+ async function searchBlobs(params) {
4160
+ let basePath;
4161
+ if (params.project_id) {
4162
+ const decodedProjectId = decodeURIComponent(params.project_id);
4163
+ const projectId = encodeURIComponent(getEffectiveProjectId(decodedProjectId));
4164
+ basePath = `${getEffectiveApiUrl()}/projects/${projectId}/search`;
4165
+ }
4166
+ else if (params.group_id) {
4167
+ const groupId = encodeURIComponent(decodeURIComponent(params.group_id));
4168
+ basePath = `${getEffectiveApiUrl()}/groups/${groupId}/search`;
4169
+ }
4170
+ else {
4171
+ basePath = `${getEffectiveApiUrl()}/search`;
4172
+ }
4173
+ const url = new URL(basePath);
4174
+ url.searchParams.append("scope", "blobs");
4175
+ url.searchParams.append("search", params.search);
4176
+ if (params.ref) {
4177
+ url.searchParams.append("ref", params.ref);
4178
+ }
4179
+ if (params.page) {
4180
+ url.searchParams.append("page", params.page.toString());
4181
+ }
4182
+ if (params.per_page) {
4183
+ url.searchParams.append("per_page", params.per_page.toString());
4184
+ }
4185
+ if (params.filename) {
4186
+ url.searchParams.append("filename", params.filename);
4187
+ }
4188
+ if (params.path) {
4189
+ url.searchParams.append("path", params.path);
4190
+ }
4191
+ if (params.extension) {
4192
+ url.searchParams.append("extension", params.extension);
4193
+ }
4194
+ const response = await fetch(url.toString(), {
4195
+ ...getFetchConfig(),
4196
+ });
4197
+ await handleGitLabError(response);
4198
+ const data = await response.json();
4199
+ return z.array(GitLabSearchBlobResultSchema).parse(data);
4200
+ }
2553
4201
  /**
2554
4202
  * Create a new GitLab repository
2555
4203
  * 새 저장소 생성
@@ -2884,6 +4532,99 @@ async function listMergeRequestDiffs(projectId, mergeRequestIid, branchName, pag
2884
4532
  await handleGitLabError(response);
2885
4533
  return await response.json(); // Return full response including commits, diff_refs, changes, etc.
2886
4534
  }
4535
+ /**
4536
+ * Returns the list of changed files in a merge request WITHOUT diff content.
4537
+ * Use this as STEP 1 of code review: get file paths, then fetch diffs in batches
4538
+ * with getMergeRequestFileDiff to avoid loading the entire diff payload at once.
4539
+ *
4540
+ * @param {string} projectId - The ID or URL-encoded path of the project
4541
+ * @param {number|string} [mergeRequestIid] - The internal ID of the merge request
4542
+ * @param {string} [branchName] - The name of the source branch (used to resolve MR if iid not provided)
4543
+ * @param {string[]} [excludedFilePatterns] - Regex patterns to exclude files from the result
4544
+ * @returns {Promise<any[]>} Array of changed file metadata (new_path, old_path, new_file, deleted_file, renamed_file)
4545
+ */
4546
+ async function listMergeRequestChangedFiles(projectId, mergeRequestIid, branchName, excludedFilePatterns) {
4547
+ projectId = decodeURIComponent(projectId);
4548
+ if (!mergeRequestIid && !branchName) {
4549
+ throw new Error("Either mergeRequestIid or branchName must be provided");
4550
+ }
4551
+ if (branchName && !mergeRequestIid) {
4552
+ const mergeRequest = await getMergeRequest(projectId, undefined, branchName);
4553
+ mergeRequestIid = mergeRequest.iid;
4554
+ }
4555
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/changes`);
4556
+ const response = await fetch(url.toString(), { ...getFetchConfig() });
4557
+ await handleGitLabError(response);
4558
+ const data = (await response.json());
4559
+ const rawFiles = (data.changes || []).map((f) => ({
4560
+ new_path: f.new_path,
4561
+ old_path: f.old_path,
4562
+ new_file: f.new_file,
4563
+ deleted_file: f.deleted_file,
4564
+ renamed_file: f.renamed_file,
4565
+ }));
4566
+ return filterDiffsByPatterns(rawFiles, excludedFilePatterns);
4567
+ }
4568
+ /**
4569
+ * Get diffs for specific files from a merge request.
4570
+ * Use this as STEP 2 of code review: pass file paths obtained from
4571
+ * listMergeRequestChangedFiles to fetch their diffs efficiently.
4572
+ *
4573
+ * @param {string} projectId - The ID or URL-encoded path of the project
4574
+ * @param {string[]} filePaths - List of file paths to retrieve diffs for
4575
+ * @param {number|string} [mergeRequestIid] - The internal ID of the merge request
4576
+ * @param {string} [branchName] - The name of the source branch (used to resolve MR if iid not provided)
4577
+ * @param {boolean} [unidiff] - Return diff in unified diff format
4578
+ * @returns {Promise<any[]>} Array of diff objects for each requested file, or error objects for files not found
4579
+ */
4580
+ async function getMergeRequestFileDiff(projectId, filePaths, mergeRequestIid, branchName, unidiff) {
4581
+ projectId = decodeURIComponent(projectId);
4582
+ if (!mergeRequestIid && !branchName) {
4583
+ throw new Error("Either mergeRequestIid or branchName must be provided");
4584
+ }
4585
+ if (branchName && !mergeRequestIid) {
4586
+ const mergeRequest = await getMergeRequest(projectId, undefined, branchName);
4587
+ mergeRequestIid = mergeRequest.iid;
4588
+ }
4589
+ // Paginate through /diffs once, collecting all requested files.
4590
+ // More efficient than N separate searches when fetching multiple files.
4591
+ const remaining = new Set(filePaths);
4592
+ const results = [];
4593
+ let page = 1;
4594
+ const perPage = 20;
4595
+ while (remaining.size > 0) {
4596
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/diffs`);
4597
+ url.searchParams.append("page", page.toString());
4598
+ url.searchParams.append("per_page", perPage.toString());
4599
+ if (unidiff) {
4600
+ url.searchParams.append("unidiff", "true");
4601
+ }
4602
+ const response = await fetch(url.toString(), { ...getFetchConfig() });
4603
+ await handleGitLabError(response);
4604
+ const items = (await response.json());
4605
+ if (!Array.isArray(items) || items.length === 0) {
4606
+ break;
4607
+ }
4608
+ for (const item of items) {
4609
+ if (remaining.has(item.new_path) || remaining.has(item.old_path)) {
4610
+ results.push(item);
4611
+ remaining.delete(item.new_path);
4612
+ remaining.delete(item.old_path);
4613
+ }
4614
+ }
4615
+ if (items.length < perPage) {
4616
+ break;
4617
+ }
4618
+ page++;
4619
+ }
4620
+ for (const notFound of remaining) {
4621
+ results.push({
4622
+ error: `File not found in merge request diffs: ${notFound}`,
4623
+ hint: "Use list_merge_request_changed_files to verify the correct file paths.",
4624
+ });
4625
+ }
4626
+ return results;
4627
+ }
2887
4628
  /**
2888
4629
  * Get branch comparison diffs
2889
4630
  *
@@ -3870,6 +5611,84 @@ async function deleteWikiPage(projectId, slug) {
3870
5611
  });
3871
5612
  await handleGitLabError(response);
3872
5613
  }
5614
+ /**
5615
+ * List wiki pages in a GitLab group
5616
+ */
5617
+ async function listGroupWikiPages(groupId, options = {}) {
5618
+ groupId = decodeURIComponent(groupId); // Decode group ID
5619
+ const url = new URL(`${getEffectiveApiUrl()}/groups/${encodeURIComponent(groupId)}/wikis`);
5620
+ if (options.page)
5621
+ url.searchParams.append("page", options.page.toString());
5622
+ if (options.per_page)
5623
+ url.searchParams.append("per_page", options.per_page.toString());
5624
+ if (options.with_content)
5625
+ url.searchParams.append("with_content", options.with_content.toString());
5626
+ const response = await fetch(url.toString(), {
5627
+ ...getFetchConfig(),
5628
+ });
5629
+ await handleGitLabError(response);
5630
+ const data = await response.json();
5631
+ return GitLabWikiPageSchema.array().parse(data);
5632
+ }
5633
+ /**
5634
+ * Get a specific group wiki page
5635
+ */
5636
+ async function getGroupWikiPage(groupId, slug) {
5637
+ groupId = decodeURIComponent(groupId); // Decode group ID
5638
+ const response = await fetch(`${getEffectiveApiUrl()}/groups/${encodeURIComponent(groupId)}/wikis/${encodeURIComponent(slug)}`, { ...getFetchConfig() });
5639
+ await handleGitLabError(response);
5640
+ const data = await response.json();
5641
+ return GitLabWikiPageSchema.parse(data);
5642
+ }
5643
+ /**
5644
+ * Create a new group wiki page
5645
+ */
5646
+ async function createGroupWikiPage(groupId, title, content, format) {
5647
+ groupId = decodeURIComponent(groupId); // Decode group ID
5648
+ const body = { title, content };
5649
+ if (format)
5650
+ body.format = format;
5651
+ const response = await fetch(`${getEffectiveApiUrl()}/groups/${encodeURIComponent(groupId)}/wikis`, {
5652
+ ...getFetchConfig(),
5653
+ method: "POST",
5654
+ body: JSON.stringify(body),
5655
+ });
5656
+ await handleGitLabError(response);
5657
+ const data = await response.json();
5658
+ return GitLabWikiPageSchema.parse(data);
5659
+ }
5660
+ /**
5661
+ * Update an existing group wiki page
5662
+ */
5663
+ async function updateGroupWikiPage(groupId, slug, title, content, format) {
5664
+ groupId = decodeURIComponent(groupId); // Decode group ID
5665
+ const body = {};
5666
+ if (title)
5667
+ body.title = title;
5668
+ if (content)
5669
+ body.content = content;
5670
+ if (format)
5671
+ body.format = format;
5672
+ const response = await fetch(`${getEffectiveApiUrl()}/groups/${encodeURIComponent(groupId)}/wikis/${encodeURIComponent(slug)}`, {
5673
+ ...getFetchConfig(),
5674
+ method: "PUT",
5675
+ body: JSON.stringify(body),
5676
+ });
5677
+ await handleGitLabError(response);
5678
+ const data = await response.json();
5679
+ return GitLabWikiPageSchema.parse(data);
5680
+ }
5681
+ /**
5682
+ * Delete a group wiki page
5683
+ */
5684
+ async function deleteGroupWikiPage(groupId, slug) {
5685
+ groupId = decodeURIComponent(groupId); // Decode group ID
5686
+ const response = await fetch(`${getEffectiveApiUrl()}/groups/${encodeURIComponent(groupId)}/wikis/${encodeURIComponent(slug)}`, {
5687
+ ...getFetchConfig(),
5688
+ method: "DELETE",
5689
+ });
5690
+ await handleGitLabError(response);
5691
+ }
3873
5692
  /**
3874
5693
  * List pipelines in a GitLab project
3875
5694
  *
@@ -5198,6 +7017,51 @@ async function handleToolCall(params) {
5198
7017
  content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
5199
7018
  };
5200
7019
  }
7020
+ case "search_code": {
7021
+ const args = SearchCodeSchema.parse(params.arguments);
7022
+ const results = await searchBlobs({
7023
+ search: args.search,
7024
+ filename: args.filename,
7025
+ path: args.path,
7026
+ extension: args.extension,
7027
+ page: args.page,
7028
+ per_page: args.per_page,
7029
+ });
7030
+ return {
7031
+ content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
7032
+ };
7033
+ }
7034
+ case "search_project_code": {
7035
+ const args = SearchProjectCodeSchema.parse(params.arguments);
7036
+ const results = await searchBlobs({
7037
+ search: args.search,
7038
+ project_id: args.project_id,
7039
+ ref: args.ref,
7040
+ filename: args.filename,
7041
+ path: args.path,
7042
+ extension: args.extension,
7043
+ page: args.page,
7044
+ per_page: args.per_page,
7045
+ });
7046
+ return {
7047
+ content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
7048
+ };
7049
+ }
7050
+ case "search_group_code": {
7051
+ const args = SearchGroupCodeSchema.parse(params.arguments);
7052
+ const results = await searchBlobs({
7053
+ search: args.search,
7054
+ group_id: args.group_id,
7055
+ filename: args.filename,
7056
+ path: args.path,
7057
+ extension: args.extension,
7058
+ page: args.page,
7059
+ per_page: args.per_page,
7060
+ });
7061
+ return {
7062
+ content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
7063
+ };
7064
+ }
5201
7065
  case "create_repository": {
5202
7066
  if (GITLAB_PROJECT_ID) {
5203
7067
  throw new Error("Direct project ID is set. So fork_repository is not allowed");
@@ -5347,6 +7211,13 @@ async function handleToolCall(params) {
5347
7211
  content: [{ type: "text", text: JSON.stringify(filteredDiffs, null, 2) }],
5348
7212
  };
5349
7213
  }
7214
+ case "list_merge_request_changed_files": {
7215
+ const args = ListMergeRequestChangedFilesSchema.parse(params.arguments);
7216
+ const files = await listMergeRequestChangedFiles(args.project_id, args.merge_request_iid, args.source_branch, args.excluded_file_patterns);
7217
+ return {
7218
+ content: [{ type: "text", text: JSON.stringify(files, null, 2) }],
7219
+ };
7220
+ }
5350
7221
  case "list_merge_request_diffs": {
5351
7222
  const args = ListMergeRequestDiffsSchema.parse(params.arguments);
5352
7223
  const changes = await listMergeRequestDiffs(args.project_id, args.merge_request_iid, args.source_branch, args.page, args.per_page, args.unidiff);
@@ -5354,6 +7225,13 @@ async function handleToolCall(params) {
5354
7225
  content: [{ type: "text", text: JSON.stringify(changes, null, 2) }],
5355
7226
  };
5356
7227
  }
7228
+ case "get_merge_request_file_diff": {
7229
+ const args = GetMergeRequestFileDiffSchema.parse(params.arguments);
7230
+ const fileDiff = await getMergeRequestFileDiff(args.project_id, args.file_paths, args.merge_request_iid, args.source_branch, args.unidiff);
7231
+ return {
7232
+ content: [{ type: "text", text: JSON.stringify(fileDiff, null, 2) }],
7233
+ };
7234
+ }
5357
7235
  case "list_merge_request_versions": {
5358
7236
  const args = ListMergeRequestVersionsSchema.parse(params.arguments);
5359
7237
  const versions = await listMergeRequestVersions(args.project_id, args.merge_request_iid);
@@ -5680,6 +7558,93 @@ async function handleToolCall(params) {
5680
7558
  ],
5681
7559
  };
5682
7560
  }
7561
+ case "get_work_item": {
7562
+ const args = GetWorkItemSchema.parse(params.arguments);
7563
+ const result = await getWorkItem(args.project_id, args.iid);
7564
+ return {
7565
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
7566
+ };
7567
+ }
7568
+ case "list_work_items": {
7569
+ const args = ListWorkItemsSchema.parse(params.arguments);
7570
+ const { project_id, ...options } = args;
7571
+ const result = await listWorkItems(project_id, options);
7572
+ return {
7573
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
7574
+ };
7575
+ }
7576
+ case "create_work_item": {
7577
+ const args = CreateWorkItemSchema.parse(params.arguments);
7578
+ const { project_id, ...options } = args;
7579
+ const result = await createWorkItem(project_id, options);
7580
+ return {
7581
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
7582
+ };
7583
+ }
7584
+ case "update_work_item": {
7585
+ const args = UpdateWorkItemSchema.parse(params.arguments);
7586
+ const { project_id, iid, ...options } = args;
7587
+ const result = await updateWorkItem(project_id, iid, options);
7588
+ return {
7589
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
7590
+ };
7591
+ }
7592
+ case "convert_work_item_type": {
7593
+ const args = ConvertWorkItemTypeSchema.parse(params.arguments);
7594
+ const result = await convertIssueType(args.project_id, args.iid, args.new_type);
7595
+ return {
7596
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
7597
+ };
7598
+ }
7599
+ case "list_work_item_statuses": {
7600
+ const args = ListWorkItemStatusesSchema.parse(params.arguments);
7601
+ const result = await listIssueStatuses(args.project_id, args.work_item_type);
7602
+ return {
7603
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
7604
+ };
7605
+ }
7606
+ case "list_custom_field_definitions": {
7607
+ const args = ListCustomFieldDefinitionsSchema.parse(params.arguments);
7608
+ const result = await listCustomFieldDefinitions(args.project_id, args.work_item_type);
7609
+ return {
7610
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
7611
+ };
7612
+ }
7613
+ case "move_work_item": {
7614
+ const args = MoveWorkItemSchema.parse(params.arguments);
7615
+ const result = await moveWorkItem(args.project_id, args.iid, args.target_project_id);
7616
+ return {
7617
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
7618
+ };
7619
+ }
7620
+ case "list_work_item_notes": {
7621
+ const args = ListWorkItemNotesSchema.parse(params.arguments);
7622
+ const result = await listWorkItemNotes(args.project_id, args.iid, args);
7623
+ return {
7624
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
7625
+ };
7626
+ }
7627
+ case "create_work_item_note": {
7628
+ const args = CreateWorkItemNoteSchema.parse(params.arguments);
7629
+ const result = await createWorkItemNote(args.project_id, args.iid, args.body, args);
7630
+ return {
7631
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
7632
+ };
7633
+ }
7634
+ case "get_timeline_events": {
7635
+ const args = GetTimelineEventsSchema.parse(params.arguments);
7636
+ const result = await getTimelineEvents(args.project_id, args.incident_iid);
7637
+ return {
7638
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
7639
+ };
7640
+ }
7641
+ case "create_timeline_event": {
7642
+ const args = CreateTimelineEventSchema.parse(params.arguments);
7643
+ const result = await createTimelineEvent(args.project_id, args.incident_iid, args.note, args.occurred_at, args.tag_names);
7644
+ return {
7645
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
7646
+ };
7647
+ }
5683
7648
  case "list_labels": {
5684
7649
  const args = ListLabelsSchema.parse(params.arguments);
5685
7650
  const labels = await listLabels(args.project_id, args);
@@ -5775,6 +7740,53 @@ async function handleToolCall(params) {
5775
7740
  ],
5776
7741
  };
5777
7742
  }
7743
+ case "list_group_wiki_pages": {
7744
+ const { group_id, page, per_page, with_content } = ListGroupWikiPagesSchema.parse(params.arguments);
7745
+ const wikiPages = await listGroupWikiPages(group_id, {
7746
+ page,
7747
+ per_page,
7748
+ with_content,
7749
+ });
7750
+ return {
7751
+ content: [{ type: "text", text: JSON.stringify(wikiPages, null, 2) }],
7752
+ };
7753
+ }
7754
+ case "get_group_wiki_page": {
7755
+ const { group_id, slug } = GetGroupWikiPageSchema.parse(params.arguments);
7756
+ const wikiPage = await getGroupWikiPage(group_id, slug);
7757
+ return {
7758
+ content: [{ type: "text", text: JSON.stringify(wikiPage, null, 2) }],
7759
+ };
7760
+ }
7761
+ case "create_group_wiki_page": {
7762
+ const { group_id, title, content, format } = CreateGroupWikiPageSchema.parse(params.arguments);
7763
+ const wikiPage = await createGroupWikiPage(group_id, title, content, format);
7764
+ return {
7765
+ content: [{ type: "text", text: JSON.stringify(wikiPage, null, 2) }],
7766
+ };
7767
+ }
7768
+ case "update_group_wiki_page": {
7769
+ const { group_id, slug, title, content, format } = UpdateGroupWikiPageSchema.parse(params.arguments);
7770
+ const wikiPage = await updateGroupWikiPage(group_id, slug, title, content, format);
7771
+ return {
7772
+ content: [{ type: "text", text: JSON.stringify(wikiPage, null, 2) }],
7773
+ };
7774
+ }
7775
+ case "delete_group_wiki_page": {
7776
+ const { group_id, slug } = DeleteGroupWikiPageSchema.parse(params.arguments);
7777
+ await deleteGroupWikiPage(group_id, slug);
7778
+ return {
7779
+ content: [
7780
+ {
7781
+ type: "text",
7782
+ text: JSON.stringify({
7783
+ status: "success",
7784
+ message: "Group wiki page deleted successfully",
7785
+ }, null, 2),
7786
+ },
7787
+ ],
7788
+ };
7789
+ }
5778
7790
  case "get_repository_tree": {
5779
7791
  const args = GetRepositoryTreeSchema.parse(params.arguments);
5780
7792
  const tree = await getRepositoryTree(args);
@@ -5989,8 +8001,20 @@ async function handleToolCall(params) {
5989
8001
  };
5990
8002
  }
5991
8003
  case "list_merge_requests": {
5992
- const args = ListMergeRequestsSchema.parse(params.arguments);
5993
- const mergeRequests = await listMergeRequests(args.project_id, args);
8004
+ const { project_id, ...options } = ListMergeRequestsSchema.parse(params.arguments);
8005
+ // GitLab API treats _id and _username as mutually exclusive for these fields.
8006
+ // When both are provided, prefer _username and remove _id to avoid 400 errors.
8007
+ const cleanedOptions = { ...options };
8008
+ if (cleanedOptions.author_id && cleanedOptions.author_username) {
8009
+ delete cleanedOptions.author_id;
8010
+ }
8011
+ if (cleanedOptions.assignee_id && cleanedOptions.assignee_username) {
8012
+ delete cleanedOptions.assignee_id;
8013
+ }
8014
+ if (cleanedOptions.reviewer_id && cleanedOptions.reviewer_username) {
8015
+ delete cleanedOptions.reviewer_id;
8016
+ }
8017
+ const mergeRequests = await listMergeRequests(project_id, cleanedOptions);
5994
8018
  return {
5995
8019
  content: [{ type: "text", text: JSON.stringify(mergeRequests, null, 2) }],
5996
8020
  };
@@ -6526,13 +8550,43 @@ async function startStreamableHTTPServer() {
6526
8550
  };
6527
8551
  // Configure Express middleware
6528
8552
  app.use(express.json());
8553
+ // MCP OAuth — mount auth router and prepare bearer-auth middleware
8554
+ if (GITLAB_MCP_OAUTH) {
8555
+ // Trust first proxy so express-rate-limit uses X-Forwarded-For for real client IP.
8556
+ // Only enabled in OAuth mode where the server is typically behind a reverse proxy.
8557
+ app.set("trust proxy", 1);
8558
+ const gitlabBaseUrl = GITLAB_API_URL.replace(/\/api\/v4\/?$/, "").replace(/\/$/, "");
8559
+ const issuerUrl = new URL(MCP_SERVER_URL);
8560
+ const oauthProvider = createGitLabOAuthProvider(gitlabBaseUrl, GITLAB_OAUTH_APP_ID, "GitLab MCP Server", GITLAB_READ_ONLY_MODE);
8561
+ // Mounts /.well-known/oauth-authorization-server,
8562
+ // /.well-known/oauth-protected-resource,
8563
+ // /authorize, /token, /register, /revoke
8564
+ app.use(mcpAuthRouter({
8565
+ provider: oauthProvider,
8566
+ issuerUrl,
8567
+ baseUrl: issuerUrl,
8568
+ scopesSupported: ["api", "read_api", "read_user"],
8569
+ resourceName: "GitLab MCP Server",
8570
+ }));
8571
+ // Expose provider so the /mcp route middleware can reference it
8572
+ app._mcpOAuthProvider = oauthProvider;
8573
+ }
8574
+ // Build bearer-auth middleware — no-op unless GITLAB_MCP_OAUTH is enabled.
8575
+ // Unauthenticated requests receive 401 + WWW-Authenticate header, which is
8576
+ // exactly what Claude.ai needs to trigger the OAuth browser flow.
8577
+ const mcpBearerAuth = GITLAB_MCP_OAUTH
8578
+ ? requireBearerAuth({
8579
+ verifier: app._mcpOAuthProvider,
8580
+ requiredScopes: [],
8581
+ })
8582
+ : (_req, _res, next) => next();
6529
8583
  // Streamable HTTP endpoint - handles both session creation and message handling
6530
- app.post("/mcp", async (req, res) => {
8584
+ app.post("/mcp", mcpBearerAuth, async (req, res) => {
6531
8585
  const sessionId = req.headers["mcp-session-id"];
6532
8586
  // Track request
6533
8587
  metrics.requestsProcessed++;
6534
8588
  // Rate limiting check for existing sessions
6535
- if (REMOTE_AUTHORIZATION && sessionId && !checkRateLimit(sessionId)) {
8589
+ if ((REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) && sessionId && !checkRateLimit(sessionId)) {
6536
8590
  metrics.rejectedByRateLimit++;
6537
8591
  res.status(429).json({
6538
8592
  error: "Rate limit exceeded",
@@ -6582,6 +8636,31 @@ async function startStreamableHTTPServer() {
6582
8636
  // First request without session - will fail in initialization
6583
8637
  }
6584
8638
  }
8639
+ // MCP OAuth mode — token already validated by requireBearerAuth middleware.
8640
+ // req.auth is populated by the middleware; store/refresh per session so that
8641
+ // buildAuthHeaders() can pick it up via AsyncLocalStorage, exactly like the
8642
+ // REMOTE_AUTHORIZATION path.
8643
+ if (GITLAB_MCP_OAUTH) {
8644
+ const authInfo = req.auth;
8645
+ if (authInfo?.token && sessionId) {
8646
+ if (!authBySession[sessionId]) {
8647
+ authBySession[sessionId] = {
8648
+ header: "Authorization",
8649
+ token: authInfo.token,
8650
+ lastUsed: Date.now(),
8651
+ apiUrl: GITLAB_API_URL,
8652
+ };
8653
+ logger.info(`Session ${sessionId}: stored OAuth token (client: ${authInfo.clientId})`);
8654
+ setAuthTimeout(sessionId);
8655
+ }
8656
+ else {
8657
+ // Update token on every request — the client may have refreshed it
8658
+ authBySession[sessionId].token = authInfo.token;
8659
+ authBySession[sessionId].lastUsed = Date.now();
8660
+ setAuthTimeout(sessionId);
8661
+ }
8662
+ }
8663
+ }
6585
8664
  // Handle request with proper AsyncLocalStorage context
6586
8665
  const handleRequest = async () => {
6587
8666
  try {
@@ -6609,6 +8688,20 @@ async function startStreamableHTTPServer() {
6609
8688
  setAuthTimeout(newSessionId);
6610
8689
  }
6611
8690
  }
8691
+ // Store OAuth token for newly created session in MCP OAuth mode
8692
+ if (GITLAB_MCP_OAUTH && !authBySession[newSessionId]) {
8693
+ const authInfo = req.auth;
8694
+ if (authInfo?.token) {
8695
+ authBySession[newSessionId] = {
8696
+ header: "Authorization",
8697
+ token: authInfo.token,
8698
+ lastUsed: Date.now(),
8699
+ apiUrl: GITLAB_API_URL,
8700
+ };
8701
+ logger.info(`Session ${newSessionId}: stored OAuth token (client: ${authInfo.clientId})`);
8702
+ setAuthTimeout(newSessionId);
8703
+ }
8704
+ }
6612
8705
  },
6613
8706
  });
6614
8707
  // Set up cleanup handler when transport closes
@@ -6618,7 +8711,7 @@ async function startStreamableHTTPServer() {
6618
8711
  logger.warn(`Streamable HTTP transport closed for session ${sid}, cleaning up`);
6619
8712
  delete streamableTransports[sid];
6620
8713
  metrics.activeSessions--;
6621
- if (REMOTE_AUTHORIZATION) {
8714
+ if (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) {
6622
8715
  cleanupSessionAuth(sid);
6623
8716
  delete sessionRequestCounts[sid];
6624
8717
  logger.info(`Session ${sid}: cleaned up auth mapping`);
@@ -6641,8 +8734,8 @@ async function startStreamableHTTPServer() {
6641
8734
  });
6642
8735
  }
6643
8736
  };
6644
- // Execute with auth context in remote mode
6645
- if (REMOTE_AUTHORIZATION && sessionId && authBySession[sessionId]) {
8737
+ // Execute with auth context in remote mode (REMOTE_AUTHORIZATION or GITLAB_MCP_OAUTH)
8738
+ if ((REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) && sessionId && authBySession[sessionId]) {
6646
8739
  const authData = authBySession[sessionId];
6647
8740
  const ctx = {
6648
8741
  sessionId,
@@ -6655,7 +8748,7 @@ async function startStreamableHTTPServer() {
6655
8748
  await sessionAuthStore.run(ctx, handleRequest);
6656
8749
  }
6657
8750
  else {
6658
- // Standard execution (no remote auth or no session yet)
8751
+ // Standard execution (no per-session auth or no session yet)
6659
8752
  await handleRequest();
6660
8753
  }
6661
8754
  });
@@ -6673,6 +8766,7 @@ async function startStreamableHTTPServer() {
6673
8766
  ...metrics,
6674
8767
  activeSessions: Object.keys(streamableTransports).length,
6675
8768
  authenticatedSessions: Object.keys(authBySession).length,
8769
+ gitlabClientPool: clientPool.getStats(),
6676
8770
  uptime: process.uptime(),
6677
8771
  memoryUsage: process.memoryUsage(),
6678
8772
  config: {
@@ -6680,6 +8774,7 @@ async function startStreamableHTTPServer() {
6680
8774
  maxRequestsPerMinute: MAX_REQUESTS_PER_MINUTE,
6681
8775
  sessionTimeoutSeconds: SESSION_TIMEOUT_SECONDS,
6682
8776
  remoteAuthEnabled: REMOTE_AUTHORIZATION,
8777
+ mcpOAuthEnabled: GITLAB_MCP_OAUTH,
6683
8778
  },
6684
8779
  });
6685
8780
  });
@@ -6705,7 +8800,7 @@ async function startStreamableHTTPServer() {
6705
8800
  try {
6706
8801
  await transport.close();
6707
8802
  logger.info(`Explicitly closed session via DELETE request: ${sessionId}`);
6708
- if (REMOTE_AUTHORIZATION) {
8803
+ if (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) {
6709
8804
  cleanupSessionAuth(sessionId);
6710
8805
  delete sessionRequestCounts[sessionId];
6711
8806
  logger.info(`Session ${sessionId}: cleaned up auth mapping on DELETE`);
@@ -6741,7 +8836,7 @@ async function startStreamableHTTPServer() {
6741
8836
  const transport = streamableTransports[sessionId];
6742
8837
  if (transport) {
6743
8838
  await transport.close();
6744
- if (REMOTE_AUTHORIZATION) {
8839
+ if (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) {
6745
8840
  cleanupSessionAuth(sessionId);
6746
8841
  delete sessionRequestCounts[sessionId];
6747
8842
  }