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