@zereight/mcp-gitlab 2.0.34 → 2.0.36

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({
@@ -152,7 +155,7 @@ function createServer() {
152
155
  // Manually retrieve the session context using the session ID passed in the request.
153
156
  // This is a robust workaround for AsyncLocalStorage context loss.
154
157
  const sessionId = request.params.sessionId;
155
- if (REMOTE_AUTHORIZATION && sessionId && authBySession[sessionId]) {
158
+ if ((REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) && sessionId && authBySession[sessionId]) {
156
159
  const authData = authBySession[sessionId];
157
160
  const sessionContext = {
158
161
  sessionId,
@@ -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,13 @@ 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");
346
+ const GITLAB_OAUTH_SCOPES_RAW = getConfig("oauth-scopes", "GITLAB_OAUTH_SCOPES");
347
+ const GITLAB_OAUTH_SCOPES = GITLAB_OAUTH_SCOPES_RAW
348
+ ? GITLAB_OAUTH_SCOPES_RAW.split(",").map((s) => s.trim()).filter(Boolean)
349
+ : undefined;
310
350
  const ENABLE_DYNAMIC_API_URL = getConfig("enable-dynamic-api-url", "ENABLE_DYNAMIC_API_URL") === "true";
311
351
  const SESSION_TIMEOUT_SECONDS = Number.parseInt(getConfig("session-timeout", "SESSION_TIMEOUT_SECONDS", "3600"), 10);
312
352
  const HOST = getConfig("host", "HOST") || "127.0.0.1";
@@ -482,11 +522,11 @@ const BASE_HEADERS = {
482
522
  };
483
523
  /**
484
524
  * Build authentication headers dynamically based on context
485
- * In REMOTE_AUTHORIZATION mode, reads from AsyncLocalStorage session context
525
+ * In REMOTE_AUTHORIZATION or GITLAB_MCP_OAUTH mode, reads from AsyncLocalStorage session context
486
526
  * Otherwise, uses environment token (OAuth token is refreshed lazily before each tool call)
487
527
  */
488
528
  function buildAuthHeaders() {
489
- if (REMOTE_AUTHORIZATION) {
529
+ if (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) {
490
530
  const ctx = sessionAuthStore.getStore();
491
531
  logger.debug({ context: ctx }, "buildAuthHeaders: session context");
492
532
  if (ctx?.token) {
@@ -496,11 +536,10 @@ function buildAuthHeaders() {
496
536
  }
497
537
  return {}; // No auth headers if no session context
498
538
  }
499
- // CI job tokens use a dedicated header (not Bearer/Private-Token)
500
- if (GITLAB_JOB_TOKEN) {
501
- return { "JOB-TOKEN": String(GITLAB_JOB_TOKEN) };
502
- }
503
- // Standard mode: prioritize OAuth token, then fall back to environment token
539
+ // Standard mode: PAT preferred over job token (broader permissions).
540
+ // OAuth token takes priority over PAT when both are set.
541
+ // NOTE: Changed in PR #400 — previously GITLAB_JOB_TOKEN had highest priority.
542
+ // If both GITLAB_PERSONAL_ACCESS_TOKEN and GITLAB_JOB_TOKEN are set, PAT wins.
504
543
  const token = OAUTH_ACCESS_TOKEN || GITLAB_PERSONAL_ACCESS_TOKEN;
505
544
  if (IS_OLD && token) {
506
545
  return { "Private-Token": String(token) };
@@ -508,6 +547,10 @@ function buildAuthHeaders() {
508
547
  if (token) {
509
548
  return { Authorization: `Bearer ${token}` };
510
549
  }
550
+ // Fall back to CI job token
551
+ if (GITLAB_JOB_TOKEN) {
552
+ return { "JOB-TOKEN": String(GITLAB_JOB_TOKEN) };
553
+ }
511
554
  return {};
512
555
  }
513
556
  /**
@@ -670,11 +713,32 @@ const allTools = [
670
713
  description: "Get the changes/diffs of a merge request (Either mergeRequestIid or branchName must be provided)",
671
714
  inputSchema: toJSONSchema(GetMergeRequestDiffsSchema),
672
715
  },
716
+ {
717
+ name: "list_merge_request_changed_files",
718
+ description: "STEP 1 of code review workflow. " +
719
+ "Returns ONLY the list of changed file paths in a merge request — WITHOUT diff content. " +
720
+ "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). " +
721
+ "This avoids loading the entire diff payload at once and reduces API calls. " +
722
+ "Supports excluded_file_patterns filtering using regex. " +
723
+ "Returns: new_path, old_path, new_file, deleted_file, renamed_file flags for each file. " +
724
+ "(Either mergeRequestIid or branchName must be provided)",
725
+ inputSchema: toJSONSchema(ListMergeRequestChangedFilesSchema),
726
+ },
673
727
  {
674
728
  name: "list_merge_request_diffs",
675
729
  description: "List merge request diffs with pagination support (Either mergeRequestIid or branchName must be provided)",
676
730
  inputSchema: toJSONSchema(ListMergeRequestDiffsSchema),
677
731
  },
732
+ {
733
+ name: "get_merge_request_file_diff",
734
+ description: "STEP 2 of code review workflow. " +
735
+ "Get diffs for one or more files from a merge request. " +
736
+ "Call list_merge_request_changed_files first to get file paths, then pass them as an array to fetch their diffs efficiently. " +
737
+ "Batching multiple files (recommended 3-5) is supported and preferred over individual requests. " +
738
+ "Returns an array of results - one per requested file path. Files not found are returned with error messages. " +
739
+ "(Either mergeRequestIid or branchName must be provided)",
740
+ inputSchema: toJSONSchema(GetMergeRequestFileDiffSchema),
741
+ },
678
742
  {
679
743
  name: "list_merge_request_versions",
680
744
  description: "List all versions of a merge request",
@@ -797,7 +861,7 @@ const allTools = [
797
861
  },
798
862
  {
799
863
  name: "create_issue_note",
800
- description: "Add a new note to an existing issue thread",
864
+ description: "Add a note to an issue. Creates a top-level comment, or replies to a discussion thread if discussion_id is provided",
801
865
  inputSchema: toJSONSchema(CreateIssueNoteSchema),
802
866
  },
803
867
  {
@@ -935,6 +999,31 @@ const allTools = [
935
999
  description: "Delete a wiki page from a GitLab project",
936
1000
  inputSchema: toJSONSchema(DeleteWikiPageSchema),
937
1001
  },
1002
+ {
1003
+ name: "list_group_wiki_pages",
1004
+ description: "List wiki pages in a GitLab group",
1005
+ inputSchema: toJSONSchema(ListGroupWikiPagesSchema),
1006
+ },
1007
+ {
1008
+ name: "get_group_wiki_page",
1009
+ description: "Get details of a specific group wiki page",
1010
+ inputSchema: toJSONSchema(GetGroupWikiPageSchema),
1011
+ },
1012
+ {
1013
+ name: "create_group_wiki_page",
1014
+ description: "Create a new wiki page in a GitLab group",
1015
+ inputSchema: toJSONSchema(CreateGroupWikiPageSchema),
1016
+ },
1017
+ {
1018
+ name: "update_group_wiki_page",
1019
+ description: "Update an existing wiki page in a GitLab group",
1020
+ inputSchema: toJSONSchema(UpdateGroupWikiPageSchema),
1021
+ },
1022
+ {
1023
+ name: "delete_group_wiki_page",
1024
+ description: "Delete a wiki page from a GitLab group",
1025
+ inputSchema: toJSONSchema(DeleteGroupWikiPageSchema),
1026
+ },
938
1027
  {
939
1028
  name: "get_repository_tree",
940
1029
  description: "Get the repository tree for a GitLab project (list files and directories)",
@@ -1165,6 +1254,68 @@ const allTools = [
1165
1254
  description: "Download a release asset file by direct asset path",
1166
1255
  inputSchema: toJSONSchema(DownloadReleaseAssetSchema),
1167
1256
  },
1257
+ // --- Work item tools (GraphQL-based) ---
1258
+ {
1259
+ name: "get_work_item",
1260
+ description: "Get a single work item with full details including status, hierarchy (parent/children), type, labels, assignees, and all widgets.",
1261
+ inputSchema: toJSONSchema(GetWorkItemSchema),
1262
+ },
1263
+ {
1264
+ name: "list_work_items",
1265
+ description: "List work items in a project with filters (type, state, search, assignees, labels). Returns items with status and hierarchy info.",
1266
+ inputSchema: toJSONSchema(ListWorkItemsSchema),
1267
+ },
1268
+ {
1269
+ name: "create_work_item",
1270
+ 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.",
1271
+ inputSchema: toJSONSchema(CreateWorkItemSchema),
1272
+ },
1273
+ {
1274
+ name: "update_work_item",
1275
+ 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.",
1276
+ inputSchema: toJSONSchema(UpdateWorkItemSchema),
1277
+ },
1278
+ {
1279
+ name: "convert_work_item_type",
1280
+ description: "Convert a work item to a different type (e.g. issue to task, task to incident).",
1281
+ inputSchema: toJSONSchema(ConvertWorkItemTypeSchema),
1282
+ },
1283
+ {
1284
+ name: "list_work_item_statuses",
1285
+ description: "List available statuses for a work item type in a project. Requires GitLab Premium/Ultimate with configurable statuses.",
1286
+ inputSchema: toJSONSchema(ListWorkItemStatusesSchema),
1287
+ },
1288
+ {
1289
+ name: "list_custom_field_definitions",
1290
+ 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.",
1291
+ inputSchema: toJSONSchema(ListCustomFieldDefinitionsSchema),
1292
+ },
1293
+ {
1294
+ name: "move_work_item",
1295
+ description: "Move a work item (issue, task, etc.) to a different project. Uses GitLab GraphQL issueMove mutation.",
1296
+ inputSchema: toJSONSchema(MoveWorkItemSchema),
1297
+ },
1298
+ {
1299
+ name: "list_work_item_notes",
1300
+ description: "List notes and discussions on a work item. Returns threaded discussions with author, body, timestamps, and system/internal flags.",
1301
+ inputSchema: toJSONSchema(ListWorkItemNotesSchema),
1302
+ },
1303
+ {
1304
+ name: "create_work_item_note",
1305
+ description: "Add a note/comment to a work item. Supports Markdown, internal notes, and threaded replies.",
1306
+ inputSchema: toJSONSchema(CreateWorkItemNoteSchema),
1307
+ },
1308
+ // --- Incident timeline event tools ---
1309
+ {
1310
+ name: "get_timeline_events",
1311
+ description: "List timeline events for an incident. Returns chronological events with notes, timestamps, and tags (Start time, End time, Impact detected, etc.).",
1312
+ inputSchema: toJSONSchema(GetTimelineEventsSchema),
1313
+ },
1314
+ {
1315
+ name: "create_timeline_event",
1316
+ description: "Create a timeline event on an incident. Supports tags: 'Start time', 'End time', 'Impact detected', 'Response initiated', 'Impact mitigated', 'Cause identified'.",
1317
+ inputSchema: toJSONSchema(CreateTimelineEventSchema),
1318
+ },
1168
1319
  {
1169
1320
  name: "list_webhooks",
1170
1321
  description: "List all configured webhooks for a GitLab project or group. Provide either project_id or group_id.",
@@ -1180,17 +1331,42 @@ const allTools = [
1180
1331
  description: "Get full details of a specific webhook event by ID, including request/response payloads. Searches up to 500 most recent events.",
1181
1332
  inputSchema: toJSONSchema(GetWebhookEventSchema),
1182
1333
  },
1334
+ {
1335
+ name: "search_code",
1336
+ 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.",
1337
+ inputSchema: toJSONSchema(SearchCodeSchema),
1338
+ },
1339
+ {
1340
+ name: "search_project_code",
1341
+ 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.",
1342
+ inputSchema: toJSONSchema(SearchProjectCodeSchema),
1343
+ },
1344
+ {
1345
+ name: "search_group_code",
1346
+ 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.",
1347
+ inputSchema: toJSONSchema(SearchGroupCodeSchema),
1348
+ },
1183
1349
  ];
1184
1350
  // Define which tools are read-only
1185
1351
  const readOnlyTools = new Set([
1186
1352
  "search_repositories",
1353
+ "search_code",
1354
+ "search_project_code",
1355
+ "search_group_code",
1187
1356
  "execute_graphql",
1188
1357
  "get_file_contents",
1189
1358
  "get_merge_request",
1190
1359
  "get_merge_request_diffs",
1360
+ "list_merge_request_changed_files",
1361
+ "list_merge_request_diffs",
1362
+ "get_merge_request_file_diff",
1191
1363
  "list_merge_request_versions",
1192
1364
  "get_merge_request_version",
1193
1365
  "get_branch_diffs",
1366
+ "get_merge_request_note",
1367
+ "get_merge_request_notes",
1368
+ "get_draft_note",
1369
+ "list_draft_notes",
1194
1370
  "mr_discussions",
1195
1371
  "list_issues",
1196
1372
  "my_issues",
@@ -1229,6 +1405,8 @@ const readOnlyTools = new Set([
1229
1405
  "get_milestone_burndown_events",
1230
1406
  "list_wiki_pages",
1231
1407
  "get_wiki_page",
1408
+ "list_group_wiki_pages",
1409
+ "get_group_wiki_page",
1232
1410
  "get_users",
1233
1411
  "list_commits",
1234
1412
  "get_commit",
@@ -1242,6 +1420,12 @@ const readOnlyTools = new Set([
1242
1420
  "get_release",
1243
1421
  "download_release_asset",
1244
1422
  "get_merge_request_approval_state",
1423
+ "get_work_item",
1424
+ "list_work_items",
1425
+ "list_work_item_statuses",
1426
+ "list_custom_field_definitions",
1427
+ "list_work_item_notes",
1428
+ "get_timeline_events",
1245
1429
  "get_merge_request_conflicts",
1246
1430
  "list_webhooks",
1247
1431
  "list_webhook_events",
@@ -1254,6 +1438,11 @@ const wikiToolNames = new Set([
1254
1438
  "create_wiki_page",
1255
1439
  "update_wiki_page",
1256
1440
  "delete_wiki_page",
1441
+ "list_group_wiki_pages",
1442
+ "get_group_wiki_page",
1443
+ "create_group_wiki_page",
1444
+ "update_group_wiki_page",
1445
+ "delete_group_wiki_page",
1257
1446
  "upload_wiki_attachment",
1258
1447
  ]);
1259
1448
  // Define which tools are related to milestones and can be toggled by USE_MILESTONE
@@ -1302,7 +1491,9 @@ const TOOLSET_DEFINITIONS = [
1302
1491
  "get_merge_request_conflicts",
1303
1492
  "get_merge_request",
1304
1493
  "get_merge_request_diffs",
1494
+ "list_merge_request_changed_files",
1305
1495
  "list_merge_request_diffs",
1496
+ "get_merge_request_file_diff",
1306
1497
  "list_merge_request_versions",
1307
1498
  "get_merge_request_version",
1308
1499
  "update_merge_request",
@@ -1446,6 +1637,11 @@ const TOOLSET_DEFINITIONS = [
1446
1637
  "create_wiki_page",
1447
1638
  "update_wiki_page",
1448
1639
  "delete_wiki_page",
1640
+ "list_group_wiki_pages",
1641
+ "get_group_wiki_page",
1642
+ "create_group_wiki_page",
1643
+ "update_group_wiki_page",
1644
+ "delete_group_wiki_page",
1449
1645
  ]),
1450
1646
  },
1451
1647
  {
@@ -1472,6 +1668,24 @@ const TOOLSET_DEFINITIONS = [
1472
1668
  "download_attachment",
1473
1669
  ]),
1474
1670
  },
1671
+ {
1672
+ id: "workitems",
1673
+ isDefault: false,
1674
+ tools: new Set([
1675
+ "get_work_item",
1676
+ "list_work_items",
1677
+ "create_work_item",
1678
+ "update_work_item",
1679
+ "convert_work_item_type",
1680
+ "list_work_item_statuses",
1681
+ "list_custom_field_definitions",
1682
+ "move_work_item",
1683
+ "list_work_item_notes",
1684
+ "create_work_item_note",
1685
+ "get_timeline_events",
1686
+ "create_timeline_event",
1687
+ ]),
1688
+ },
1475
1689
  {
1476
1690
  id: "webhooks",
1477
1691
  isDefault: false,
@@ -1481,6 +1695,11 @@ const TOOLSET_DEFINITIONS = [
1481
1695
  "get_webhook_event",
1482
1696
  ]),
1483
1697
  },
1698
+ {
1699
+ id: "search",
1700
+ isDefault: false,
1701
+ tools: new Set(["search_code", "search_project_code", "search_group_code"]),
1702
+ },
1484
1703
  ];
1485
1704
  // Derived lookup: tool name → toolset ID
1486
1705
  const TOOLSET_BY_TOOL_NAME = new Map();
@@ -1610,7 +1829,20 @@ if (REMOTE_AUTHORIZATION) {
1610
1829
  }
1611
1830
  logger.info("Remote authorization enabled: tokens will be read from HTTP headers");
1612
1831
  }
1613
- else if (!USE_OAUTH && !GITLAB_PERSONAL_ACCESS_TOKEN && !GITLAB_JOB_TOKEN && !GITLAB_AUTH_COOKIE_PATH) {
1832
+ if (GITLAB_MCP_OAUTH) {
1833
+ if (SSE) {
1834
+ logger.error("GITLAB_MCP_OAUTH=true is not compatible with SSE transport mode");
1835
+ logger.error("Please use STREAMABLE_HTTP=true instead");
1836
+ process.exit(1);
1837
+ }
1838
+ if (!STREAMABLE_HTTP) {
1839
+ logger.error("GITLAB_MCP_OAUTH=true requires STREAMABLE_HTTP=true");
1840
+ logger.error("Set STREAMABLE_HTTP=true to enable MCP OAuth");
1841
+ process.exit(1);
1842
+ }
1843
+ logger.info("MCP OAuth enabled: GitLab OAuth proxy active (Private-Token/JOB-TOKEN headers bypass OAuth)");
1844
+ }
1845
+ if (!REMOTE_AUTHORIZATION && !GITLAB_MCP_OAUTH && !USE_OAUTH && !GITLAB_PERSONAL_ACCESS_TOKEN && !GITLAB_JOB_TOKEN && !GITLAB_AUTH_COOKIE_PATH) {
1614
1846
  // Standard mode: token must be in environment (unless using OAuth)
1615
1847
  logger.error("GITLAB_PERSONAL_ACCESS_TOKEN environment variable is not set");
1616
1848
  logger.info("Either set GITLAB_PERSONAL_ACCESS_TOKEN or enable OAuth with GITLAB_USE_OAUTH=true");
@@ -1772,28 +2004,19 @@ async function getFileContents(projectId, filePath, ref) {
1772
2004
  }
1773
2005
  return parsedData;
1774
2006
  }
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
2007
  async function createIssue(projectId, options) {
1784
2008
  projectId = decodeURIComponent(projectId); // Decode project ID
1785
2009
  const effectiveProjectId = getEffectiveProjectId(projectId);
1786
2010
  const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/issues`);
2011
+ // Build request body, converting labels array to comma-separated string
2012
+ const body = { ...options };
2013
+ if (body.labels && Array.isArray(body.labels)) {
2014
+ body.labels = body.labels.join(",");
2015
+ }
1787
2016
  const response = await fetch(url.toString(), {
1788
2017
  ...getFetchConfig(),
1789
2018
  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
- }),
2019
+ body: JSON.stringify(body),
1797
2020
  });
1798
2021
  // Handle bad request
1799
2022
  if (response.status === 400) {
@@ -1943,6 +2166,1392 @@ async function deleteIssue(projectId, issueIid) {
1943
2166
  });
1944
2167
  await handleGitLabError(response);
1945
2168
  }
2169
+ // --- GraphQL helper ---
2170
+ /**
2171
+ * Execute a GraphQL query against the GitLab instance.
2172
+ * Reusable helper for work item operations.
2173
+ */
2174
+ async function executeGraphQL(query, variables = {}) {
2175
+ const apiUrl = new URL(getEffectiveApiUrl());
2176
+ const restPath = apiUrl.pathname || "";
2177
+ const idx = restPath.lastIndexOf("/api/v4");
2178
+ const prefix = idx >= 0 ? restPath.slice(0, idx) : "";
2179
+ const graphqlUrl = process.env.GITLAB_GRAPHQL_URL || `${apiUrl.origin}${prefix}/api/graphql`;
2180
+ const response = await fetch(graphqlUrl, {
2181
+ ...getFetchConfig(),
2182
+ method: "POST",
2183
+ headers: {
2184
+ ...BASE_HEADERS,
2185
+ ...buildAuthHeaders(),
2186
+ },
2187
+ body: JSON.stringify({ query, variables }),
2188
+ });
2189
+ if (!response.ok) {
2190
+ const errorBody = await response.text();
2191
+ throw new Error(`GraphQL request failed (${response.status}): ${errorBody}`);
2192
+ }
2193
+ const json = await response.json();
2194
+ if (json.errors && json.errors.length > 0) {
2195
+ throw new Error(`GraphQL errors: ${json.errors.map((e) => e.message).join(", ")}`);
2196
+ }
2197
+ return json.data;
2198
+ }
2199
+ /**
2200
+ * Resolve a project path and issue IID to a work item GraphQL GID.
2201
+ */
2202
+ async function resolveWorkItemGID(projectId, issueIid) {
2203
+ projectId = decodeURIComponent(projectId);
2204
+ const effectiveProjectId = getEffectiveProjectId(projectId);
2205
+ // First get the project path via REST (needed for GraphQL namespace query)
2206
+ const projectUrl = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}`);
2207
+ const projectResponse = await fetch(projectUrl.toString(), {
2208
+ ...getFetchConfig(),
2209
+ });
2210
+ await handleGitLabError(projectResponse);
2211
+ const project = await projectResponse.json();
2212
+ const projectPath = project.path_with_namespace;
2213
+ // Resolve work item GID via GraphQL
2214
+ const data = await executeGraphQL(`query($path: ID!, $iid: String!) {
2215
+ namespace(fullPath: $path) {
2216
+ workItem(iid: $iid) {
2217
+ id
2218
+ }
2219
+ }
2220
+ }`, { path: projectPath, iid: String(issueIid) });
2221
+ if (!data.namespace?.workItem?.id) {
2222
+ throw new Error(`Work item #${issueIid} not found in project ${projectPath}`);
2223
+ }
2224
+ return { workItemGID: data.namespace.workItem.id, projectPath };
2225
+ }
2226
+ /**
2227
+ * Resolve label names and usernames to GitLab GIDs in a single GraphQL call.
2228
+ */
2229
+ async function resolveNamesToIds(projectPath, labelNames, usernames) {
2230
+ if (!labelNames?.length && !usernames?.length) {
2231
+ return { labelIds: [], userIds: [] };
2232
+ }
2233
+ const data = await executeGraphQL(`query($path: ID!, $usernames: [String!]!) {
2234
+ project(fullPath: $path) { labels(includeAncestorGroups: true, first: 250) { nodes { id title } } }
2235
+ users(usernames: $usernames) { nodes { id username } }
2236
+ }`, { path: projectPath, usernames: usernames || [] });
2237
+ const labelIds = (labelNames || []).map(name => {
2238
+ const label = data.project.labels.nodes.find(l => l.title === name);
2239
+ if (!label)
2240
+ throw new Error(`Label '${name}' not found in project`);
2241
+ return label.id;
2242
+ });
2243
+ const userIds = (usernames || []).map(name => {
2244
+ const user = data.users.nodes.find(u => u.username === name);
2245
+ if (!user)
2246
+ throw new Error(`User '${name}' not found`);
2247
+ return user.id;
2248
+ });
2249
+ return { labelIds, userIds };
2250
+ }
2251
+ // --- Work item type conversion ---
2252
+ /**
2253
+ * Map user-facing type names to GitLab WorkItemType names for GraphQL queries.
2254
+ */
2255
+ const WORK_ITEM_TYPE_NAMES = {
2256
+ issue: "Issue",
2257
+ task: "Task",
2258
+ incident: "Incident",
2259
+ test_case: "Test Case",
2260
+ epic: "Epic",
2261
+ key_result: "Key Result",
2262
+ objective: "Objective",
2263
+ requirement: "Requirement",
2264
+ ticket: "Ticket",
2265
+ };
2266
+ /**
2267
+ * Get the GraphQL GID for a work item type by querying the project's available types.
2268
+ */
2269
+ async function resolveWorkItemTypeGID(projectPath, typeName) {
2270
+ const targetName = WORK_ITEM_TYPE_NAMES[typeName];
2271
+ if (!targetName) {
2272
+ throw new Error(`Unknown work item type: ${typeName}`);
2273
+ }
2274
+ const data = await executeGraphQL(`query($path: ID!) {
2275
+ namespace(fullPath: $path) {
2276
+ workItemTypes {
2277
+ nodes {
2278
+ id
2279
+ name
2280
+ }
2281
+ }
2282
+ }
2283
+ }`, { path: projectPath });
2284
+ const typeNode = data.namespace?.workItemTypes?.nodes?.find((n) => n.name === targetName);
2285
+ if (!typeNode) {
2286
+ throw new Error(`Work item type '${targetName}' not found in project ${projectPath}`);
2287
+ }
2288
+ return typeNode.id;
2289
+ }
2290
+ /**
2291
+ * Convert an issue to a different work item type using GraphQL.
2292
+ */
2293
+ async function convertIssueType(projectId, issueIid, newType) {
2294
+ const { workItemGID, projectPath } = await resolveWorkItemGID(projectId, issueIid);
2295
+ const workItemTypeGID = await resolveWorkItemTypeGID(projectPath, newType);
2296
+ const data = await executeGraphQL(`mutation($id: WorkItemID!, $typeId: WorkItemsTypeID!) {
2297
+ workItemConvert(input: { id: $id, workItemTypeId: $typeId }) {
2298
+ workItem {
2299
+ id
2300
+ workItemType { name }
2301
+ }
2302
+ errors
2303
+ }
2304
+ }`, { id: workItemGID, typeId: workItemTypeGID });
2305
+ if (data.workItemConvert.errors?.length > 0) {
2306
+ throw new Error(`Conversion failed: ${data.workItemConvert.errors.join(", ")}`);
2307
+ }
2308
+ return {
2309
+ id: data.workItemConvert.workItem.id,
2310
+ type: data.workItemConvert.workItem.workItemType.name,
2311
+ };
2312
+ }
2313
+ // --- Work item hierarchy ---
2314
+ /**
2315
+ * Set a parent for a work item (issue hierarchy).
2316
+ */
2317
+ async function setIssueParent(projectId, issueIid, parentProjectId, parentIssueIid) {
2318
+ const { workItemGID } = await resolveWorkItemGID(projectId, issueIid);
2319
+ const { workItemGID: parentGID } = await resolveWorkItemGID(parentProjectId, parentIssueIid);
2320
+ const data = await executeGraphQL(`mutation($id: WorkItemID!, $parentId: WorkItemID!) {
2321
+ workItemUpdate(input: { id: $id, hierarchyWidget: { parentId: $parentId } }) {
2322
+ workItem { id }
2323
+ errors
2324
+ }
2325
+ }`, { id: workItemGID, parentId: parentGID });
2326
+ if (data.workItemUpdate.errors?.length > 0) {
2327
+ throw new Error(`Failed to set parent: ${data.workItemUpdate.errors.join(", ")}`);
2328
+ }
2329
+ return { id: workItemGID, parentId: parentGID };
2330
+ }
2331
+ /**
2332
+ * Remove the parent from a work item.
2333
+ */
2334
+ async function removeIssueParent(projectId, issueIid) {
2335
+ const { workItemGID } = await resolveWorkItemGID(projectId, issueIid);
2336
+ const data = await executeGraphQL(`mutation($id: WorkItemID!) {
2337
+ workItemUpdate(input: { id: $id, hierarchyWidget: { parentId: null } }) {
2338
+ workItem { id }
2339
+ errors
2340
+ }
2341
+ }`, { id: workItemGID });
2342
+ if (data.workItemUpdate.errors?.length > 0) {
2343
+ throw new Error(`Failed to remove parent: ${data.workItemUpdate.errors.join(", ")}`);
2344
+ }
2345
+ }
2346
+ /**
2347
+ * List children of a work item (hierarchy widget).
2348
+ */
2349
+ async function listIssueChildren(projectId, issueIid) {
2350
+ projectId = decodeURIComponent(projectId);
2351
+ const effectiveProjectId = getEffectiveProjectId(projectId);
2352
+ // Get project path
2353
+ const projectUrl = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}`);
2354
+ const projectResponse = await fetch(projectUrl.toString(), {
2355
+ ...getFetchConfig(),
2356
+ });
2357
+ await handleGitLabError(projectResponse);
2358
+ const project = await projectResponse.json();
2359
+ const data = await executeGraphQL(`query($path: ID!, $iid: String!) {
2360
+ namespace(fullPath: $path) {
2361
+ workItem(iid: $iid) {
2362
+ id
2363
+ title
2364
+ widgets {
2365
+ __typename
2366
+ ... on WorkItemWidgetHierarchy {
2367
+ parent {
2368
+ id
2369
+ title
2370
+ webUrl
2371
+ workItemType { name }
2372
+ }
2373
+ children {
2374
+ nodes {
2375
+ id
2376
+ title
2377
+ state
2378
+ webUrl
2379
+ workItemType { name }
2380
+ }
2381
+ }
2382
+ }
2383
+ }
2384
+ }
2385
+ }
2386
+ }`, { path: project.path_with_namespace, iid: String(issueIid) });
2387
+ if (!data.namespace?.workItem) {
2388
+ throw new Error(`Work item #${issueIid} not found`);
2389
+ }
2390
+ // Extract hierarchy widget
2391
+ const hierarchyWidget = data.namespace.workItem.widgets?.find((w) => w.__typename === "WorkItemWidgetHierarchy");
2392
+ return {
2393
+ id: data.namespace.workItem.id,
2394
+ title: data.namespace.workItem.title,
2395
+ parent: hierarchyWidget?.parent || null,
2396
+ children: hierarchyWidget?.children?.nodes || [],
2397
+ };
2398
+ }
2399
+ /**
2400
+ * Add a child to a parent work item.
2401
+ */
2402
+ async function addIssueChild(projectId, issueIid, childProjectId, childIssueIid) {
2403
+ const { workItemGID: parentGID } = await resolveWorkItemGID(projectId, issueIid);
2404
+ const { workItemGID: childGID } = await resolveWorkItemGID(childProjectId, childIssueIid);
2405
+ const data = await executeGraphQL(`mutation($id: WorkItemID!, $childId: WorkItemID!) {
2406
+ workItemUpdate(input: { id: $id, hierarchyWidget: { childrenIds: [$childId] } }) {
2407
+ workItem { id }
2408
+ errors
2409
+ }
2410
+ }`, { id: parentGID, childId: childGID });
2411
+ if (data.workItemUpdate.errors?.length > 0) {
2412
+ throw new Error(`Failed to add child: ${data.workItemUpdate.errors.join(", ")}`);
2413
+ }
2414
+ return { parentId: parentGID, childId: childGID };
2415
+ }
2416
+ /**
2417
+ * Remove a child from a parent work item by setting the child's parent to null.
2418
+ */
2419
+ async function removeIssueChild(projectId, issueIid, childProjectId, childIssueIid) {
2420
+ // Removing a child is done by removing the parent from the child
2421
+ await removeIssueParent(childProjectId, childIssueIid);
2422
+ }
2423
+ // --- Work item status ---
2424
+ /**
2425
+ * List available statuses for a work item type in a project.
2426
+ * Requires Premium/Ultimate with configurable statuses enabled.
2427
+ */
2428
+ async function listIssueStatuses(projectId, workItemType = "issue") {
2429
+ projectId = decodeURIComponent(projectId);
2430
+ const effectiveProjectId = getEffectiveProjectId(projectId);
2431
+ // Get project path
2432
+ const projectUrl = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}`);
2433
+ const projectResponse = await fetch(projectUrl.toString(), {
2434
+ ...getFetchConfig(),
2435
+ });
2436
+ await handleGitLabError(projectResponse);
2437
+ const project = await projectResponse.json();
2438
+ const typeName = WORK_ITEM_TYPE_NAMES[workItemType] || "Issue";
2439
+ const data = await executeGraphQL(`query($path: ID!, $typeName: IssueType) {
2440
+ namespace(fullPath: $path) {
2441
+ workItemTypes(name: $typeName) {
2442
+ nodes {
2443
+ id
2444
+ name
2445
+ supportedConversionTypes { id name }
2446
+ widgetDefinitions {
2447
+ __typename
2448
+ ... on WorkItemWidgetDefinitionStatus {
2449
+ allowedStatuses {
2450
+ id
2451
+ name
2452
+ iconName
2453
+ color
2454
+ position
2455
+ }
2456
+ }
2457
+ ... on WorkItemWidgetDefinitionHierarchy {
2458
+ allowedChildTypes { nodes { id name } }
2459
+ allowedParentTypes { nodes { id name } }
2460
+ }
2461
+ }
2462
+ }
2463
+ }
2464
+ }
2465
+ }`, { path: project.path_with_namespace, typeName: typeName.replace(/ /g, "_").toUpperCase() });
2466
+ const typeNodes = data.namespace?.workItemTypes?.nodes;
2467
+ if (!typeNodes || typeNodes.length === 0) {
2468
+ throw new Error(`Work item type '${typeName}' not found in project`);
2469
+ }
2470
+ const typeNode = typeNodes[0];
2471
+ // Extract statuses from the status widget definition
2472
+ const statusWidget = typeNode.widgetDefinitions?.find((w) => w.__typename === "WorkItemWidgetDefinitionStatus");
2473
+ const statuses = statusWidget?.allowedStatuses || [];
2474
+ // Extract hierarchy info
2475
+ const hierarchyWidget = typeNode.widgetDefinitions?.find((w) => w.__typename === "WorkItemWidgetDefinitionHierarchy");
2476
+ const result = {
2477
+ work_item_type: typeNode.name,
2478
+ statuses_available: statuses.length > 0,
2479
+ statuses,
2480
+ };
2481
+ // Add supported conversion types
2482
+ const conversionTypes = typeNode.supportedConversionTypes || [];
2483
+ if (conversionTypes.length > 0) {
2484
+ result.supported_conversion_types = conversionTypes.map((t) => t.name);
2485
+ }
2486
+ // Add allowed child/parent types
2487
+ const childTypes = hierarchyWidget?.allowedChildTypes?.nodes || [];
2488
+ const parentTypes = hierarchyWidget?.allowedParentTypes?.nodes || [];
2489
+ if (childTypes.length > 0) {
2490
+ result.allowed_child_types = childTypes.map((t) => t.name);
2491
+ }
2492
+ if (parentTypes.length > 0) {
2493
+ result.allowed_parent_types = parentTypes.map((t) => t.name);
2494
+ }
2495
+ return result;
2496
+ }
2497
+ /**
2498
+ * List available custom field definitions for a work item type.
2499
+ */
2500
+ async function listCustomFieldDefinitions(projectId, workItemType = "issue") {
2501
+ const projectPath = await resolveProjectPath(projectId);
2502
+ const typeName = WORK_ITEM_TYPE_NAMES[workItemType] || "Issue";
2503
+ const data = await executeGraphQL(`query($path: ID!, $typeName: IssueType) {
2504
+ namespace(fullPath: $path) {
2505
+ workItemTypes(name: $typeName) {
2506
+ nodes {
2507
+ id
2508
+ name
2509
+ widgetDefinitions {
2510
+ __typename
2511
+ ... on WorkItemWidgetDefinitionCustomFields {
2512
+ customFieldValues {
2513
+ customField {
2514
+ id
2515
+ name
2516
+ fieldType
2517
+ selectOptions { id value }
2518
+ workItemTypes { id name }
2519
+ }
2520
+ }
2521
+ }
2522
+ }
2523
+ }
2524
+ }
2525
+ }
2526
+ }`, { path: projectPath, typeName: typeName.replace(/ /g, "_").toUpperCase() });
2527
+ const typeNodes = data.namespace?.workItemTypes?.nodes;
2528
+ if (!typeNodes || typeNodes.length === 0) {
2529
+ throw new Error(`Work item type '${typeName}' not found in project`);
2530
+ }
2531
+ const typeNode = typeNodes[0];
2532
+ const customFieldsWidget = typeNode.widgetDefinitions?.find((w) => w.__typename === "WorkItemWidgetDefinitionCustomFields");
2533
+ const fields = (customFieldsWidget?.customFieldValues || []).map((cfv) => {
2534
+ const cf = cfv.customField;
2535
+ const field = {
2536
+ id: cf?.id,
2537
+ name: cf?.name,
2538
+ type: cf?.fieldType,
2539
+ };
2540
+ const options = cf?.selectOptions || [];
2541
+ if (options.length > 0)
2542
+ field.selectOptions = options;
2543
+ const types = (cf?.workItemTypes || []).map((t) => t.name);
2544
+ if (types.length > 0)
2545
+ field.workItemTypes = types;
2546
+ return field;
2547
+ });
2548
+ return {
2549
+ work_item_type: typeNode.name,
2550
+ custom_fields: fields,
2551
+ };
2552
+ }
2553
+ /**
2554
+ * Move a work item to a different project.
2555
+ */
2556
+ async function moveWorkItem(projectId, iid, targetProjectId) {
2557
+ const projectPath = await resolveProjectPath(projectId);
2558
+ const targetPath = await resolveProjectPath(targetProjectId);
2559
+ const data = await executeGraphQL(`mutation($projectPath: ID!, $iid: String!, $targetProjectPath: ID!) {
2560
+ issueMove(input: { projectPath: $projectPath, iid: $iid, targetProjectPath: $targetProjectPath }) {
2561
+ issue { id iid webUrl }
2562
+ errors
2563
+ }
2564
+ }`, { projectPath: projectPath, iid: String(iid), targetProjectPath: targetPath });
2565
+ if (data.issueMove.errors?.length > 0) {
2566
+ throw new Error(`Failed to move work item: ${data.issueMove.errors.join(", ")}`);
2567
+ }
2568
+ return data.issueMove.issue;
2569
+ }
2570
+ /**
2571
+ * List notes/discussions on a work item.
2572
+ */
2573
+ async function listWorkItemNotes(projectId, iid, options = {}) {
2574
+ const projectPath = await resolveProjectPath(projectId);
2575
+ const data = await executeGraphQL(`query($path: ID!, $iid: String!, $pageSize: Int, $after: String, $sort: WorkItemDiscussionsSort) {
2576
+ namespace(fullPath: $path) {
2577
+ workItem(iid: $iid) {
2578
+ id
2579
+ widgets(onlyTypes: [NOTES]) {
2580
+ ... on WorkItemWidgetNotes {
2581
+ discussionLocked
2582
+ discussions(first: $pageSize, after: $after, filter: ALL_NOTES, sort: $sort) {
2583
+ pageInfo { hasNextPage endCursor }
2584
+ nodes {
2585
+ id
2586
+ resolved
2587
+ resolvable
2588
+ notes {
2589
+ nodes {
2590
+ id
2591
+ body
2592
+ system
2593
+ internal
2594
+ createdAt
2595
+ lastEditedAt
2596
+ author { username }
2597
+ }
2598
+ }
2599
+ }
2600
+ }
2601
+ }
2602
+ }
2603
+ }
2604
+ }
2605
+ }`, {
2606
+ path: projectPath,
2607
+ iid: String(iid),
2608
+ pageSize: options.page_size || 20,
2609
+ after: options.after || null,
2610
+ sort: options.sort || "CREATED_ASC",
2611
+ });
2612
+ const workItem = data.namespace?.workItem;
2613
+ if (!workItem) {
2614
+ throw new Error(`Work item #${iid} not found in project ${projectPath}`);
2615
+ }
2616
+ const notesWidget = workItem.widgets?.find((w) => w.discussions);
2617
+ const discussions = notesWidget?.discussions;
2618
+ // Flatten to lean output
2619
+ const items = (discussions?.nodes || []).map((d) => {
2620
+ const notes = (d.notes?.nodes || []).map((n) => {
2621
+ const note = {
2622
+ id: n.id,
2623
+ author: n.author?.username,
2624
+ body: n.body,
2625
+ createdAt: n.createdAt,
2626
+ };
2627
+ if (n.system)
2628
+ note.system = true;
2629
+ if (n.internal)
2630
+ note.internal = true;
2631
+ if (n.lastEditedAt)
2632
+ note.lastEditedAt = n.lastEditedAt;
2633
+ return note;
2634
+ });
2635
+ const discussion = { id: d.id, notes };
2636
+ if (d.resolved)
2637
+ discussion.resolved = true;
2638
+ if (d.resolvable)
2639
+ discussion.resolvable = true;
2640
+ return discussion;
2641
+ });
2642
+ return {
2643
+ discussions: items,
2644
+ pageInfo: discussions?.pageInfo || {},
2645
+ };
2646
+ }
2647
+ /**
2648
+ * Create a note on a work item.
2649
+ */
2650
+ async function createWorkItemNote(projectId, iid, body, options = {}) {
2651
+ const { workItemGID } = await resolveWorkItemGID(projectId, iid);
2652
+ const varDefs = ["$noteableId: NoteableID!", "$body: String!"];
2653
+ const inputParts = ["noteableId: $noteableId", "body: $body"];
2654
+ const variables = { noteableId: workItemGID, body };
2655
+ if (options.internal) {
2656
+ varDefs.push("$internal: Boolean");
2657
+ inputParts.push("internal: $internal");
2658
+ variables.internal = true;
2659
+ }
2660
+ if (options.discussion_id) {
2661
+ varDefs.push("$discussionId: DiscussionID");
2662
+ inputParts.push("discussionId: $discussionId");
2663
+ variables.discussionId = options.discussion_id;
2664
+ }
2665
+ const data = await executeGraphQL(`mutation(${varDefs.join(", ")}) {
2666
+ createNote(input: { ${inputParts.join(", ")} }) {
2667
+ note {
2668
+ id
2669
+ body
2670
+ discussion { id }
2671
+ }
2672
+ errors
2673
+ }
2674
+ }`, variables);
2675
+ if (data.createNote.errors?.length > 0) {
2676
+ throw new Error(`Failed to create note: ${data.createNote.errors.join(", ")}`);
2677
+ }
2678
+ return data.createNote.note;
2679
+ }
2680
+ // --- Incident Timeline Events ---
2681
+ /**
2682
+ * List timeline events for an incident.
2683
+ */
2684
+ async function getTimelineEvents(projectId, incidentIid) {
2685
+ const { workItemGID, projectPath } = await resolveWorkItemGID(projectId, incidentIid);
2686
+ // Timeline events expect gid://gitlab/Issue/... not gid://gitlab/WorkItem/...
2687
+ const incidentGID = workItemGID.replace("/WorkItem/", "/Issue/");
2688
+ const data = await executeGraphQL(`query($fullPath: ID!, $incidentId: IssueID!) {
2689
+ project(fullPath: $fullPath) {
2690
+ incidentManagementTimelineEvents(incidentId: $incidentId) {
2691
+ nodes {
2692
+ id
2693
+ note
2694
+ noteHtml
2695
+ action
2696
+ occurredAt
2697
+ createdAt
2698
+ timelineEventTags {
2699
+ nodes {
2700
+ id
2701
+ name
2702
+ }
2703
+ }
2704
+ }
2705
+ }
2706
+ }
2707
+ }`, { fullPath: projectPath, incidentId: incidentGID });
2708
+ const events = data.project?.incidentManagementTimelineEvents?.nodes || [];
2709
+ return events.map((e) => {
2710
+ const event = {
2711
+ id: e.id,
2712
+ note: e.note,
2713
+ action: e.action,
2714
+ occurredAt: e.occurredAt,
2715
+ createdAt: e.createdAt,
2716
+ };
2717
+ if (e.noteHtml)
2718
+ event.noteHtml = e.noteHtml;
2719
+ const tags = (e.timelineEventTags?.nodes || []).map((t) => t.name);
2720
+ if (tags.length > 0)
2721
+ event.tags = tags;
2722
+ return event;
2723
+ });
2724
+ }
2725
+ /**
2726
+ * Create a timeline event on an incident.
2727
+ */
2728
+ async function createTimelineEvent(projectId, incidentIid, note, occurredAt, tagNames) {
2729
+ const { workItemGID } = await resolveWorkItemGID(projectId, incidentIid);
2730
+ // Timeline events expect gid://gitlab/Issue/... not gid://gitlab/WorkItem/...
2731
+ const incidentGID = workItemGID.replace("/WorkItem/", "/Issue/");
2732
+ const variables = {
2733
+ input: {
2734
+ incidentId: incidentGID,
2735
+ note,
2736
+ occurredAt,
2737
+ },
2738
+ };
2739
+ if (tagNames && tagNames.length > 0) {
2740
+ variables.input.timelineEventTagNames = tagNames;
2741
+ }
2742
+ const data = await executeGraphQL(`mutation CreateTimelineEvent($input: TimelineEventCreateInput!) {
2743
+ timelineEventCreate(input: $input) {
2744
+ timelineEvent {
2745
+ id
2746
+ note
2747
+ noteHtml
2748
+ action
2749
+ occurredAt
2750
+ createdAt
2751
+ timelineEventTags {
2752
+ nodes {
2753
+ id
2754
+ name
2755
+ }
2756
+ }
2757
+ }
2758
+ errors
2759
+ }
2760
+ }`, variables);
2761
+ if (data.timelineEventCreate.errors?.length > 0) {
2762
+ throw new Error(`Failed to create timeline event: ${data.timelineEventCreate.errors.join(", ")}`);
2763
+ }
2764
+ const e = data.timelineEventCreate.timelineEvent;
2765
+ const result = {
2766
+ id: e.id,
2767
+ note: e.note,
2768
+ action: e.action,
2769
+ occurredAt: e.occurredAt,
2770
+ createdAt: e.createdAt,
2771
+ };
2772
+ if (e.noteHtml)
2773
+ result.noteHtml = e.noteHtml;
2774
+ const tags = (e.timelineEventTags?.nodes || []).map((t) => t.name);
2775
+ if (tags.length > 0)
2776
+ result.tags = tags;
2777
+ return result;
2778
+ }
2779
+ /**
2780
+ * Update the severity of an incident.
2781
+ * Accepts projectPath directly to avoid redundant REST calls when called from updateWorkItem.
2782
+ */
2783
+ async function updateIncidentSeverity(projectPath, incidentIid, severity) {
2784
+ const data = await executeGraphQL(`mutation($projectPath: ID!, $severity: IssuableSeverity!, $iid: String!) {
2785
+ issueSetSeverity(input: { iid: $iid, severity: $severity, projectPath: $projectPath }) {
2786
+ errors
2787
+ issue {
2788
+ iid
2789
+ id
2790
+ severity
2791
+ }
2792
+ }
2793
+ }`, { projectPath, severity, iid: String(incidentIid) });
2794
+ if (data.issueSetSeverity.errors?.length > 0) {
2795
+ throw new Error(`Failed to set severity: ${data.issueSetSeverity.errors.join(", ")}`);
2796
+ }
2797
+ return data.issueSetSeverity.issue;
2798
+ }
2799
+ /**
2800
+ * Update the escalation status of an incident.
2801
+ * Accepts projectPath directly to avoid redundant REST calls when called from updateWorkItem.
2802
+ */
2803
+ async function updateIncidentEscalationStatus(projectPath, incidentIid, status) {
2804
+ const data = await executeGraphQL(`mutation($projectPath: ID!, $status: IssueEscalationStatus!, $iid: String!) {
2805
+ issueSetEscalationStatus(input: { projectPath: $projectPath, status: $status, iid: $iid }) {
2806
+ errors
2807
+ issue {
2808
+ id
2809
+ escalationStatus
2810
+ }
2811
+ }
2812
+ }`, { projectPath, status, iid: String(incidentIid) });
2813
+ if (data.issueSetEscalationStatus.errors?.length > 0) {
2814
+ throw new Error(`Failed to set escalation status: ${data.issueSetEscalationStatus.errors.join(", ")}`);
2815
+ }
2816
+ return data.issueSetEscalationStatus.issue;
2817
+ }
2818
+ /**
2819
+ * Set the status of a work item.
2820
+ */
2821
+ async function setIssueStatus(projectId, issueIid, status) {
2822
+ const { workItemGID } = await resolveWorkItemGID(projectId, issueIid);
2823
+ const data = await executeGraphQL(`mutation($id: WorkItemID!, $status: WorkItemsStatusesStatusID!) {
2824
+ workItemUpdate(input: { id: $id, statusWidget: { status: $status } }) {
2825
+ workItem {
2826
+ id
2827
+ widgets {
2828
+ __typename
2829
+ ... on WorkItemWidgetStatus {
2830
+ status { id name category color }
2831
+ }
2832
+ }
2833
+ }
2834
+ errors
2835
+ }
2836
+ }`, { id: workItemGID, status });
2837
+ if (data.workItemUpdate.errors?.length > 0) {
2838
+ throw new Error(`Failed to set status: ${data.workItemUpdate.errors.join(", ")}`);
2839
+ }
2840
+ // Extract the current status from the response
2841
+ const statusWidget = data.workItemUpdate.workItem?.widgets?.find((w) => w.__typename === "WorkItemWidgetStatus");
2842
+ return {
2843
+ id: data.workItemUpdate.workItem.id,
2844
+ status: statusWidget?.status || null,
2845
+ };
2846
+ }
2847
+ /**
2848
+ * Resolve a project ID (numeric or path) to its full path_with_namespace.
2849
+ */
2850
+ async function resolveProjectPath(projectId) {
2851
+ projectId = decodeURIComponent(projectId);
2852
+ const effectiveProjectId = getEffectiveProjectId(projectId);
2853
+ const projectUrl = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}`);
2854
+ const projectResponse = await fetch(projectUrl.toString(), {
2855
+ ...getFetchConfig(),
2856
+ });
2857
+ await handleGitLabError(projectResponse);
2858
+ const project = await projectResponse.json();
2859
+ return project.path_with_namespace;
2860
+ }
2861
+ /**
2862
+ * Get a single work item with all widget data.
2863
+ */
2864
+ async function getWorkItem(projectId, iid) {
2865
+ const projectPath = await resolveProjectPath(projectId);
2866
+ const data = await executeGraphQL(`query($path: ID!, $iid: String!) {
2867
+ namespace(fullPath: $path) {
2868
+ workItem(iid: $iid) {
2869
+ id
2870
+ iid
2871
+ title
2872
+ state
2873
+ description
2874
+ webUrl
2875
+ confidential
2876
+ author { username }
2877
+ createdAt
2878
+ closedAt
2879
+ workItemType { name }
2880
+ widgets {
2881
+ __typename
2882
+ ... on WorkItemWidgetHierarchy {
2883
+ hasChildren hasParent
2884
+ parent { id iid title webUrl workItemType { name } namespace { fullPath } }
2885
+ children { nodes { id iid title state webUrl workItemType { name } namespace { fullPath } } }
2886
+ }
2887
+ ... on WorkItemWidgetStatus { status { id name category color iconName position } }
2888
+ ... on WorkItemWidgetCustomFields {
2889
+ customFieldValues {
2890
+ __typename
2891
+ customField { id name fieldType }
2892
+ ... on WorkItemNumberFieldValue { value }
2893
+ ... on WorkItemTextFieldValue { value }
2894
+ ... on WorkItemSelectFieldValue {
2895
+ selectedOptions { id value }
2896
+ }
2897
+ }
2898
+ }
2899
+ ... on WorkItemWidgetLabels { labels { nodes { id title color } } }
2900
+ ... on WorkItemWidgetAssignees { assignees { nodes { id username name } } }
2901
+ ... on WorkItemWidgetWeight { weight rolledUpWeight rolledUpCompletedWeight }
2902
+ ... on WorkItemWidgetHealthStatus { healthStatus }
2903
+ ... on WorkItemWidgetStartAndDueDate { startDate dueDate }
2904
+ ... on WorkItemWidgetMilestone { milestone { id title } }
2905
+ ... on WorkItemWidgetLinkedItems {
2906
+ blocked blockedByCount blockingCount
2907
+ linkedItems { nodes { linkType workItem { id iid title state webUrl workItemType { name } namespace { fullPath } } } }
2908
+ }
2909
+ ... on WorkItemWidgetTimeTracking {
2910
+ timeEstimate totalTimeSpent
2911
+ }
2912
+ ... on WorkItemWidgetDevelopment {
2913
+ willAutoCloseByMergeRequest
2914
+ relatedBranches { nodes { name } }
2915
+ relatedMergeRequests {
2916
+ nodes { iid title webUrl state sourceBranch }
2917
+ }
2918
+ closingMergeRequests {
2919
+ nodes {
2920
+ mergeRequest { iid title webUrl state sourceBranch }
2921
+ }
2922
+ }
2923
+ featureFlags { nodes { name active } }
2924
+ }
2925
+ ... on WorkItemWidgetIteration {
2926
+ iteration { id title startDate dueDate webUrl iterationCadence { id title } }
2927
+ }
2928
+ ... on WorkItemWidgetProgress { progress }
2929
+ ... on WorkItemWidgetColor { color textColor }
2930
+ }
2931
+ }
2932
+ }
2933
+ }`, { path: projectPath, iid: String(iid) });
2934
+ if (!data.namespace?.workItem) {
2935
+ throw new Error(`Work item #${iid} not found in project ${projectPath}`);
2936
+ }
2937
+ const wi = data.namespace.workItem;
2938
+ const widgets = wi.widgets || [];
2939
+ // Flatten widget data into a clean response
2940
+ const hierarchyWidget = widgets.find((w) => w.__typename === "WorkItemWidgetHierarchy");
2941
+ const statusWidget = widgets.find((w) => w.__typename === "WorkItemWidgetStatus");
2942
+ const labelsWidget = widgets.find((w) => w.__typename === "WorkItemWidgetLabels");
2943
+ const assigneesWidget = widgets.find((w) => w.__typename === "WorkItemWidgetAssignees");
2944
+ const weightWidget = widgets.find((w) => w.__typename === "WorkItemWidgetWeight");
2945
+ const healthStatusWidget = widgets.find((w) => w.__typename === "WorkItemWidgetHealthStatus");
2946
+ const datesWidget = widgets.find((w) => w.__typename === "WorkItemWidgetStartAndDueDate");
2947
+ const milestoneWidget = widgets.find((w) => w.__typename === "WorkItemWidgetMilestone");
2948
+ const linkedItemsWidget = widgets.find((w) => w.__typename === "WorkItemWidgetLinkedItems");
2949
+ const timeTrackingWidget = widgets.find((w) => w.__typename === "WorkItemWidgetTimeTracking");
2950
+ const developmentWidget = widgets.find((w) => w.__typename === "WorkItemWidgetDevelopment");
2951
+ const customFieldsWidget = widgets.find((w) => w.__typename === "WorkItemWidgetCustomFields");
2952
+ // Build response, omitting null/empty values to keep output lean
2953
+ const result = {
2954
+ id: wi.id,
2955
+ iid: wi.iid,
2956
+ title: wi.title,
2957
+ state: wi.state,
2958
+ type: wi.workItemType?.name,
2959
+ webUrl: wi.webUrl,
2960
+ };
2961
+ if (wi.description)
2962
+ result.description = wi.description;
2963
+ if (wi.confidential)
2964
+ result.confidential = true;
2965
+ if (wi.author?.username)
2966
+ result.author = wi.author.username;
2967
+ if (wi.createdAt)
2968
+ result.createdAt = wi.createdAt;
2969
+ if (wi.closedAt)
2970
+ result.closedAt = wi.closedAt;
2971
+ if (statusWidget?.status)
2972
+ result.status = { name: statusWidget.status.name, id: statusWidget.status.id, category: statusWidget.status.category };
2973
+ const labels = (labelsWidget?.labels?.nodes || []).map((l) => l.title);
2974
+ if (labels.length > 0)
2975
+ result.labels = labels;
2976
+ const assignees = (assigneesWidget?.assignees?.nodes || []).map((a) => a.username);
2977
+ if (assignees.length > 0)
2978
+ result.assignees = assignees;
2979
+ if (weightWidget?.weight != null) {
2980
+ result.weight = weightWidget.weight;
2981
+ if (weightWidget.rolledUpWeight != null)
2982
+ result.rolledUpWeight = weightWidget.rolledUpWeight;
2983
+ if (weightWidget.rolledUpCompletedWeight != null)
2984
+ result.rolledUpCompletedWeight = weightWidget.rolledUpCompletedWeight;
2985
+ }
2986
+ if (healthStatusWidget?.healthStatus)
2987
+ result.healthStatus = healthStatusWidget.healthStatus;
2988
+ if (datesWidget?.startDate)
2989
+ result.startDate = datesWidget.startDate;
2990
+ if (datesWidget?.dueDate)
2991
+ result.dueDate = datesWidget.dueDate;
2992
+ if (milestoneWidget?.milestone)
2993
+ result.milestone = { id: milestoneWidget.milestone.id, title: milestoneWidget.milestone.title };
2994
+ const iterationWidget = widgets.find((w) => w.__typename === "WorkItemWidgetIteration");
2995
+ if (iterationWidget?.iteration) {
2996
+ result.iteration = {
2997
+ id: iterationWidget.iteration.id,
2998
+ title: iterationWidget.iteration.title,
2999
+ startDate: iterationWidget.iteration.startDate,
3000
+ dueDate: iterationWidget.iteration.dueDate,
3001
+ };
3002
+ }
3003
+ const progressWidget = widgets.find((w) => w.__typename === "WorkItemWidgetProgress");
3004
+ if (progressWidget?.progress != null)
3005
+ result.progress = progressWidget.progress;
3006
+ const colorWidget = widgets.find((w) => w.__typename === "WorkItemWidgetColor");
3007
+ if (colorWidget?.color)
3008
+ result.color = colorWidget.color;
3009
+ if (hierarchyWidget?.parent)
3010
+ result.parent = { iid: hierarchyWidget.parent.iid, title: hierarchyWidget.parent.title, type: hierarchyWidget.parent.workItemType?.name, project: hierarchyWidget.parent.namespace?.fullPath, webUrl: hierarchyWidget.parent.webUrl };
3011
+ const children = hierarchyWidget?.children?.nodes || [];
3012
+ if (children.length > 0)
3013
+ 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 }));
3014
+ if (linkedItemsWidget?.blocked)
3015
+ result.blocked = true;
3016
+ if (linkedItemsWidget?.blockedByCount > 0)
3017
+ result.blockedByCount = linkedItemsWidget.blockedByCount;
3018
+ if (linkedItemsWidget?.blockingCount > 0)
3019
+ result.blockingCount = linkedItemsWidget.blockingCount;
3020
+ const linkedNodes = linkedItemsWidget?.linkedItems?.nodes || [];
3021
+ if (linkedNodes.length > 0) {
3022
+ result.linkedItems = linkedNodes.map((n) => ({
3023
+ linkType: n.linkType,
3024
+ iid: n.workItem?.iid,
3025
+ title: n.workItem?.title,
3026
+ state: n.workItem?.state,
3027
+ type: n.workItem?.workItemType?.name,
3028
+ project: n.workItem?.namespace?.fullPath,
3029
+ webUrl: n.workItem?.webUrl,
3030
+ }));
3031
+ }
3032
+ if (timeTrackingWidget?.timeEstimate > 0)
3033
+ result.timeEstimate = timeTrackingWidget.timeEstimate;
3034
+ if (timeTrackingWidget?.totalTimeSpent > 0)
3035
+ result.totalTimeSpent = timeTrackingWidget.totalTimeSpent;
3036
+ // Development: only include if there's actual data
3037
+ const relatedMRs = developmentWidget?.relatedMergeRequests?.nodes || [];
3038
+ const closingMRs = (developmentWidget?.closingMergeRequests?.nodes || []).map((n) => n.mergeRequest);
3039
+ const branches = developmentWidget?.relatedBranches?.nodes || [];
3040
+ const flags = developmentWidget?.featureFlags?.nodes || [];
3041
+ if (relatedMRs.length > 0 || closingMRs.length > 0 || branches.length > 0 || flags.length > 0) {
3042
+ const dev = {};
3043
+ if (relatedMRs.length > 0)
3044
+ dev.relatedMergeRequests = relatedMRs;
3045
+ if (closingMRs.length > 0)
3046
+ dev.closingMergeRequests = closingMRs;
3047
+ if (branches.length > 0)
3048
+ dev.relatedBranches = branches.map((b) => b.name);
3049
+ if (flags.length > 0)
3050
+ dev.featureFlags = flags;
3051
+ result.development = dev;
3052
+ }
3053
+ const cfValues = (customFieldsWidget?.customFieldValues || []).filter((cfv) => cfv.value != null || cfv.selectedOptions != null);
3054
+ if (cfValues.length > 0) {
3055
+ result.customFields = cfValues.map((cfv) => ({
3056
+ name: cfv.customField?.name,
3057
+ type: cfv.customField?.fieldType,
3058
+ value: cfv.value ?? cfv.selectedOptions ?? null,
3059
+ }));
3060
+ }
3061
+ return result;
3062
+ }
3063
+ /**
3064
+ * List work items in a project with filters.
3065
+ */
3066
+ async function listWorkItems(projectId, options) {
3067
+ const projectPath = await resolveProjectPath(projectId);
3068
+ // Map type names to GraphQL enum values
3069
+ const typeMap = {
3070
+ issue: "ISSUE",
3071
+ task: "TASK",
3072
+ incident: "INCIDENT",
3073
+ test_case: "TEST_CASE",
3074
+ epic: "EPIC",
3075
+ key_result: "KEY_RESULT",
3076
+ objective: "OBJECTIVE",
3077
+ requirement: "REQUIREMENT",
3078
+ ticket: "TICKET",
3079
+ };
3080
+ const variables = {
3081
+ path: projectPath,
3082
+ first: options.first || 20,
3083
+ };
3084
+ if (options.types && options.types.length > 0) {
3085
+ variables.types = options.types.map((t) => typeMap[t] || t.replace(/ /g, "_").toUpperCase());
3086
+ }
3087
+ if (options.state) {
3088
+ variables.state = options.state === "opened" ? "opened" : "closed";
3089
+ }
3090
+ if (options.search) {
3091
+ variables.search = options.search;
3092
+ }
3093
+ if (options.assignee_usernames && options.assignee_usernames.length > 0) {
3094
+ variables.assigneeUsernames = options.assignee_usernames;
3095
+ }
3096
+ if (options.label_names && options.label_names.length > 0) {
3097
+ variables.labelName = options.label_names;
3098
+ }
3099
+ if (options.after) {
3100
+ variables.after = options.after;
3101
+ }
3102
+ const data = await executeGraphQL(`query($path: ID!, $types: [IssueType!], $state: IssuableState, $search: String, $assigneeUsernames: [String!], $labelName: [String!], $first: Int, $after: String) {
3103
+ project(fullPath: $path) {
3104
+ workItems(types: $types, state: $state, search: $search, assigneeUsernames: $assigneeUsernames, labelName: $labelName, first: $first, after: $after) {
3105
+ nodes {
3106
+ id iid title state webUrl workItemType { name }
3107
+ widgets {
3108
+ __typename
3109
+ ... on WorkItemWidgetStatus { status { id name category color } }
3110
+ ... on WorkItemWidgetLabels { labels { nodes { title } } }
3111
+ ... on WorkItemWidgetAssignees { assignees { nodes { username } } }
3112
+ ... on WorkItemWidgetWeight { weight }
3113
+ ... on WorkItemWidgetHealthStatus { healthStatus }
3114
+ ... on WorkItemWidgetStartAndDueDate { startDate dueDate }
3115
+ ... on WorkItemWidgetMilestone { milestone { id title } }
3116
+ }
3117
+ }
3118
+ pageInfo { hasNextPage endCursor }
3119
+ }
3120
+ }
3121
+ }`, variables);
3122
+ const workItems = data.project?.workItems?.nodes || [];
3123
+ const pageInfo = data.project?.workItems?.pageInfo || {};
3124
+ // Flatten widget data for each item
3125
+ const items = workItems.map((wi) => {
3126
+ const widgets = wi.widgets || [];
3127
+ const statusWidget = widgets.find((w) => w.__typename === "WorkItemWidgetStatus");
3128
+ const labelsWidget = widgets.find((w) => w.__typename === "WorkItemWidgetLabels");
3129
+ const assigneesWidget = widgets.find((w) => w.__typename === "WorkItemWidgetAssignees");
3130
+ const weightWidget = widgets.find((w) => w.__typename === "WorkItemWidgetWeight");
3131
+ const healthStatusWidget = widgets.find((w) => w.__typename === "WorkItemWidgetHealthStatus");
3132
+ const datesWidget = widgets.find((w) => w.__typename === "WorkItemWidgetStartAndDueDate");
3133
+ const milestoneWidget = widgets.find((w) => w.__typename === "WorkItemWidgetMilestone");
3134
+ const item = {
3135
+ iid: wi.iid,
3136
+ title: wi.title,
3137
+ state: wi.state,
3138
+ type: wi.workItemType?.name,
3139
+ webUrl: wi.webUrl,
3140
+ };
3141
+ if (statusWidget?.status)
3142
+ item.status = statusWidget.status.name;
3143
+ const labels = (labelsWidget?.labels?.nodes || []).map((l) => l.title);
3144
+ if (labels.length > 0)
3145
+ item.labels = labels;
3146
+ const assignees = (assigneesWidget?.assignees?.nodes || []).map((a) => a.username);
3147
+ if (assignees.length > 0)
3148
+ item.assignees = assignees;
3149
+ if (weightWidget?.weight != null)
3150
+ item.weight = weightWidget.weight;
3151
+ if (healthStatusWidget?.healthStatus)
3152
+ item.healthStatus = healthStatusWidget.healthStatus;
3153
+ if (datesWidget?.startDate)
3154
+ item.startDate = datesWidget.startDate;
3155
+ if (datesWidget?.dueDate)
3156
+ item.dueDate = datesWidget.dueDate;
3157
+ if (milestoneWidget?.milestone)
3158
+ item.milestone = milestoneWidget.milestone.title;
3159
+ return item;
3160
+ });
3161
+ return { items, pageInfo };
3162
+ }
3163
+ /**
3164
+ * Create a new work item using GraphQL.
3165
+ */
3166
+ async function createWorkItem(projectId, options) {
3167
+ const projectPath = await resolveProjectPath(projectId);
3168
+ const typeName = options.type || "issue";
3169
+ const typeGID = await resolveWorkItemTypeGID(projectPath, typeName);
3170
+ // Build the input dynamically - only include widgets that have values
3171
+ const inputFields = [
3172
+ "$projectPath: ID!",
3173
+ "$title: String!",
3174
+ "$typeId: WorkItemsTypeID!",
3175
+ ];
3176
+ const inputValues = [
3177
+ "namespacePath: $projectPath",
3178
+ "title: $title",
3179
+ "workItemTypeId: $typeId",
3180
+ ];
3181
+ const variables = {
3182
+ projectPath,
3183
+ title: options.title,
3184
+ typeId: typeGID,
3185
+ };
3186
+ if (options.description !== undefined) {
3187
+ inputFields.push("$description: String!");
3188
+ inputValues.push("descriptionWidget: { description: $description }");
3189
+ variables.description = options.description;
3190
+ }
3191
+ // Resolve label names and usernames to GIDs in a single GraphQL call
3192
+ const { labelIds, userIds } = await resolveNamesToIds(projectPath, options.labels, options.assignee_usernames);
3193
+ if (labelIds.length > 0) {
3194
+ inputFields.push("$labelIds: [LabelID!]!");
3195
+ inputValues.push("labelsWidget: { labelIds: $labelIds }");
3196
+ variables.labelIds = labelIds;
3197
+ }
3198
+ if (options.weight !== undefined) {
3199
+ inputFields.push("$weight: Int");
3200
+ inputValues.push("weightWidget: { weight: $weight }");
3201
+ variables.weight = options.weight;
3202
+ }
3203
+ // Resolve parent GID if provided
3204
+ if (options.parent_iid !== undefined) {
3205
+ const { workItemGID: parentGID } = await resolveWorkItemGID(projectId, options.parent_iid);
3206
+ inputFields.push("$parentId: WorkItemID");
3207
+ inputValues.push("hierarchyWidget: { parentId: $parentId }");
3208
+ variables.parentId = parentGID;
3209
+ }
3210
+ if (userIds.length > 0) {
3211
+ inputFields.push("$assigneeIds: [UserID!]!");
3212
+ inputValues.push("assigneesWidget: { assigneeIds: $assigneeIds }");
3213
+ variables.assigneeIds = userIds;
3214
+ }
3215
+ if (options.health_status !== undefined) {
3216
+ inputFields.push("$healthStatus: HealthStatus");
3217
+ inputValues.push("healthStatusWidget: { healthStatus: $healthStatus }");
3218
+ variables.healthStatus = options.health_status;
3219
+ }
3220
+ // Start and due date widget - combine into one widget
3221
+ if (options.start_date !== undefined || options.due_date !== undefined) {
3222
+ const dateParts = [];
3223
+ if (options.start_date !== undefined) {
3224
+ inputFields.push("$startDate: Date");
3225
+ dateParts.push("startDate: $startDate");
3226
+ variables.startDate = options.start_date;
3227
+ }
3228
+ if (options.due_date !== undefined) {
3229
+ inputFields.push("$dueDate: Date");
3230
+ dateParts.push("dueDate: $dueDate");
3231
+ variables.dueDate = options.due_date;
3232
+ }
3233
+ inputValues.push(`startAndDueDateWidget: { ${dateParts.join(", ")} }`);
3234
+ }
3235
+ if (options.milestone_id !== undefined) {
3236
+ // Convert numeric ID to GID format if needed
3237
+ const milestoneGID = options.milestone_id.startsWith("gid://")
3238
+ ? options.milestone_id
3239
+ : `gid://gitlab/Milestone/${options.milestone_id}`;
3240
+ inputFields.push("$milestoneId: MilestoneID");
3241
+ inputValues.push("milestoneWidget: { milestoneId: $milestoneId }");
3242
+ variables.milestoneId = milestoneGID;
3243
+ }
3244
+ if (options.iteration_id !== undefined) {
3245
+ const iterationGID = options.iteration_id.startsWith("gid://")
3246
+ ? options.iteration_id
3247
+ : `gid://gitlab/Iteration/${options.iteration_id}`;
3248
+ inputFields.push("$iterationId: IterationID");
3249
+ inputValues.push("iterationWidget: { iterationId: $iterationId }");
3250
+ variables.iterationId = iterationGID;
3251
+ }
3252
+ if (options.confidential !== undefined) {
3253
+ inputFields.push("$confidential: Boolean");
3254
+ inputValues.push("confidential: $confidential");
3255
+ variables.confidential = options.confidential;
3256
+ }
3257
+ const mutation = `mutation(${inputFields.join(", ")}) {
3258
+ workItemCreate(input: { ${inputValues.join(", ")} }) {
3259
+ workItem {
3260
+ id
3261
+ iid
3262
+ title
3263
+ webUrl
3264
+ workItemType { name }
3265
+ }
3266
+ errors
3267
+ }
3268
+ }`;
3269
+ const data = await executeGraphQL(mutation, variables);
3270
+ if (data.workItemCreate.errors?.length > 0) {
3271
+ throw new Error(`Failed to create work item: ${data.workItemCreate.errors.join(", ")}`);
3272
+ }
3273
+ const wi = data.workItemCreate.workItem;
3274
+ return {
3275
+ id: wi.id,
3276
+ iid: wi.iid,
3277
+ title: wi.title,
3278
+ type: wi.workItemType?.name,
3279
+ webUrl: wi.webUrl,
3280
+ };
3281
+ }
3282
+ /**
3283
+ * Update a work item - consolidated handler for title, description, labels, assignees,
3284
+ * weight, state, status, parent, and children operations.
3285
+ */
3286
+ async function updateWorkItem(projectId, iid, options) {
3287
+ const { workItemGID, projectPath } = await resolveWorkItemGID(projectId, iid);
3288
+ // Build the main workItemUpdate mutation dynamically
3289
+ const inputParts = ["id: $id"];
3290
+ const varDefs = ["$id: WorkItemID!"];
3291
+ const variables = { id: workItemGID };
3292
+ if (options.title !== undefined) {
3293
+ varDefs.push("$title: String");
3294
+ inputParts.push("title: $title");
3295
+ variables.title = options.title;
3296
+ }
3297
+ if (options.description !== undefined) {
3298
+ varDefs.push("$description: String!");
3299
+ inputParts.push("descriptionWidget: { description: $description }");
3300
+ variables.description = options.description;
3301
+ }
3302
+ // Resolve label names and usernames to GIDs in a single GraphQL call
3303
+ const allLabelNames = [...(options.add_labels || []), ...(options.remove_labels || [])];
3304
+ const needsResolve = allLabelNames.length > 0 || options.assignee_usernames?.length;
3305
+ const { labelIds: resolvedLabelIds, userIds } = needsResolve
3306
+ ? await resolveNamesToIds(projectPath, allLabelNames.length > 0 ? allLabelNames : undefined, options.assignee_usernames)
3307
+ : { labelIds: [], userIds: [] };
3308
+ if (options.add_labels || options.remove_labels) {
3309
+ const labelParts = [];
3310
+ let offset = 0;
3311
+ if (options.add_labels && options.add_labels.length > 0) {
3312
+ const addIds = resolvedLabelIds.slice(0, options.add_labels.length);
3313
+ offset = options.add_labels.length;
3314
+ varDefs.push("$addLabelIds: [LabelID!]");
3315
+ labelParts.push("addLabelIds: $addLabelIds");
3316
+ variables.addLabelIds = addIds;
3317
+ }
3318
+ if (options.remove_labels && options.remove_labels.length > 0) {
3319
+ const removeIds = resolvedLabelIds.slice(offset);
3320
+ varDefs.push("$removeLabelIds: [LabelID!]");
3321
+ labelParts.push("removeLabelIds: $removeLabelIds");
3322
+ variables.removeLabelIds = removeIds;
3323
+ }
3324
+ if (labelParts.length > 0) {
3325
+ inputParts.push(`labelsWidget: { ${labelParts.join(", ")} }`);
3326
+ }
3327
+ }
3328
+ if (userIds.length > 0) {
3329
+ varDefs.push("$assigneeIds: [UserID!]!");
3330
+ inputParts.push("assigneesWidget: { assigneeIds: $assigneeIds }");
3331
+ variables.assigneeIds = userIds;
3332
+ }
3333
+ if (options.state_event !== undefined) {
3334
+ varDefs.push("$stateEvent: WorkItemStateEvent");
3335
+ inputParts.push("stateEvent: $stateEvent");
3336
+ variables.stateEvent = options.state_event === "close" ? "CLOSE" : "REOPEN";
3337
+ }
3338
+ if (options.weight !== undefined) {
3339
+ varDefs.push("$weight: Int");
3340
+ inputParts.push("weightWidget: { weight: $weight }");
3341
+ variables.weight = options.weight;
3342
+ }
3343
+ if (options.status !== undefined) {
3344
+ varDefs.push("$status: WorkItemsStatusesStatusID");
3345
+ inputParts.push("statusWidget: { status: $status }");
3346
+ variables.status = options.status;
3347
+ }
3348
+ if (options.health_status !== undefined) {
3349
+ varDefs.push("$healthStatus: HealthStatus");
3350
+ inputParts.push("healthStatusWidget: { healthStatus: $healthStatus }");
3351
+ variables.healthStatus = options.health_status;
3352
+ }
3353
+ // Start and due date widget - combine into one widget
3354
+ if (options.start_date !== undefined || options.due_date !== undefined) {
3355
+ const dateParts = [];
3356
+ if (options.start_date !== undefined) {
3357
+ varDefs.push("$startDate: Date");
3358
+ dateParts.push("startDate: $startDate");
3359
+ variables.startDate = options.start_date;
3360
+ }
3361
+ if (options.due_date !== undefined) {
3362
+ varDefs.push("$dueDate: Date");
3363
+ dateParts.push("dueDate: $dueDate");
3364
+ variables.dueDate = options.due_date;
3365
+ }
3366
+ inputParts.push(`startAndDueDateWidget: { ${dateParts.join(", ")} }`);
3367
+ }
3368
+ if (options.milestone_id !== undefined) {
3369
+ // Convert numeric ID to GID format if needed
3370
+ const milestoneGID = options.milestone_id.startsWith("gid://")
3371
+ ? options.milestone_id
3372
+ : `gid://gitlab/Milestone/${options.milestone_id}`;
3373
+ varDefs.push("$milestoneId: MilestoneID");
3374
+ inputParts.push("milestoneWidget: { milestoneId: $milestoneId }");
3375
+ variables.milestoneId = milestoneGID;
3376
+ }
3377
+ if (options.iteration_id !== undefined) {
3378
+ const iterationGID = options.iteration_id.startsWith("gid://")
3379
+ ? options.iteration_id
3380
+ : `gid://gitlab/Iteration/${options.iteration_id}`;
3381
+ varDefs.push("$iterationId: IterationID");
3382
+ inputParts.push("iterationWidget: { iterationId: $iterationId }");
3383
+ variables.iterationId = iterationGID;
3384
+ }
3385
+ if (options.confidential !== undefined) {
3386
+ varDefs.push("$confidential: Boolean");
3387
+ inputParts.push("confidential: $confidential");
3388
+ variables.confidential = options.confidential;
3389
+ }
3390
+ // Custom fields widget
3391
+ if (options.custom_fields && options.custom_fields.length > 0) {
3392
+ const cfValues = options.custom_fields.map(cf => {
3393
+ const cfId = cf.custom_field_id.startsWith("gid://")
3394
+ ? cf.custom_field_id
3395
+ : `gid://gitlab/IssuablesCustomField/${cf.custom_field_id}`;
3396
+ const val = { customFieldId: cfId };
3397
+ if (cf.text_value !== undefined)
3398
+ val.textValue = cf.text_value;
3399
+ if (cf.number_value !== undefined)
3400
+ val.numberValue = cf.number_value;
3401
+ if (cf.selected_option_ids !== undefined)
3402
+ val.selectedOptionIds = cf.selected_option_ids;
3403
+ if (cf.date_value !== undefined)
3404
+ val.dateValue = cf.date_value;
3405
+ return val;
3406
+ });
3407
+ varDefs.push("$customFieldsWidget: [WorkItemWidgetCustomFieldValueInputType!]");
3408
+ inputParts.push("customFieldsWidget: $customFieldsWidget");
3409
+ variables.customFieldsWidget = cfValues;
3410
+ }
3411
+ // Hierarchy: set parent or remove parent
3412
+ if (options.remove_parent) {
3413
+ inputParts.push("hierarchyWidget: { parentId: null }");
3414
+ }
3415
+ else if (options.parent_iid !== undefined) {
3416
+ const parentProjectId = options.parent_project_id || projectId;
3417
+ const { workItemGID: parentGID } = await resolveWorkItemGID(parentProjectId, options.parent_iid);
3418
+ varDefs.push("$parentId: WorkItemID");
3419
+ inputParts.push("hierarchyWidget: { parentId: $parentId }");
3420
+ variables.parentId = parentGID;
3421
+ }
3422
+ // Execute the main update mutation
3423
+ const mutation = `mutation(${varDefs.join(", ")}) {
3424
+ workItemUpdate(input: { ${inputParts.join(", ")} }) {
3425
+ workItem {
3426
+ id
3427
+ iid
3428
+ title
3429
+ state
3430
+ webUrl
3431
+ workItemType { name }
3432
+ widgets {
3433
+ __typename
3434
+ ... on WorkItemWidgetStatus { status { id name category color } }
3435
+ ... on WorkItemWidgetLabels { labels { nodes { title } } }
3436
+ ... on WorkItemWidgetAssignees { assignees { nodes { username } } }
3437
+ ... on WorkItemWidgetWeight { weight }
3438
+ ... on WorkItemWidgetHierarchy {
3439
+ parent { id title workItemType { name } }
3440
+ }
3441
+ ... on WorkItemWidgetHealthStatus { healthStatus }
3442
+ ... on WorkItemWidgetStartAndDueDate { startDate dueDate }
3443
+ ... on WorkItemWidgetMilestone { milestone { id title } }
3444
+ }
3445
+ }
3446
+ errors
3447
+ }
3448
+ }`;
3449
+ const data = await executeGraphQL(mutation, variables);
3450
+ if (data.workItemUpdate.errors?.length > 0) {
3451
+ throw new Error(`Failed to update work item: ${data.workItemUpdate.errors.join(", ")}`);
3452
+ }
3453
+ // Handle children_to_add: use separate workItemUpdate call with hierarchyWidget.childrenIds
3454
+ if (options.children_to_add && options.children_to_add.length > 0) {
3455
+ const childGIDs = [];
3456
+ for (const child of options.children_to_add) {
3457
+ const { workItemGID: childGID } = await resolveWorkItemGID(child.project_id, child.iid);
3458
+ childGIDs.push(childGID);
3459
+ }
3460
+ const addData = await executeGraphQL(`mutation($id: WorkItemID!, $childrenIds: [WorkItemID!]!) {
3461
+ workItemUpdate(input: { id: $id, hierarchyWidget: { childrenIds: $childrenIds } }) {
3462
+ errors
3463
+ }
3464
+ }`, { id: workItemGID, childrenIds: childGIDs });
3465
+ if (addData.workItemUpdate.errors?.length > 0) {
3466
+ throw new Error(`Failed to add children: ${addData.workItemUpdate.errors.join(", ")}`);
3467
+ }
3468
+ }
3469
+ // Handle children_to_remove: remove parent from each child
3470
+ if (options.children_to_remove && options.children_to_remove.length > 0) {
3471
+ for (const child of options.children_to_remove) {
3472
+ await removeIssueParent(child.project_id, child.iid);
3473
+ }
3474
+ }
3475
+ // Handle linked_items_to_add: use workItemAddLinkedItems mutation
3476
+ if (options.linked_items_to_add && options.linked_items_to_add.length > 0) {
3477
+ // Group by link_type since each mutation call needs a single linkType
3478
+ const groupedByType = {};
3479
+ for (const item of options.linked_items_to_add) {
3480
+ const linkType = item.link_type || "RELATED";
3481
+ if (!groupedByType[linkType])
3482
+ groupedByType[linkType] = [];
3483
+ const { workItemGID: targetGID } = await resolveWorkItemGID(item.project_id, item.iid);
3484
+ groupedByType[linkType].push(targetGID);
3485
+ }
3486
+ for (const [linkType, targetGIDs] of Object.entries(groupedByType)) {
3487
+ const addLinkedData = await executeGraphQL(`mutation($id: WorkItemID!, $workItemsIds: [WorkItemID!]!, $linkType: WorkItemRelatedLinkType!) {
3488
+ workItemAddLinkedItems(input: { id: $id, workItemsIds: $workItemsIds, linkType: $linkType }) {
3489
+ errors
3490
+ }
3491
+ }`, { id: workItemGID, workItemsIds: targetGIDs, linkType });
3492
+ if (addLinkedData.workItemAddLinkedItems.errors?.length > 0) {
3493
+ throw new Error(`Failed to add linked items: ${addLinkedData.workItemAddLinkedItems.errors.join(", ")}`);
3494
+ }
3495
+ }
3496
+ }
3497
+ // Handle linked_items_to_remove: use workItemRemoveLinkedItems mutation
3498
+ if (options.linked_items_to_remove && options.linked_items_to_remove.length > 0) {
3499
+ const targetGIDs = [];
3500
+ for (const item of options.linked_items_to_remove) {
3501
+ const { workItemGID: targetGID } = await resolveWorkItemGID(item.project_id, item.iid);
3502
+ targetGIDs.push(targetGID);
3503
+ }
3504
+ const removeLinkedData = await executeGraphQL(`mutation($id: WorkItemID!, $workItemsIds: [WorkItemID!]!) {
3505
+ workItemRemoveLinkedItems(input: { id: $id, workItemsIds: $workItemsIds }) {
3506
+ errors
3507
+ }
3508
+ }`, { id: workItemGID, workItemsIds: targetGIDs });
3509
+ if (removeLinkedData.workItemRemoveLinkedItems.errors?.length > 0) {
3510
+ throw new Error(`Failed to remove linked items: ${removeLinkedData.workItemRemoveLinkedItems.errors.join(", ")}`);
3511
+ }
3512
+ }
3513
+ // Handle incident-specific fields via separate mutations
3514
+ if (options.severity !== undefined) {
3515
+ await updateIncidentSeverity(projectPath, iid, options.severity);
3516
+ }
3517
+ if (options.escalation_status !== undefined) {
3518
+ await updateIncidentEscalationStatus(projectPath, iid, options.escalation_status);
3519
+ }
3520
+ // Flatten the response
3521
+ const wi = data.workItemUpdate.workItem;
3522
+ const widgets = wi?.widgets || [];
3523
+ const statusW = widgets.find((w) => w.__typename === "WorkItemWidgetStatus");
3524
+ const labelsW = widgets.find((w) => w.__typename === "WorkItemWidgetLabels");
3525
+ const assigneesW = widgets.find((w) => w.__typename === "WorkItemWidgetAssignees");
3526
+ const weightW = widgets.find((w) => w.__typename === "WorkItemWidgetWeight");
3527
+ const hierarchyW = widgets.find((w) => w.__typename === "WorkItemWidgetHierarchy");
3528
+ const healthStatusW = widgets.find((w) => w.__typename === "WorkItemWidgetHealthStatus");
3529
+ const datesW = widgets.find((w) => w.__typename === "WorkItemWidgetStartAndDueDate");
3530
+ const milestoneW = widgets.find((w) => w.__typename === "WorkItemWidgetMilestone");
3531
+ return {
3532
+ id: wi.id,
3533
+ iid: wi.iid,
3534
+ title: wi.title,
3535
+ state: wi.state,
3536
+ type: wi.workItemType?.name,
3537
+ webUrl: wi.webUrl,
3538
+ status: statusW?.status || null,
3539
+ labels: (labelsW?.labels?.nodes || []).map((l) => l.title),
3540
+ assignees: (assigneesW?.assignees?.nodes || []).map((a) => a.username),
3541
+ weight: weightW?.weight ?? null,
3542
+ parent: hierarchyW?.parent || null,
3543
+ healthStatus: healthStatusW?.healthStatus || null,
3544
+ startDate: datesW?.startDate || null,
3545
+ dueDate: datesW?.dueDate || null,
3546
+ milestone: milestoneW?.milestone || null,
3547
+ children_added: options.children_to_add?.length || 0,
3548
+ children_removed: options.children_to_remove?.length || 0,
3549
+ linked_items_added: options.linked_items_to_add?.length || 0,
3550
+ linked_items_removed: options.linked_items_to_remove?.length || 0,
3551
+ ...(options.severity !== undefined && { severity: options.severity }),
3552
+ ...(options.escalation_status !== undefined && { escalation_status: options.escalation_status }),
3553
+ };
3554
+ }
1946
3555
  /**
1947
3556
  * List all issue links for a specific issue
1948
3557
  * 이슈 관계 목록 조회
@@ -2234,14 +3843,17 @@ async function updateIssueNote(projectId, issueIid, discussionId, noteId, body,
2234
3843
  * Create a note in an issue discussion
2235
3844
  * @param {string} projectId - The ID or URL-encoded path of the project
2236
3845
  * @param {number} issueIid - The IID of an issue
2237
- * @param {string} discussionId - The ID of a thread
3846
+ * @param {string} [discussionId] - The ID of a thread (omit for top-level note)
2238
3847
  * @param {string} body - The content of the new note
2239
3848
  * @param {string} [createdAt] - The creation date of the note (ISO 8601 format)
2240
3849
  * @returns {Promise<GitLabDiscussionNote>} The created note
2241
3850
  */
2242
3851
  async function createIssueNote(projectId, issueIid, discussionId, body, createdAt) {
2243
3852
  projectId = decodeURIComponent(projectId); // Decode project ID
2244
- const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/issues/${issueIid}/discussions/${discussionId}/notes`);
3853
+ const basePath = `${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/issues/${issueIid}`;
3854
+ const url = new URL(discussionId
3855
+ ? `${basePath}/discussions/${discussionId}/notes`
3856
+ : `${basePath}/notes`);
2245
3857
  const payload = { body };
2246
3858
  if (createdAt) {
2247
3859
  payload.created_at = createdAt;
@@ -2550,6 +4162,52 @@ async function searchProjects(query, page = 1, perPage = 20) {
2550
4162
  items: projects,
2551
4163
  });
2552
4164
  }
4165
+ /**
4166
+ * Search for code blobs using GitLab Search API
4167
+ * Supports global, project-level, and group-level search
4168
+ */
4169
+ async function searchBlobs(params) {
4170
+ let basePath;
4171
+ if (params.project_id) {
4172
+ const decodedProjectId = decodeURIComponent(params.project_id);
4173
+ const projectId = encodeURIComponent(getEffectiveProjectId(decodedProjectId));
4174
+ basePath = `${getEffectiveApiUrl()}/projects/${projectId}/search`;
4175
+ }
4176
+ else if (params.group_id) {
4177
+ const groupId = encodeURIComponent(decodeURIComponent(params.group_id));
4178
+ basePath = `${getEffectiveApiUrl()}/groups/${groupId}/search`;
4179
+ }
4180
+ else {
4181
+ basePath = `${getEffectiveApiUrl()}/search`;
4182
+ }
4183
+ const url = new URL(basePath);
4184
+ url.searchParams.append("scope", "blobs");
4185
+ url.searchParams.append("search", params.search);
4186
+ if (params.ref) {
4187
+ url.searchParams.append("ref", params.ref);
4188
+ }
4189
+ if (params.page) {
4190
+ url.searchParams.append("page", params.page.toString());
4191
+ }
4192
+ if (params.per_page) {
4193
+ url.searchParams.append("per_page", params.per_page.toString());
4194
+ }
4195
+ if (params.filename) {
4196
+ url.searchParams.append("filename", params.filename);
4197
+ }
4198
+ if (params.path) {
4199
+ url.searchParams.append("path", params.path);
4200
+ }
4201
+ if (params.extension) {
4202
+ url.searchParams.append("extension", params.extension);
4203
+ }
4204
+ const response = await fetch(url.toString(), {
4205
+ ...getFetchConfig(),
4206
+ });
4207
+ await handleGitLabError(response);
4208
+ const data = await response.json();
4209
+ return z.array(GitLabSearchBlobResultSchema).parse(data);
4210
+ }
2553
4211
  /**
2554
4212
  * Create a new GitLab repository
2555
4213
  * 새 저장소 생성
@@ -2884,6 +4542,99 @@ async function listMergeRequestDiffs(projectId, mergeRequestIid, branchName, pag
2884
4542
  await handleGitLabError(response);
2885
4543
  return await response.json(); // Return full response including commits, diff_refs, changes, etc.
2886
4544
  }
4545
+ /**
4546
+ * Returns the list of changed files in a merge request WITHOUT diff content.
4547
+ * Use this as STEP 1 of code review: get file paths, then fetch diffs in batches
4548
+ * with getMergeRequestFileDiff to avoid loading the entire diff payload at once.
4549
+ *
4550
+ * @param {string} projectId - The ID or URL-encoded path of the project
4551
+ * @param {number|string} [mergeRequestIid] - The internal ID of the merge request
4552
+ * @param {string} [branchName] - The name of the source branch (used to resolve MR if iid not provided)
4553
+ * @param {string[]} [excludedFilePatterns] - Regex patterns to exclude files from the result
4554
+ * @returns {Promise<any[]>} Array of changed file metadata (new_path, old_path, new_file, deleted_file, renamed_file)
4555
+ */
4556
+ async function listMergeRequestChangedFiles(projectId, mergeRequestIid, branchName, excludedFilePatterns) {
4557
+ projectId = decodeURIComponent(projectId);
4558
+ if (!mergeRequestIid && !branchName) {
4559
+ throw new Error("Either mergeRequestIid or branchName must be provided");
4560
+ }
4561
+ if (branchName && !mergeRequestIid) {
4562
+ const mergeRequest = await getMergeRequest(projectId, undefined, branchName);
4563
+ mergeRequestIid = mergeRequest.iid;
4564
+ }
4565
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/changes`);
4566
+ const response = await fetch(url.toString(), { ...getFetchConfig() });
4567
+ await handleGitLabError(response);
4568
+ const data = (await response.json());
4569
+ const rawFiles = (data.changes || []).map((f) => ({
4570
+ new_path: f.new_path,
4571
+ old_path: f.old_path,
4572
+ new_file: f.new_file,
4573
+ deleted_file: f.deleted_file,
4574
+ renamed_file: f.renamed_file,
4575
+ }));
4576
+ return filterDiffsByPatterns(rawFiles, excludedFilePatterns);
4577
+ }
4578
+ /**
4579
+ * Get diffs for specific files from a merge request.
4580
+ * Use this as STEP 2 of code review: pass file paths obtained from
4581
+ * listMergeRequestChangedFiles to fetch their diffs efficiently.
4582
+ *
4583
+ * @param {string} projectId - The ID or URL-encoded path of the project
4584
+ * @param {string[]} filePaths - List of file paths to retrieve diffs for
4585
+ * @param {number|string} [mergeRequestIid] - The internal ID of the merge request
4586
+ * @param {string} [branchName] - The name of the source branch (used to resolve MR if iid not provided)
4587
+ * @param {boolean} [unidiff] - Return diff in unified diff format
4588
+ * @returns {Promise<any[]>} Array of diff objects for each requested file, or error objects for files not found
4589
+ */
4590
+ async function getMergeRequestFileDiff(projectId, filePaths, mergeRequestIid, branchName, unidiff) {
4591
+ projectId = decodeURIComponent(projectId);
4592
+ if (!mergeRequestIid && !branchName) {
4593
+ throw new Error("Either mergeRequestIid or branchName must be provided");
4594
+ }
4595
+ if (branchName && !mergeRequestIid) {
4596
+ const mergeRequest = await getMergeRequest(projectId, undefined, branchName);
4597
+ mergeRequestIid = mergeRequest.iid;
4598
+ }
4599
+ // Paginate through /diffs once, collecting all requested files.
4600
+ // More efficient than N separate searches when fetching multiple files.
4601
+ const remaining = new Set(filePaths);
4602
+ const results = [];
4603
+ let page = 1;
4604
+ const perPage = 20;
4605
+ while (remaining.size > 0) {
4606
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/diffs`);
4607
+ url.searchParams.append("page", page.toString());
4608
+ url.searchParams.append("per_page", perPage.toString());
4609
+ if (unidiff) {
4610
+ url.searchParams.append("unidiff", "true");
4611
+ }
4612
+ const response = await fetch(url.toString(), { ...getFetchConfig() });
4613
+ await handleGitLabError(response);
4614
+ const items = (await response.json());
4615
+ if (!Array.isArray(items) || items.length === 0) {
4616
+ break;
4617
+ }
4618
+ for (const item of items) {
4619
+ if (remaining.has(item.new_path) || remaining.has(item.old_path)) {
4620
+ results.push(item);
4621
+ remaining.delete(item.new_path);
4622
+ remaining.delete(item.old_path);
4623
+ }
4624
+ }
4625
+ if (items.length < perPage) {
4626
+ break;
4627
+ }
4628
+ page++;
4629
+ }
4630
+ for (const notFound of remaining) {
4631
+ results.push({
4632
+ error: `File not found in merge request diffs: ${notFound}`,
4633
+ hint: "Use list_merge_request_changed_files to verify the correct file paths.",
4634
+ });
4635
+ }
4636
+ return results;
4637
+ }
2887
4638
  /**
2888
4639
  * Get branch comparison diffs
2889
4640
  *
@@ -3870,6 +5621,84 @@ async function deleteWikiPage(projectId, slug) {
3870
5621
  });
3871
5622
  await handleGitLabError(response);
3872
5623
  }
5624
+ /**
5625
+ * List wiki pages in a GitLab group
5626
+ */
5627
+ async function listGroupWikiPages(groupId, options = {}) {
5628
+ groupId = decodeURIComponent(groupId); // Decode group ID
5629
+ const url = new URL(`${getEffectiveApiUrl()}/groups/${encodeURIComponent(groupId)}/wikis`);
5630
+ if (options.page)
5631
+ url.searchParams.append("page", options.page.toString());
5632
+ if (options.per_page)
5633
+ url.searchParams.append("per_page", options.per_page.toString());
5634
+ if (options.with_content)
5635
+ url.searchParams.append("with_content", options.with_content.toString());
5636
+ const response = await fetch(url.toString(), {
5637
+ ...getFetchConfig(),
5638
+ });
5639
+ await handleGitLabError(response);
5640
+ const data = await response.json();
5641
+ return GitLabWikiPageSchema.array().parse(data);
5642
+ }
5643
+ /**
5644
+ * Get a specific group wiki page
5645
+ */
5646
+ async function getGroupWikiPage(groupId, slug) {
5647
+ groupId = decodeURIComponent(groupId); // Decode group ID
5648
+ const response = await fetch(`${getEffectiveApiUrl()}/groups/${encodeURIComponent(groupId)}/wikis/${encodeURIComponent(slug)}`, { ...getFetchConfig() });
5649
+ await handleGitLabError(response);
5650
+ const data = await response.json();
5651
+ return GitLabWikiPageSchema.parse(data);
5652
+ }
5653
+ /**
5654
+ * Create a new group wiki page
5655
+ */
5656
+ async function createGroupWikiPage(groupId, title, content, format) {
5657
+ groupId = decodeURIComponent(groupId); // Decode group ID
5658
+ const body = { title, content };
5659
+ if (format)
5660
+ body.format = format;
5661
+ const response = await fetch(`${getEffectiveApiUrl()}/groups/${encodeURIComponent(groupId)}/wikis`, {
5662
+ ...getFetchConfig(),
5663
+ method: "POST",
5664
+ body: JSON.stringify(body),
5665
+ });
5666
+ await handleGitLabError(response);
5667
+ const data = await response.json();
5668
+ return GitLabWikiPageSchema.parse(data);
5669
+ }
5670
+ /**
5671
+ * Update an existing group wiki page
5672
+ */
5673
+ async function updateGroupWikiPage(groupId, slug, title, content, format) {
5674
+ groupId = decodeURIComponent(groupId); // Decode group ID
5675
+ const body = {};
5676
+ if (title)
5677
+ body.title = title;
5678
+ if (content)
5679
+ body.content = content;
5680
+ if (format)
5681
+ body.format = format;
5682
+ const response = await fetch(`${getEffectiveApiUrl()}/groups/${encodeURIComponent(groupId)}/wikis/${encodeURIComponent(slug)}`, {
5683
+ ...getFetchConfig(),
5684
+ method: "PUT",
5685
+ body: JSON.stringify(body),
5686
+ });
5687
+ await handleGitLabError(response);
5688
+ const data = await response.json();
5689
+ return GitLabWikiPageSchema.parse(data);
5690
+ }
5691
+ /**
5692
+ * Delete a group wiki page
5693
+ */
5694
+ async function deleteGroupWikiPage(groupId, slug) {
5695
+ groupId = decodeURIComponent(groupId); // Decode group ID
5696
+ const response = await fetch(`${getEffectiveApiUrl()}/groups/${encodeURIComponent(groupId)}/wikis/${encodeURIComponent(slug)}`, {
5697
+ ...getFetchConfig(),
5698
+ method: "DELETE",
5699
+ });
5700
+ await handleGitLabError(response);
5701
+ }
3873
5702
  /**
3874
5703
  * List pipelines in a GitLab project
3875
5704
  *
@@ -5198,6 +7027,51 @@ async function handleToolCall(params) {
5198
7027
  content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
5199
7028
  };
5200
7029
  }
7030
+ case "search_code": {
7031
+ const args = SearchCodeSchema.parse(params.arguments);
7032
+ const results = await searchBlobs({
7033
+ search: args.search,
7034
+ filename: args.filename,
7035
+ path: args.path,
7036
+ extension: args.extension,
7037
+ page: args.page,
7038
+ per_page: args.per_page,
7039
+ });
7040
+ return {
7041
+ content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
7042
+ };
7043
+ }
7044
+ case "search_project_code": {
7045
+ const args = SearchProjectCodeSchema.parse(params.arguments);
7046
+ const results = await searchBlobs({
7047
+ search: args.search,
7048
+ project_id: args.project_id,
7049
+ ref: args.ref,
7050
+ filename: args.filename,
7051
+ path: args.path,
7052
+ extension: args.extension,
7053
+ page: args.page,
7054
+ per_page: args.per_page,
7055
+ });
7056
+ return {
7057
+ content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
7058
+ };
7059
+ }
7060
+ case "search_group_code": {
7061
+ const args = SearchGroupCodeSchema.parse(params.arguments);
7062
+ const results = await searchBlobs({
7063
+ search: args.search,
7064
+ group_id: args.group_id,
7065
+ filename: args.filename,
7066
+ path: args.path,
7067
+ extension: args.extension,
7068
+ page: args.page,
7069
+ per_page: args.per_page,
7070
+ });
7071
+ return {
7072
+ content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
7073
+ };
7074
+ }
5201
7075
  case "create_repository": {
5202
7076
  if (GITLAB_PROJECT_ID) {
5203
7077
  throw new Error("Direct project ID is set. So fork_repository is not allowed");
@@ -5347,6 +7221,13 @@ async function handleToolCall(params) {
5347
7221
  content: [{ type: "text", text: JSON.stringify(filteredDiffs, null, 2) }],
5348
7222
  };
5349
7223
  }
7224
+ case "list_merge_request_changed_files": {
7225
+ const args = ListMergeRequestChangedFilesSchema.parse(params.arguments);
7226
+ const files = await listMergeRequestChangedFiles(args.project_id, args.merge_request_iid, args.source_branch, args.excluded_file_patterns);
7227
+ return {
7228
+ content: [{ type: "text", text: JSON.stringify(files, null, 2) }],
7229
+ };
7230
+ }
5350
7231
  case "list_merge_request_diffs": {
5351
7232
  const args = ListMergeRequestDiffsSchema.parse(params.arguments);
5352
7233
  const changes = await listMergeRequestDiffs(args.project_id, args.merge_request_iid, args.source_branch, args.page, args.per_page, args.unidiff);
@@ -5354,6 +7235,13 @@ async function handleToolCall(params) {
5354
7235
  content: [{ type: "text", text: JSON.stringify(changes, null, 2) }],
5355
7236
  };
5356
7237
  }
7238
+ case "get_merge_request_file_diff": {
7239
+ const args = GetMergeRequestFileDiffSchema.parse(params.arguments);
7240
+ const fileDiff = await getMergeRequestFileDiff(args.project_id, args.file_paths, args.merge_request_iid, args.source_branch, args.unidiff);
7241
+ return {
7242
+ content: [{ type: "text", text: JSON.stringify(fileDiff, null, 2) }],
7243
+ };
7244
+ }
5357
7245
  case "list_merge_request_versions": {
5358
7246
  const args = ListMergeRequestVersionsSchema.parse(params.arguments);
5359
7247
  const versions = await listMergeRequestVersions(args.project_id, args.merge_request_iid);
@@ -5680,6 +7568,93 @@ async function handleToolCall(params) {
5680
7568
  ],
5681
7569
  };
5682
7570
  }
7571
+ case "get_work_item": {
7572
+ const args = GetWorkItemSchema.parse(params.arguments);
7573
+ const result = await getWorkItem(args.project_id, args.iid);
7574
+ return {
7575
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
7576
+ };
7577
+ }
7578
+ case "list_work_items": {
7579
+ const args = ListWorkItemsSchema.parse(params.arguments);
7580
+ const { project_id, ...options } = args;
7581
+ const result = await listWorkItems(project_id, options);
7582
+ return {
7583
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
7584
+ };
7585
+ }
7586
+ case "create_work_item": {
7587
+ const args = CreateWorkItemSchema.parse(params.arguments);
7588
+ const { project_id, ...options } = args;
7589
+ const result = await createWorkItem(project_id, options);
7590
+ return {
7591
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
7592
+ };
7593
+ }
7594
+ case "update_work_item": {
7595
+ const args = UpdateWorkItemSchema.parse(params.arguments);
7596
+ const { project_id, iid, ...options } = args;
7597
+ const result = await updateWorkItem(project_id, iid, options);
7598
+ return {
7599
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
7600
+ };
7601
+ }
7602
+ case "convert_work_item_type": {
7603
+ const args = ConvertWorkItemTypeSchema.parse(params.arguments);
7604
+ const result = await convertIssueType(args.project_id, args.iid, args.new_type);
7605
+ return {
7606
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
7607
+ };
7608
+ }
7609
+ case "list_work_item_statuses": {
7610
+ const args = ListWorkItemStatusesSchema.parse(params.arguments);
7611
+ const result = await listIssueStatuses(args.project_id, args.work_item_type);
7612
+ return {
7613
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
7614
+ };
7615
+ }
7616
+ case "list_custom_field_definitions": {
7617
+ const args = ListCustomFieldDefinitionsSchema.parse(params.arguments);
7618
+ const result = await listCustomFieldDefinitions(args.project_id, args.work_item_type);
7619
+ return {
7620
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
7621
+ };
7622
+ }
7623
+ case "move_work_item": {
7624
+ const args = MoveWorkItemSchema.parse(params.arguments);
7625
+ const result = await moveWorkItem(args.project_id, args.iid, args.target_project_id);
7626
+ return {
7627
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
7628
+ };
7629
+ }
7630
+ case "list_work_item_notes": {
7631
+ const args = ListWorkItemNotesSchema.parse(params.arguments);
7632
+ const result = await listWorkItemNotes(args.project_id, args.iid, args);
7633
+ return {
7634
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
7635
+ };
7636
+ }
7637
+ case "create_work_item_note": {
7638
+ const args = CreateWorkItemNoteSchema.parse(params.arguments);
7639
+ const result = await createWorkItemNote(args.project_id, args.iid, args.body, args);
7640
+ return {
7641
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
7642
+ };
7643
+ }
7644
+ case "get_timeline_events": {
7645
+ const args = GetTimelineEventsSchema.parse(params.arguments);
7646
+ const result = await getTimelineEvents(args.project_id, args.incident_iid);
7647
+ return {
7648
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
7649
+ };
7650
+ }
7651
+ case "create_timeline_event": {
7652
+ const args = CreateTimelineEventSchema.parse(params.arguments);
7653
+ const result = await createTimelineEvent(args.project_id, args.incident_iid, args.note, args.occurred_at, args.tag_names);
7654
+ return {
7655
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
7656
+ };
7657
+ }
5683
7658
  case "list_labels": {
5684
7659
  const args = ListLabelsSchema.parse(params.arguments);
5685
7660
  const labels = await listLabels(args.project_id, args);
@@ -5775,6 +7750,53 @@ async function handleToolCall(params) {
5775
7750
  ],
5776
7751
  };
5777
7752
  }
7753
+ case "list_group_wiki_pages": {
7754
+ const { group_id, page, per_page, with_content } = ListGroupWikiPagesSchema.parse(params.arguments);
7755
+ const wikiPages = await listGroupWikiPages(group_id, {
7756
+ page,
7757
+ per_page,
7758
+ with_content,
7759
+ });
7760
+ return {
7761
+ content: [{ type: "text", text: JSON.stringify(wikiPages, null, 2) }],
7762
+ };
7763
+ }
7764
+ case "get_group_wiki_page": {
7765
+ const { group_id, slug } = GetGroupWikiPageSchema.parse(params.arguments);
7766
+ const wikiPage = await getGroupWikiPage(group_id, slug);
7767
+ return {
7768
+ content: [{ type: "text", text: JSON.stringify(wikiPage, null, 2) }],
7769
+ };
7770
+ }
7771
+ case "create_group_wiki_page": {
7772
+ const { group_id, title, content, format } = CreateGroupWikiPageSchema.parse(params.arguments);
7773
+ const wikiPage = await createGroupWikiPage(group_id, title, content, format);
7774
+ return {
7775
+ content: [{ type: "text", text: JSON.stringify(wikiPage, null, 2) }],
7776
+ };
7777
+ }
7778
+ case "update_group_wiki_page": {
7779
+ const { group_id, slug, title, content, format } = UpdateGroupWikiPageSchema.parse(params.arguments);
7780
+ const wikiPage = await updateGroupWikiPage(group_id, slug, title, content, format);
7781
+ return {
7782
+ content: [{ type: "text", text: JSON.stringify(wikiPage, null, 2) }],
7783
+ };
7784
+ }
7785
+ case "delete_group_wiki_page": {
7786
+ const { group_id, slug } = DeleteGroupWikiPageSchema.parse(params.arguments);
7787
+ await deleteGroupWikiPage(group_id, slug);
7788
+ return {
7789
+ content: [
7790
+ {
7791
+ type: "text",
7792
+ text: JSON.stringify({
7793
+ status: "success",
7794
+ message: "Group wiki page deleted successfully",
7795
+ }, null, 2),
7796
+ },
7797
+ ],
7798
+ };
7799
+ }
5778
7800
  case "get_repository_tree": {
5779
7801
  const args = GetRepositoryTreeSchema.parse(params.arguments);
5780
7802
  const tree = await getRepositoryTree(args);
@@ -5989,8 +8011,20 @@ async function handleToolCall(params) {
5989
8011
  };
5990
8012
  }
5991
8013
  case "list_merge_requests": {
5992
- const args = ListMergeRequestsSchema.parse(params.arguments);
5993
- const mergeRequests = await listMergeRequests(args.project_id, args);
8014
+ const { project_id, ...options } = ListMergeRequestsSchema.parse(params.arguments);
8015
+ // GitLab API treats _id and _username as mutually exclusive for these fields.
8016
+ // When both are provided, prefer _username and remove _id to avoid 400 errors.
8017
+ const cleanedOptions = { ...options };
8018
+ if (cleanedOptions.author_id && cleanedOptions.author_username) {
8019
+ delete cleanedOptions.author_id;
8020
+ }
8021
+ if (cleanedOptions.assignee_id && cleanedOptions.assignee_username) {
8022
+ delete cleanedOptions.assignee_id;
8023
+ }
8024
+ if (cleanedOptions.reviewer_id && cleanedOptions.reviewer_username) {
8025
+ delete cleanedOptions.reviewer_id;
8026
+ }
8027
+ const mergeRequests = await listMergeRequests(project_id, cleanedOptions);
5994
8028
  return {
5995
8029
  content: [{ type: "text", text: JSON.stringify(mergeRequests, null, 2) }],
5996
8030
  };
@@ -6440,10 +8474,19 @@ async function startStreamableHTTPServer() {
6440
8474
  session.count++;
6441
8475
  return true;
6442
8476
  };
8477
+ /**
8478
+ * Check whether the request carries a raw header auth token (Private-Token or JOB-TOKEN).
8479
+ * Used to decide whether to bypass OAuth validation.
8480
+ */
8481
+ const hasHeaderAuth = (req) => {
8482
+ return !!(req.headers["private-token"] ||
8483
+ req.headers["job-token"]);
8484
+ };
6443
8485
  /**
6444
8486
  * Parse authentication from request headers
6445
8487
  * Returns null if no auth found or invalid format
6446
- * Supports: JOB-TOKEN header, Private-Token header, Authorization Bearer header
8488
+ * Supports: Private-Token header, JOB-TOKEN header, Authorization Bearer header
8489
+ * Priority: Private-Token > JOB-TOKEN > Authorization Bearer
6447
8490
  */
6448
8491
  const parseAuthHeaders = (req) => {
6449
8492
  const authHeader = req.headers["authorization"] || "";
@@ -6462,17 +8505,18 @@ async function startStreamableHTTPServer() {
6462
8505
  return null; // Reject if URL is malformed
6463
8506
  }
6464
8507
  }
6465
- // Extract token
8508
+ // Extract token — priority: Private-Token > JOB-TOKEN > Authorization Bearer
8509
+ // PATs are preferred over job tokens because they carry broader permissions.
6466
8510
  let token = null;
6467
8511
  let header = null;
6468
- if (jobToken) {
6469
- token = jobToken.trim();
6470
- header = "JOB-TOKEN";
6471
- }
6472
- else if (privateToken) {
8512
+ if (privateToken) {
6473
8513
  token = privateToken.trim();
6474
8514
  header = "Private-Token";
6475
8515
  }
8516
+ else if (jobToken) {
8517
+ token = jobToken.trim();
8518
+ header = "JOB-TOKEN";
8519
+ }
6476
8520
  else if (authHeader) {
6477
8521
  // Use \S+ instead of .+ to prevent ReDoS attacks
6478
8522
  // \S+ only matches non-whitespace, so trim() is technically unnecessary,
@@ -6526,13 +8570,68 @@ async function startStreamableHTTPServer() {
6526
8570
  };
6527
8571
  // Configure Express middleware
6528
8572
  app.use(express.json());
8573
+ // MCP OAuth — mount auth router and prepare bearer-auth middleware
8574
+ if (GITLAB_MCP_OAUTH) {
8575
+ // Trust first proxy so express-rate-limit uses X-Forwarded-For for real client IP.
8576
+ // Only enabled in OAuth mode where the server is typically behind a reverse proxy.
8577
+ app.set("trust proxy", 1);
8578
+ const gitlabBaseUrl = GITLAB_API_URL.replace(/\/api\/v4\/?$/, "").replace(/\/$/, "");
8579
+ const issuerUrl = new URL(MCP_SERVER_URL);
8580
+ const oauthProvider = createGitLabOAuthProvider(gitlabBaseUrl, GITLAB_OAUTH_APP_ID, "GitLab MCP Server", GITLAB_READ_ONLY_MODE, GITLAB_OAUTH_SCOPES);
8581
+ // Mounts /.well-known/oauth-authorization-server,
8582
+ // /.well-known/oauth-protected-resource,
8583
+ // /authorize, /token, /register, /revoke
8584
+ app.use(mcpAuthRouter({
8585
+ provider: oauthProvider,
8586
+ issuerUrl,
8587
+ baseUrl: issuerUrl,
8588
+ scopesSupported: GITLAB_OAUTH_SCOPES ?? ["api", "read_api", "read_user"],
8589
+ resourceName: "GitLab MCP Server",
8590
+ }));
8591
+ // Expose provider so the /mcp route middleware can reference it
8592
+ app._mcpOAuthProvider = oauthProvider;
8593
+ }
8594
+ // Build bearer-auth middleware — no-op unless GITLAB_MCP_OAUTH is enabled.
8595
+ // Unauthenticated requests receive 401 + WWW-Authenticate header, which is
8596
+ // exactly what Claude.ai needs to trigger the OAuth browser flow.
8597
+ //
8598
+ // Header auth fallback: if Private-Token or JOB-TOKEN headers are present,
8599
+ // OAuth validation is skipped and the raw token is used directly per-session.
8600
+ // Note: Authorization: Bearer is always treated as an OAuth token and goes
8601
+ // through OAuth validation — use Private-Token for PAT-based header auth.
8602
+ const oauthBearerAuth = GITLAB_MCP_OAUTH
8603
+ ? requireBearerAuth({
8604
+ verifier: app._mcpOAuthProvider,
8605
+ requiredScopes: [],
8606
+ })
8607
+ : undefined;
8608
+ const mcpBearerAuth = GITLAB_MCP_OAUTH
8609
+ ? (req, res, next) => {
8610
+ const privateToken = req.headers["private-token"] || "";
8611
+ const jobToken = req.headers["job-token"] || "";
8612
+ if (privateToken || jobToken) {
8613
+ // Validate the raw token before bypassing OAuth
8614
+ const authData = parseAuthHeaders(req);
8615
+ if (authData) {
8616
+ next();
8617
+ return;
8618
+ }
8619
+ res.status(401).json({
8620
+ error: "Invalid Private-Token or JOB-TOKEN header",
8621
+ message: "The provided token failed validation. Check the token value and format.",
8622
+ });
8623
+ return;
8624
+ }
8625
+ oauthBearerAuth(req, res, next);
8626
+ }
8627
+ : (_req, _res, next) => next();
6529
8628
  // Streamable HTTP endpoint - handles both session creation and message handling
6530
- app.post("/mcp", async (req, res) => {
8629
+ app.post("/mcp", mcpBearerAuth, async (req, res) => {
6531
8630
  const sessionId = req.headers["mcp-session-id"];
6532
8631
  // Track request
6533
8632
  metrics.requestsProcessed++;
6534
8633
  // Rate limiting check for existing sessions
6535
- if (REMOTE_AUTHORIZATION && sessionId && !checkRateLimit(sessionId)) {
8634
+ if ((REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) && sessionId && !checkRateLimit(sessionId)) {
6536
8635
  metrics.rejectedByRateLimit++;
6537
8636
  res.status(429).json({
6538
8637
  error: "Rate limit exceeded",
@@ -6557,8 +8656,8 @@ async function startStreamableHTTPServer() {
6557
8656
  if (!authData) {
6558
8657
  metrics.authFailures++;
6559
8658
  res.status(401).json({
6560
- error: "Missing Authorization, Private-Token, or JOB-TOKEN header",
6561
- message: "Remote authorization is enabled. Please provide Authorization, Private-Token, or JOB-TOKEN header.",
8659
+ error: "Missing Private-Token, JOB-TOKEN, or Authorization header",
8660
+ message: "Remote authorization is enabled. Please provide Private-Token, JOB-TOKEN, or Authorization header.",
6562
8661
  });
6563
8662
  return;
6564
8663
  }
@@ -6582,6 +8681,52 @@ async function startStreamableHTTPServer() {
6582
8681
  // First request without session - will fail in initialization
6583
8682
  }
6584
8683
  }
8684
+ // MCP OAuth mode — either header auth (PAT/job token) or OAuth Bearer token.
8685
+ // Header auth takes precedence: if Private-Token or JOB-TOKEN is present the
8686
+ // OAuth middleware was bypassed and we store the raw token per-session.
8687
+ // Otherwise req.auth is populated by requireBearerAuth; store the OAuth token.
8688
+ if (GITLAB_MCP_OAUTH) {
8689
+ const headerAuthData = hasHeaderAuth(req) ? parseAuthHeaders(req) : null;
8690
+ if (headerAuthData) {
8691
+ if (headerAuthData && sessionId) {
8692
+ if (!authBySession[sessionId]) {
8693
+ authBySession[sessionId] = headerAuthData;
8694
+ logger.info(`Session ${sessionId}: stored ${headerAuthData.header} header (header auth)`);
8695
+ setAuthTimeout(sessionId);
8696
+ }
8697
+ else {
8698
+ authBySession[sessionId] = {
8699
+ ...authBySession[sessionId],
8700
+ header: headerAuthData.header,
8701
+ token: headerAuthData.token,
8702
+ lastUsed: Date.now(),
8703
+ };
8704
+ setAuthTimeout(sessionId);
8705
+ }
8706
+ }
8707
+ }
8708
+ else {
8709
+ const authInfo = req.auth;
8710
+ if (authInfo?.token && sessionId) {
8711
+ if (!authBySession[sessionId]) {
8712
+ authBySession[sessionId] = {
8713
+ header: "Authorization",
8714
+ token: authInfo.token,
8715
+ lastUsed: Date.now(),
8716
+ apiUrl: GITLAB_API_URL,
8717
+ };
8718
+ logger.info(`Session ${sessionId}: stored OAuth token (client: ${authInfo.clientId})`);
8719
+ setAuthTimeout(sessionId);
8720
+ }
8721
+ else {
8722
+ // Update token on every request — the client may have refreshed it
8723
+ authBySession[sessionId].token = authInfo.token;
8724
+ authBySession[sessionId].lastUsed = Date.now();
8725
+ setAuthTimeout(sessionId);
8726
+ }
8727
+ }
8728
+ }
8729
+ }
6585
8730
  // Handle request with proper AsyncLocalStorage context
6586
8731
  const handleRequest = async () => {
6587
8732
  try {
@@ -6609,6 +8754,31 @@ async function startStreamableHTTPServer() {
6609
8754
  setAuthTimeout(newSessionId);
6610
8755
  }
6611
8756
  }
8757
+ // Store OAuth token for newly created session in MCP OAuth mode.
8758
+ // If Private-Token or JOB-TOKEN headers are present, prefer them.
8759
+ if (GITLAB_MCP_OAUTH && !authBySession[newSessionId]) {
8760
+ if (hasHeaderAuth(req)) {
8761
+ const authData = parseAuthHeaders(req);
8762
+ if (authData) {
8763
+ authBySession[newSessionId] = authData;
8764
+ logger.info(`Session ${newSessionId}: stored ${authData.header} header (header auth)`);
8765
+ setAuthTimeout(newSessionId);
8766
+ }
8767
+ }
8768
+ else {
8769
+ const authInfo = req.auth;
8770
+ if (authInfo?.token) {
8771
+ authBySession[newSessionId] = {
8772
+ header: "Authorization",
8773
+ token: authInfo.token,
8774
+ lastUsed: Date.now(),
8775
+ apiUrl: GITLAB_API_URL,
8776
+ };
8777
+ logger.info(`Session ${newSessionId}: stored OAuth token (client: ${authInfo.clientId})`);
8778
+ setAuthTimeout(newSessionId);
8779
+ }
8780
+ }
8781
+ }
6612
8782
  },
6613
8783
  });
6614
8784
  // Set up cleanup handler when transport closes
@@ -6618,7 +8788,7 @@ async function startStreamableHTTPServer() {
6618
8788
  logger.warn(`Streamable HTTP transport closed for session ${sid}, cleaning up`);
6619
8789
  delete streamableTransports[sid];
6620
8790
  metrics.activeSessions--;
6621
- if (REMOTE_AUTHORIZATION) {
8791
+ if (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) {
6622
8792
  cleanupSessionAuth(sid);
6623
8793
  delete sessionRequestCounts[sid];
6624
8794
  logger.info(`Session ${sid}: cleaned up auth mapping`);
@@ -6641,8 +8811,8 @@ async function startStreamableHTTPServer() {
6641
8811
  });
6642
8812
  }
6643
8813
  };
6644
- // Execute with auth context in remote mode
6645
- if (REMOTE_AUTHORIZATION && sessionId && authBySession[sessionId]) {
8814
+ // Execute with auth context in remote mode (REMOTE_AUTHORIZATION or GITLAB_MCP_OAUTH)
8815
+ if ((REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) && sessionId && authBySession[sessionId]) {
6646
8816
  const authData = authBySession[sessionId];
6647
8817
  const ctx = {
6648
8818
  sessionId,
@@ -6655,7 +8825,7 @@ async function startStreamableHTTPServer() {
6655
8825
  await sessionAuthStore.run(ctx, handleRequest);
6656
8826
  }
6657
8827
  else {
6658
- // Standard execution (no remote auth or no session yet)
8828
+ // Standard execution (no per-session auth or no session yet)
6659
8829
  await handleRequest();
6660
8830
  }
6661
8831
  });
@@ -6673,6 +8843,7 @@ async function startStreamableHTTPServer() {
6673
8843
  ...metrics,
6674
8844
  activeSessions: Object.keys(streamableTransports).length,
6675
8845
  authenticatedSessions: Object.keys(authBySession).length,
8846
+ gitlabClientPool: clientPool.getStats(),
6676
8847
  uptime: process.uptime(),
6677
8848
  memoryUsage: process.memoryUsage(),
6678
8849
  config: {
@@ -6680,6 +8851,7 @@ async function startStreamableHTTPServer() {
6680
8851
  maxRequestsPerMinute: MAX_REQUESTS_PER_MINUTE,
6681
8852
  sessionTimeoutSeconds: SESSION_TIMEOUT_SECONDS,
6682
8853
  remoteAuthEnabled: REMOTE_AUTHORIZATION,
8854
+ mcpOAuthEnabled: GITLAB_MCP_OAUTH,
6683
8855
  },
6684
8856
  });
6685
8857
  });
@@ -6705,7 +8877,7 @@ async function startStreamableHTTPServer() {
6705
8877
  try {
6706
8878
  await transport.close();
6707
8879
  logger.info(`Explicitly closed session via DELETE request: ${sessionId}`);
6708
- if (REMOTE_AUTHORIZATION) {
8880
+ if (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) {
6709
8881
  cleanupSessionAuth(sessionId);
6710
8882
  delete sessionRequestCounts[sessionId];
6711
8883
  logger.info(`Session ${sessionId}: cleaned up auth mapping on DELETE`);
@@ -6741,7 +8913,7 @@ async function startStreamableHTTPServer() {
6741
8913
  const transport = streamableTransports[sessionId];
6742
8914
  if (transport) {
6743
8915
  await transport.close();
6744
- if (REMOTE_AUTHORIZATION) {
8916
+ if (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) {
6745
8917
  cleanupSessionAuth(sessionId);
6746
8918
  delete sessionRequestCounts[sessionId];
6747
8919
  }