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