@zereight/mcp-gitlab 2.0.33 → 2.0.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +233 -90
- package/build/gitlab-client-pool.js +114 -6
- package/build/index.js +2244 -98
- package/build/oauth-proxy.js +257 -0
- package/build/oauth.js +11 -6
- package/build/schemas.js +458 -199
- package/build/test/mcp-oauth-tests.js +443 -0
- package/build/test/multi-server-test.js +16 -8
- package/build/test/no-proxy-integration-test.js +183 -0
- package/build/test/no-proxy-test.js +138 -0
- package/build/test/remote-auth-simple-test.js +12 -1
- package/build/test/test-geteffectiveprojectid.js +245 -0
- 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/test-upload-markdown.js +148 -0
- package/build/test/utils/mock-gitlab-server.js +267 -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, GitLabMergeRequestApprovalsResponseSchema, GitLabMergeRequestApprovalStateSchema, MyIssuesSchema, PaginatedDiscussionsResponseSchema, PromoteProjectMilestoneSchema, PublishDraftNoteSchema, PlayPipelineJobSchema, PushFilesSchema, RetryPipelineJobSchema, RetryPipelineSchema, SearchRepositoriesSchema, UpdateDraftNoteSchema, UpdateIssueNoteSchema, UpdateIssueSchema, UpdateLabelSchema, UpdateMergeRequestNoteSchema, UpdateMergeRequestDiscussionNoteSchema, UpdateMergeRequestSchema, UpdateWikiPageSchema, VerifyNamespaceSchema, GitLabEventSchema, ListEventsSchema, GetProjectEventsSchema, ExecuteGraphQLSchema, GitLabReleaseSchema, ListReleasesSchema, GetReleaseSchema, CreateReleaseSchema, UpdateReleaseSchema, DeleteReleaseSchema, CreateReleaseEvidenceSchema, DownloadReleaseAssetSchema, GetMergeRequestNotesSchema, GetMergeRequestNoteSchema, DeleteMergeRequestDiscussionNoteSchema, ResolveMergeRequestThreadSchema, ListWebhooksSchema, ListWebhookEventsSchema, GetWebhookEventSchema, } from "./schemas.js";
|
|
55
|
+
GitLabDraftNoteSchema, GitLabForkSchema, GitLabIssueLinkSchema, GitLabIssueSchema, GitLabIssueWithLinkDetailsSchema, GitLabMarkdownUploadSchema, GitLabMergeRequestSchema, GitLabMilestonesSchema, GitLabNamespaceExistsResponseSchema, GitLabNamespaceSchema, GitLabPipelineJobSchema, GitLabDeploymentSchema, GitLabEnvironmentSchema, GitLabPipelineSchema, GitLabPipelineTriggerJobSchema, GitLabProjectMemberSchema, GitLabProjectSchema, GitLabReferenceSchema, GitLabRepositorySchema, GitLabSearchBlobResultSchema, GitLabSearchResponseSchema, GitLabTreeItemSchema, GitLabTreeSchema, GitLabUserSchema, GitLabUsersResponseSchema, GitLabWikiPageSchema, GroupIteration, ListCommitsSchema, ListDraftNotesSchema, ListGroupIterationsSchema, ListGroupProjectsSchema, ListIssueDiscussionsSchema, ListIssueLinksSchema, ListIssuesSchema, ListLabelsSchema, ListMergeRequestDiffsSchema, // Added
|
|
56
|
+
GetMergeRequestFileDiffSchema, ListMergeRequestChangedFilesSchema, ListMergeRequestDiscussionsSchema, ListMergeRequestsSchema, ListMergeRequestVersionsSchema, GetMergeRequestVersionSchema, GitLabMergeRequestVersionSchema, GitLabMergeRequestVersionDetailSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelinesSchema, ListDeploymentsSchema, ListEnvironmentsSchema, ListPipelineTriggerJobsSchema, ListProjectMembersSchema, ListProjectMilestonesSchema, ListProjectsSchema, ListWikiPagesSchema, GetGroupWikiPageSchema, ListGroupWikiPagesSchema, UpdateGroupWikiPageSchema, MarkdownUploadSchema, DownloadAttachmentSchema, DownloadJobArtifactsSchema, GetJobArtifactFileSchema, GitLabArtifactEntrySchema, ListJobArtifactsSchema, MergeMergeRequestSchema, ApproveMergeRequestSchema, UnapproveMergeRequestSchema, GetMergeRequestApprovalStateSchema, GetMergeRequestConflictsSchema, GitLabMergeRequestApprovalsResponseSchema, GitLabMergeRequestApprovalStateSchema, MyIssuesSchema, PaginatedDiscussionsResponseSchema, PromoteProjectMilestoneSchema, PublishDraftNoteSchema, PlayPipelineJobSchema, PushFilesSchema, RetryPipelineJobSchema, RetryPipelineSchema, SearchCodeSchema, SearchGroupCodeSchema, SearchProjectCodeSchema, SearchRepositoriesSchema, UpdateDraftNoteSchema, UpdateIssueNoteSchema, UpdateIssueSchema, UpdateLabelSchema, UpdateMergeRequestNoteSchema, UpdateMergeRequestDiscussionNoteSchema, UpdateMergeRequestSchema, UpdateWikiPageSchema, VerifyNamespaceSchema, GitLabEventSchema, ListEventsSchema, GetProjectEventsSchema, ExecuteGraphQLSchema, GitLabReleaseSchema, ListReleasesSchema, GetReleaseSchema, CreateReleaseSchema, UpdateReleaseSchema, DeleteReleaseSchema, CreateReleaseEvidenceSchema, DownloadReleaseAssetSchema, GetMergeRequestNotesSchema, GetMergeRequestNoteSchema, DeleteMergeRequestDiscussionNoteSchema, ResolveMergeRequestThreadSchema, GetWorkItemSchema, ListWorkItemsSchema, CreateWorkItemSchema, UpdateWorkItemSchema, ConvertWorkItemTypeSchema, ListWorkItemStatusesSchema, ListWorkItemNotesSchema, CreateWorkItemNoteSchema, MoveWorkItemSchema, ListCustomFieldDefinitionsSchema, GetTimelineEventsSchema, CreateTimelineEventSchema, ListWebhooksSchema, ListWebhookEventsSchema, GetWebhookEventSchema, } from "./schemas.js";
|
|
54
57
|
import { randomUUID } from "node:crypto";
|
|
55
58
|
import { pino } from "pino";
|
|
56
59
|
const logger = pino({
|
|
@@ -228,8 +231,37 @@ function validateConfiguration() {
|
|
|
228
231
|
const hasToken = !!getConfig("token", "GITLAB_PERSONAL_ACCESS_TOKEN");
|
|
229
232
|
const hasJobToken = !!getConfig("job-token", "GITLAB_JOB_TOKEN");
|
|
230
233
|
const hasCookie = !!getConfig("cookie-path", "GITLAB_AUTH_COOKIE_PATH");
|
|
231
|
-
|
|
232
|
-
|
|
234
|
+
const mcpOAuth = getConfig("mcp-oauth", "GITLAB_MCP_OAUTH") === "true";
|
|
235
|
+
const mcpServerUrl = getConfig("mcp-server-url", "MCP_SERVER_URL");
|
|
236
|
+
if (!remoteAuth && !useOAuth && !hasToken && !hasJobToken && !hasCookie && !mcpOAuth) {
|
|
237
|
+
errors.push("Either --token, --job-token, --cookie-path, --use-oauth=true, --remote-auth=true, or --mcp-oauth=true must be set (or use environment variables)");
|
|
238
|
+
}
|
|
239
|
+
if (mcpOAuth) {
|
|
240
|
+
if (!mcpServerUrl) {
|
|
241
|
+
errors.push("MCP_SERVER_URL is required when GITLAB_MCP_OAUTH=true (e.g. https://mcp.example.com)");
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
try {
|
|
245
|
+
const u = new URL(mcpServerUrl);
|
|
246
|
+
const isInsecure = u.protocol !== "https:";
|
|
247
|
+
const isLocalhost = u.hostname === "localhost" || u.hostname === "127.0.0.1";
|
|
248
|
+
const allowInsecure = process.env.MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL === "true";
|
|
249
|
+
if (isInsecure && !isLocalhost && !allowInsecure) {
|
|
250
|
+
errors.push("MCP_SERVER_URL must use HTTPS in production " +
|
|
251
|
+
"(set MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL=true for local dev)");
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
errors.push(`MCP_SERVER_URL is not a valid URL: ${mcpServerUrl}`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (!getConfig("api-url", "GITLAB_API_URL")) {
|
|
259
|
+
errors.push("GITLAB_API_URL is required when GITLAB_MCP_OAUTH=true");
|
|
260
|
+
}
|
|
261
|
+
if (!getConfig("oauth-app-id", "GITLAB_OAUTH_APP_ID")) {
|
|
262
|
+
errors.push("GITLAB_OAUTH_APP_ID is required when GITLAB_MCP_OAUTH=true " +
|
|
263
|
+
"(create an OAuth application in GitLab Admin with the required scopes)");
|
|
264
|
+
}
|
|
233
265
|
}
|
|
234
266
|
const enableDynamicApiUrl = getConfig("enable-dynamic-api-url", "ENABLE_DYNAMIC_API_URL") === "true";
|
|
235
267
|
if (enableDynamicApiUrl && !remoteAuth) {
|
|
@@ -283,7 +315,8 @@ const GITLAB_DENIED_TOOLS_REGEX = (() => {
|
|
|
283
315
|
}
|
|
284
316
|
// Reject patterns with nested quantifiers that can cause catastrophic backtracking (ReDoS)
|
|
285
317
|
// e.g., (a+)+, (a*)+, (a+)*, (a{1,})+
|
|
286
|
-
|
|
318
|
+
// Note: lookahead (?!), (?=), lookbehind (?<), and named groups (?<name>) are safe and allowed
|
|
319
|
+
const NESTED_QUANTIFIER_PATTERN = /(\(.*[+*?].*\)|\[.*\])[+*?]/;
|
|
287
320
|
if (NESTED_QUANTIFIER_PATTERN.test(pattern)) {
|
|
288
321
|
logger.error(`GITLAB_DENIED_TOOLS_REGEX contains potentially unsafe nested quantifiers. Ignoring.`);
|
|
289
322
|
return undefined;
|
|
@@ -307,6 +340,9 @@ const GITLAB_TOOLS_RAW = getConfig("tools", "GITLAB_TOOLS");
|
|
|
307
340
|
const SSE = getConfig("sse", "SSE") === "true";
|
|
308
341
|
const STREAMABLE_HTTP = getConfig("streamable-http", "STREAMABLE_HTTP") === "true";
|
|
309
342
|
const REMOTE_AUTHORIZATION = getConfig("remote-auth", "REMOTE_AUTHORIZATION") === "true";
|
|
343
|
+
const GITLAB_MCP_OAUTH = getConfig("mcp-oauth", "GITLAB_MCP_OAUTH") === "true";
|
|
344
|
+
const MCP_SERVER_URL = getConfig("mcp-server-url", "MCP_SERVER_URL");
|
|
345
|
+
const GITLAB_OAUTH_APP_ID = getConfig("oauth-app-id", "GITLAB_OAUTH_APP_ID");
|
|
310
346
|
const ENABLE_DYNAMIC_API_URL = getConfig("enable-dynamic-api-url", "ENABLE_DYNAMIC_API_URL") === "true";
|
|
311
347
|
const SESSION_TIMEOUT_SECONDS = Number.parseInt(getConfig("session-timeout", "SESSION_TIMEOUT_SECONDS", "3600"), 10);
|
|
312
348
|
const HOST = getConfig("host", "HOST") || "127.0.0.1";
|
|
@@ -314,6 +350,7 @@ const PORT = Number.parseInt(getConfig("port", "PORT", "3002"), 10);
|
|
|
314
350
|
// Add proxy configuration
|
|
315
351
|
const HTTP_PROXY = getConfig("http-proxy", "HTTP_PROXY");
|
|
316
352
|
const HTTPS_PROXY = getConfig("https-proxy", "HTTPS_PROXY");
|
|
353
|
+
const NO_PROXY = getConfig("no-proxy", "NO_PROXY");
|
|
317
354
|
const NODE_TLS_REJECT_UNAUTHORIZED = getConfig("tls-reject-unauthorized", "NODE_TLS_REJECT_UNAUTHORIZED");
|
|
318
355
|
const GITLAB_CA_CERT_PATH = getConfig("ca-cert-path", "GITLAB_CA_CERT_PATH");
|
|
319
356
|
const GITLAB_POOL_MAX_SIZE = getConfig("pool-max-size", "GITLAB_POOL_MAX_SIZE")
|
|
@@ -355,6 +392,7 @@ const clientPool = new GitLabClientPool({
|
|
|
355
392
|
.map(normalizeGitLabApiUrl),
|
|
356
393
|
httpProxy: HTTP_PROXY,
|
|
357
394
|
httpsProxy: HTTPS_PROXY,
|
|
395
|
+
noProxy: NO_PROXY,
|
|
358
396
|
rejectUnauthorized: NODE_TLS_REJECT_UNAUTHORIZED !== "0",
|
|
359
397
|
caCertPath: GITLAB_CA_CERT_PATH,
|
|
360
398
|
poolMaxSize: GITLAB_POOL_MAX_SIZE,
|
|
@@ -480,11 +518,11 @@ const BASE_HEADERS = {
|
|
|
480
518
|
};
|
|
481
519
|
/**
|
|
482
520
|
* Build authentication headers dynamically based on context
|
|
483
|
-
* In REMOTE_AUTHORIZATION mode, reads from AsyncLocalStorage session context
|
|
521
|
+
* In REMOTE_AUTHORIZATION or GITLAB_MCP_OAUTH mode, reads from AsyncLocalStorage session context
|
|
484
522
|
* Otherwise, uses environment token (OAuth token is refreshed lazily before each tool call)
|
|
485
523
|
*/
|
|
486
524
|
function buildAuthHeaders() {
|
|
487
|
-
if (REMOTE_AUTHORIZATION) {
|
|
525
|
+
if (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) {
|
|
488
526
|
const ctx = sessionAuthStore.getStore();
|
|
489
527
|
logger.debug({ context: ctx }, "buildAuthHeaders: session context");
|
|
490
528
|
if (ctx?.token) {
|
|
@@ -535,7 +573,7 @@ function getEffectiveApiUrl() {
|
|
|
535
573
|
*/
|
|
536
574
|
const getFetchConfig = () => {
|
|
537
575
|
const effectiveApiUrl = getEffectiveApiUrl();
|
|
538
|
-
const agent = clientPool.
|
|
576
|
+
const agent = clientPool.getAgentFunctionForUrl(effectiveApiUrl);
|
|
539
577
|
return {
|
|
540
578
|
headers: { ...BASE_HEADERS, ...buildAuthHeaders() },
|
|
541
579
|
agent: agent,
|
|
@@ -603,6 +641,11 @@ const allTools = [
|
|
|
603
641
|
description: "Get merge request approval details including approvers (uses approval_state when available, falls back to approvals endpoint)",
|
|
604
642
|
inputSchema: toJSONSchema(GetMergeRequestApprovalStateSchema),
|
|
605
643
|
},
|
|
644
|
+
{
|
|
645
|
+
name: "get_merge_request_conflicts",
|
|
646
|
+
description: "Get the conflicts of a merge request in a GitLab project",
|
|
647
|
+
inputSchema: toJSONSchema(GetMergeRequestConflictsSchema),
|
|
648
|
+
},
|
|
606
649
|
{
|
|
607
650
|
name: "execute_graphql",
|
|
608
651
|
description: "Execute a GitLab GraphQL query",
|
|
@@ -663,11 +706,32 @@ const allTools = [
|
|
|
663
706
|
description: "Get the changes/diffs of a merge request (Either mergeRequestIid or branchName must be provided)",
|
|
664
707
|
inputSchema: toJSONSchema(GetMergeRequestDiffsSchema),
|
|
665
708
|
},
|
|
709
|
+
{
|
|
710
|
+
name: "list_merge_request_changed_files",
|
|
711
|
+
description: "STEP 1 of code review workflow. " +
|
|
712
|
+
"Returns ONLY the list of changed file paths in a merge request — WITHOUT diff content. " +
|
|
713
|
+
"Call this first to get file paths, then call get_merge_request_file_diff with multiple files in a single batched call (recommended 3-5 files per call). " +
|
|
714
|
+
"This avoids loading the entire diff payload at once and reduces API calls. " +
|
|
715
|
+
"Supports excluded_file_patterns filtering using regex. " +
|
|
716
|
+
"Returns: new_path, old_path, new_file, deleted_file, renamed_file flags for each file. " +
|
|
717
|
+
"(Either mergeRequestIid or branchName must be provided)",
|
|
718
|
+
inputSchema: toJSONSchema(ListMergeRequestChangedFilesSchema),
|
|
719
|
+
},
|
|
666
720
|
{
|
|
667
721
|
name: "list_merge_request_diffs",
|
|
668
722
|
description: "List merge request diffs with pagination support (Either mergeRequestIid or branchName must be provided)",
|
|
669
723
|
inputSchema: toJSONSchema(ListMergeRequestDiffsSchema),
|
|
670
724
|
},
|
|
725
|
+
{
|
|
726
|
+
name: "get_merge_request_file_diff",
|
|
727
|
+
description: "STEP 2 of code review workflow. " +
|
|
728
|
+
"Get diffs for one or more files from a merge request. " +
|
|
729
|
+
"Call list_merge_request_changed_files first to get file paths, then pass them as an array to fetch their diffs efficiently. " +
|
|
730
|
+
"Batching multiple files (recommended 3-5) is supported and preferred over individual requests. " +
|
|
731
|
+
"Returns an array of results - one per requested file path. Files not found are returned with error messages. " +
|
|
732
|
+
"(Either mergeRequestIid or branchName must be provided)",
|
|
733
|
+
inputSchema: toJSONSchema(GetMergeRequestFileDiffSchema),
|
|
734
|
+
},
|
|
671
735
|
{
|
|
672
736
|
name: "list_merge_request_versions",
|
|
673
737
|
description: "List all versions of a merge request",
|
|
@@ -928,6 +992,31 @@ const allTools = [
|
|
|
928
992
|
description: "Delete a wiki page from a GitLab project",
|
|
929
993
|
inputSchema: toJSONSchema(DeleteWikiPageSchema),
|
|
930
994
|
},
|
|
995
|
+
{
|
|
996
|
+
name: "list_group_wiki_pages",
|
|
997
|
+
description: "List wiki pages in a GitLab group",
|
|
998
|
+
inputSchema: toJSONSchema(ListGroupWikiPagesSchema),
|
|
999
|
+
},
|
|
1000
|
+
{
|
|
1001
|
+
name: "get_group_wiki_page",
|
|
1002
|
+
description: "Get details of a specific group wiki page",
|
|
1003
|
+
inputSchema: toJSONSchema(GetGroupWikiPageSchema),
|
|
1004
|
+
},
|
|
1005
|
+
{
|
|
1006
|
+
name: "create_group_wiki_page",
|
|
1007
|
+
description: "Create a new wiki page in a GitLab group",
|
|
1008
|
+
inputSchema: toJSONSchema(CreateGroupWikiPageSchema),
|
|
1009
|
+
},
|
|
1010
|
+
{
|
|
1011
|
+
name: "update_group_wiki_page",
|
|
1012
|
+
description: "Update an existing wiki page in a GitLab group",
|
|
1013
|
+
inputSchema: toJSONSchema(UpdateGroupWikiPageSchema),
|
|
1014
|
+
},
|
|
1015
|
+
{
|
|
1016
|
+
name: "delete_group_wiki_page",
|
|
1017
|
+
description: "Delete a wiki page from a GitLab group",
|
|
1018
|
+
inputSchema: toJSONSchema(DeleteGroupWikiPageSchema),
|
|
1019
|
+
},
|
|
931
1020
|
{
|
|
932
1021
|
name: "get_repository_tree",
|
|
933
1022
|
description: "Get the repository tree for a GitLab project (list files and directories)",
|
|
@@ -1158,6 +1247,68 @@ const allTools = [
|
|
|
1158
1247
|
description: "Download a release asset file by direct asset path",
|
|
1159
1248
|
inputSchema: toJSONSchema(DownloadReleaseAssetSchema),
|
|
1160
1249
|
},
|
|
1250
|
+
// --- Work item tools (GraphQL-based) ---
|
|
1251
|
+
{
|
|
1252
|
+
name: "get_work_item",
|
|
1253
|
+
description: "Get a single work item with full details including status, hierarchy (parent/children), type, labels, assignees, and all widgets.",
|
|
1254
|
+
inputSchema: toJSONSchema(GetWorkItemSchema),
|
|
1255
|
+
},
|
|
1256
|
+
{
|
|
1257
|
+
name: "list_work_items",
|
|
1258
|
+
description: "List work items in a project with filters (type, state, search, assignees, labels). Returns items with status and hierarchy info.",
|
|
1259
|
+
inputSchema: toJSONSchema(ListWorkItemsSchema),
|
|
1260
|
+
},
|
|
1261
|
+
{
|
|
1262
|
+
name: "create_work_item",
|
|
1263
|
+
description: "Create a new work item (issue, task, incident, test_case, epic, key_result, objective, requirement, ticket). Supports setting title, description, labels, assignees, weight, parent, health status, start/due dates, milestone, and confidentiality.",
|
|
1264
|
+
inputSchema: toJSONSchema(CreateWorkItemSchema),
|
|
1265
|
+
},
|
|
1266
|
+
{
|
|
1267
|
+
name: "update_work_item",
|
|
1268
|
+
description: "Update a work item. Can modify title, description, labels, assignees, weight, state, status, parent hierarchy, children, health status, start/due dates, milestone, confidentiality, linked items, and custom fields.",
|
|
1269
|
+
inputSchema: toJSONSchema(UpdateWorkItemSchema),
|
|
1270
|
+
},
|
|
1271
|
+
{
|
|
1272
|
+
name: "convert_work_item_type",
|
|
1273
|
+
description: "Convert a work item to a different type (e.g. issue to task, task to incident).",
|
|
1274
|
+
inputSchema: toJSONSchema(ConvertWorkItemTypeSchema),
|
|
1275
|
+
},
|
|
1276
|
+
{
|
|
1277
|
+
name: "list_work_item_statuses",
|
|
1278
|
+
description: "List available statuses for a work item type in a project. Requires GitLab Premium/Ultimate with configurable statuses.",
|
|
1279
|
+
inputSchema: toJSONSchema(ListWorkItemStatusesSchema),
|
|
1280
|
+
},
|
|
1281
|
+
{
|
|
1282
|
+
name: "list_custom_field_definitions",
|
|
1283
|
+
description: "List available custom field definitions for a work item type in a project. Returns field names, types, and IDs needed for setting custom fields via update_work_item.",
|
|
1284
|
+
inputSchema: toJSONSchema(ListCustomFieldDefinitionsSchema),
|
|
1285
|
+
},
|
|
1286
|
+
{
|
|
1287
|
+
name: "move_work_item",
|
|
1288
|
+
description: "Move a work item (issue, task, etc.) to a different project. Uses GitLab GraphQL issueMove mutation.",
|
|
1289
|
+
inputSchema: toJSONSchema(MoveWorkItemSchema),
|
|
1290
|
+
},
|
|
1291
|
+
{
|
|
1292
|
+
name: "list_work_item_notes",
|
|
1293
|
+
description: "List notes and discussions on a work item. Returns threaded discussions with author, body, timestamps, and system/internal flags.",
|
|
1294
|
+
inputSchema: toJSONSchema(ListWorkItemNotesSchema),
|
|
1295
|
+
},
|
|
1296
|
+
{
|
|
1297
|
+
name: "create_work_item_note",
|
|
1298
|
+
description: "Add a note/comment to a work item. Supports Markdown, internal notes, and threaded replies.",
|
|
1299
|
+
inputSchema: toJSONSchema(CreateWorkItemNoteSchema),
|
|
1300
|
+
},
|
|
1301
|
+
// --- Incident timeline event tools ---
|
|
1302
|
+
{
|
|
1303
|
+
name: "get_timeline_events",
|
|
1304
|
+
description: "List timeline events for an incident. Returns chronological events with notes, timestamps, and tags (Start time, End time, Impact detected, etc.).",
|
|
1305
|
+
inputSchema: toJSONSchema(GetTimelineEventsSchema),
|
|
1306
|
+
},
|
|
1307
|
+
{
|
|
1308
|
+
name: "create_timeline_event",
|
|
1309
|
+
description: "Create a timeline event on an incident. Supports tags: 'Start time', 'End time', 'Impact detected', 'Response initiated', 'Impact mitigated', 'Cause identified'.",
|
|
1310
|
+
inputSchema: toJSONSchema(CreateTimelineEventSchema),
|
|
1311
|
+
},
|
|
1161
1312
|
{
|
|
1162
1313
|
name: "list_webhooks",
|
|
1163
1314
|
description: "List all configured webhooks for a GitLab project or group. Provide either project_id or group_id.",
|
|
@@ -1173,17 +1324,42 @@ const allTools = [
|
|
|
1173
1324
|
description: "Get full details of a specific webhook event by ID, including request/response payloads. Searches up to 500 most recent events.",
|
|
1174
1325
|
inputSchema: toJSONSchema(GetWebhookEventSchema),
|
|
1175
1326
|
},
|
|
1327
|
+
{
|
|
1328
|
+
name: "search_code",
|
|
1329
|
+
description: "Search for code across all projects on the GitLab instance (requires advanced search or exact code search to be enabled). If exact code search (Zoekt) is enabled, the search query supports rich syntax including file:, lang:, sym: filters.",
|
|
1330
|
+
inputSchema: toJSONSchema(SearchCodeSchema),
|
|
1331
|
+
},
|
|
1332
|
+
{
|
|
1333
|
+
name: "search_project_code",
|
|
1334
|
+
description: "Search for code within a specific GitLab project (requires advanced search or exact code search to be enabled). If exact code search (Zoekt) is enabled, the search query supports rich syntax including file:, lang:, sym: filters.",
|
|
1335
|
+
inputSchema: toJSONSchema(SearchProjectCodeSchema),
|
|
1336
|
+
},
|
|
1337
|
+
{
|
|
1338
|
+
name: "search_group_code",
|
|
1339
|
+
description: "Search for code within a specific GitLab group (requires advanced search or exact code search to be enabled). If exact code search (Zoekt) is enabled, the search query supports rich syntax including file:, lang:, sym: filters.",
|
|
1340
|
+
inputSchema: toJSONSchema(SearchGroupCodeSchema),
|
|
1341
|
+
},
|
|
1176
1342
|
];
|
|
1177
1343
|
// Define which tools are read-only
|
|
1178
1344
|
const readOnlyTools = new Set([
|
|
1179
1345
|
"search_repositories",
|
|
1346
|
+
"search_code",
|
|
1347
|
+
"search_project_code",
|
|
1348
|
+
"search_group_code",
|
|
1180
1349
|
"execute_graphql",
|
|
1181
1350
|
"get_file_contents",
|
|
1182
1351
|
"get_merge_request",
|
|
1183
1352
|
"get_merge_request_diffs",
|
|
1353
|
+
"list_merge_request_changed_files",
|
|
1354
|
+
"list_merge_request_diffs",
|
|
1355
|
+
"get_merge_request_file_diff",
|
|
1184
1356
|
"list_merge_request_versions",
|
|
1185
1357
|
"get_merge_request_version",
|
|
1186
1358
|
"get_branch_diffs",
|
|
1359
|
+
"get_merge_request_note",
|
|
1360
|
+
"get_merge_request_notes",
|
|
1361
|
+
"get_draft_note",
|
|
1362
|
+
"list_draft_notes",
|
|
1187
1363
|
"mr_discussions",
|
|
1188
1364
|
"list_issues",
|
|
1189
1365
|
"my_issues",
|
|
@@ -1222,6 +1398,8 @@ const readOnlyTools = new Set([
|
|
|
1222
1398
|
"get_milestone_burndown_events",
|
|
1223
1399
|
"list_wiki_pages",
|
|
1224
1400
|
"get_wiki_page",
|
|
1401
|
+
"list_group_wiki_pages",
|
|
1402
|
+
"get_group_wiki_page",
|
|
1225
1403
|
"get_users",
|
|
1226
1404
|
"list_commits",
|
|
1227
1405
|
"get_commit",
|
|
@@ -1235,6 +1413,13 @@ const readOnlyTools = new Set([
|
|
|
1235
1413
|
"get_release",
|
|
1236
1414
|
"download_release_asset",
|
|
1237
1415
|
"get_merge_request_approval_state",
|
|
1416
|
+
"get_work_item",
|
|
1417
|
+
"list_work_items",
|
|
1418
|
+
"list_work_item_statuses",
|
|
1419
|
+
"list_custom_field_definitions",
|
|
1420
|
+
"list_work_item_notes",
|
|
1421
|
+
"get_timeline_events",
|
|
1422
|
+
"get_merge_request_conflicts",
|
|
1238
1423
|
"list_webhooks",
|
|
1239
1424
|
"list_webhook_events",
|
|
1240
1425
|
"get_webhook_event",
|
|
@@ -1246,6 +1431,11 @@ const wikiToolNames = new Set([
|
|
|
1246
1431
|
"create_wiki_page",
|
|
1247
1432
|
"update_wiki_page",
|
|
1248
1433
|
"delete_wiki_page",
|
|
1434
|
+
"list_group_wiki_pages",
|
|
1435
|
+
"get_group_wiki_page",
|
|
1436
|
+
"create_group_wiki_page",
|
|
1437
|
+
"update_group_wiki_page",
|
|
1438
|
+
"delete_group_wiki_page",
|
|
1249
1439
|
"upload_wiki_attachment",
|
|
1250
1440
|
]);
|
|
1251
1441
|
// Define which tools are related to milestones and can be toggled by USE_MILESTONE
|
|
@@ -1291,9 +1481,12 @@ const TOOLSET_DEFINITIONS = [
|
|
|
1291
1481
|
"approve_merge_request",
|
|
1292
1482
|
"unapprove_merge_request",
|
|
1293
1483
|
"get_merge_request_approval_state",
|
|
1484
|
+
"get_merge_request_conflicts",
|
|
1294
1485
|
"get_merge_request",
|
|
1295
1486
|
"get_merge_request_diffs",
|
|
1487
|
+
"list_merge_request_changed_files",
|
|
1296
1488
|
"list_merge_request_diffs",
|
|
1489
|
+
"get_merge_request_file_diff",
|
|
1297
1490
|
"list_merge_request_versions",
|
|
1298
1491
|
"get_merge_request_version",
|
|
1299
1492
|
"update_merge_request",
|
|
@@ -1437,6 +1630,11 @@ const TOOLSET_DEFINITIONS = [
|
|
|
1437
1630
|
"create_wiki_page",
|
|
1438
1631
|
"update_wiki_page",
|
|
1439
1632
|
"delete_wiki_page",
|
|
1633
|
+
"list_group_wiki_pages",
|
|
1634
|
+
"get_group_wiki_page",
|
|
1635
|
+
"create_group_wiki_page",
|
|
1636
|
+
"update_group_wiki_page",
|
|
1637
|
+
"delete_group_wiki_page",
|
|
1440
1638
|
]),
|
|
1441
1639
|
},
|
|
1442
1640
|
{
|
|
@@ -1463,6 +1661,24 @@ const TOOLSET_DEFINITIONS = [
|
|
|
1463
1661
|
"download_attachment",
|
|
1464
1662
|
]),
|
|
1465
1663
|
},
|
|
1664
|
+
{
|
|
1665
|
+
id: "workitems",
|
|
1666
|
+
isDefault: false,
|
|
1667
|
+
tools: new Set([
|
|
1668
|
+
"get_work_item",
|
|
1669
|
+
"list_work_items",
|
|
1670
|
+
"create_work_item",
|
|
1671
|
+
"update_work_item",
|
|
1672
|
+
"convert_work_item_type",
|
|
1673
|
+
"list_work_item_statuses",
|
|
1674
|
+
"list_custom_field_definitions",
|
|
1675
|
+
"move_work_item",
|
|
1676
|
+
"list_work_item_notes",
|
|
1677
|
+
"create_work_item_note",
|
|
1678
|
+
"get_timeline_events",
|
|
1679
|
+
"create_timeline_event",
|
|
1680
|
+
]),
|
|
1681
|
+
},
|
|
1466
1682
|
{
|
|
1467
1683
|
id: "webhooks",
|
|
1468
1684
|
isDefault: false,
|
|
@@ -1472,6 +1688,11 @@ const TOOLSET_DEFINITIONS = [
|
|
|
1472
1688
|
"get_webhook_event",
|
|
1473
1689
|
]),
|
|
1474
1690
|
},
|
|
1691
|
+
{
|
|
1692
|
+
id: "search",
|
|
1693
|
+
isDefault: false,
|
|
1694
|
+
tools: new Set(["search_code", "search_project_code", "search_group_code"]),
|
|
1695
|
+
},
|
|
1475
1696
|
];
|
|
1476
1697
|
// Derived lookup: tool name → toolset ID
|
|
1477
1698
|
const TOOLSET_BY_TOOL_NAME = new Map();
|
|
@@ -1583,6 +1804,9 @@ const GITLAB_ALLOWED_PROJECT_IDS = process.env.GITLAB_ALLOWED_PROJECT_IDS?.split
|
|
|
1583
1804
|
const GITLAB_COMMIT_FILES_PER_PAGE = process.env.GITLAB_COMMIT_FILES_PER_PAGE
|
|
1584
1805
|
? Number.parseInt(process.env.GITLAB_COMMIT_FILES_PER_PAGE, 10)
|
|
1585
1806
|
: 20;
|
|
1807
|
+
const GITLAB_REPO_FILE_ENCODING = getConfig("repo-file-encoding", "GITLAB_REPO_FILE_ENCODING", "text") === "base64"
|
|
1808
|
+
? "base64"
|
|
1809
|
+
: "text";
|
|
1586
1810
|
// Validate authentication configuration
|
|
1587
1811
|
if (REMOTE_AUTHORIZATION) {
|
|
1588
1812
|
// Remote authorization mode: token comes from HTTP headers
|
|
@@ -1598,7 +1822,20 @@ if (REMOTE_AUTHORIZATION) {
|
|
|
1598
1822
|
}
|
|
1599
1823
|
logger.info("Remote authorization enabled: tokens will be read from HTTP headers");
|
|
1600
1824
|
}
|
|
1601
|
-
|
|
1825
|
+
if (GITLAB_MCP_OAUTH) {
|
|
1826
|
+
if (SSE) {
|
|
1827
|
+
logger.error("GITLAB_MCP_OAUTH=true is not compatible with SSE transport mode");
|
|
1828
|
+
logger.error("Please use STREAMABLE_HTTP=true instead");
|
|
1829
|
+
process.exit(1);
|
|
1830
|
+
}
|
|
1831
|
+
if (!STREAMABLE_HTTP) {
|
|
1832
|
+
logger.error("GITLAB_MCP_OAUTH=true requires STREAMABLE_HTTP=true");
|
|
1833
|
+
logger.error("Set STREAMABLE_HTTP=true to enable MCP OAuth");
|
|
1834
|
+
process.exit(1);
|
|
1835
|
+
}
|
|
1836
|
+
logger.info("MCP OAuth enabled: GitLab OAuth proxy active");
|
|
1837
|
+
}
|
|
1838
|
+
if (!REMOTE_AUTHORIZATION && !GITLAB_MCP_OAUTH && !USE_OAUTH && !GITLAB_PERSONAL_ACCESS_TOKEN && !GITLAB_JOB_TOKEN && !GITLAB_AUTH_COOKIE_PATH) {
|
|
1602
1839
|
// Standard mode: token must be in environment (unless using OAuth)
|
|
1603
1840
|
logger.error("GITLAB_PERSONAL_ACCESS_TOKEN environment variable is not set");
|
|
1604
1841
|
logger.info("Either set GITLAB_PERSONAL_ACCESS_TOKEN or enable OAuth with GITLAB_USE_OAUTH=true");
|
|
@@ -1647,7 +1884,14 @@ function getEffectiveProjectId(projectId) {
|
|
|
1647
1884
|
}
|
|
1648
1885
|
return projectId || GITLAB_ALLOWED_PROJECT_IDS[0];
|
|
1649
1886
|
}
|
|
1650
|
-
|
|
1887
|
+
// Prioritize the passed projectId over GITLAB_PROJECT_ID to allow querying different projects
|
|
1888
|
+
if (projectId) {
|
|
1889
|
+
return projectId;
|
|
1890
|
+
}
|
|
1891
|
+
if (GITLAB_PROJECT_ID) {
|
|
1892
|
+
return GITLAB_PROJECT_ID;
|
|
1893
|
+
}
|
|
1894
|
+
throw new Error("No project ID provided and GITLAB_PROJECT_ID is not set");
|
|
1651
1895
|
}
|
|
1652
1896
|
/**
|
|
1653
1897
|
* Create a fork of a GitLab project
|
|
@@ -1753,28 +1997,19 @@ async function getFileContents(projectId, filePath, ref) {
|
|
|
1753
1997
|
}
|
|
1754
1998
|
return parsedData;
|
|
1755
1999
|
}
|
|
1756
|
-
/**
|
|
1757
|
-
* Create a new issue in a GitLab project
|
|
1758
|
-
* 이슈 생성 (Create an issue)
|
|
1759
|
-
*
|
|
1760
|
-
* @param {string} projectId - The ID or URL-encoded path of the project
|
|
1761
|
-
* @param {z.infer<typeof CreateIssueOptionsSchema>} options - Issue creation options
|
|
1762
|
-
* @returns {Promise<GitLabIssue>} The created issue
|
|
1763
|
-
*/
|
|
1764
2000
|
async function createIssue(projectId, options) {
|
|
1765
2001
|
projectId = decodeURIComponent(projectId); // Decode project ID
|
|
1766
2002
|
const effectiveProjectId = getEffectiveProjectId(projectId);
|
|
1767
2003
|
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/issues`);
|
|
2004
|
+
// Build request body, converting labels array to comma-separated string
|
|
2005
|
+
const body = { ...options };
|
|
2006
|
+
if (body.labels && Array.isArray(body.labels)) {
|
|
2007
|
+
body.labels = body.labels.join(",");
|
|
2008
|
+
}
|
|
1768
2009
|
const response = await fetch(url.toString(), {
|
|
1769
2010
|
...getFetchConfig(),
|
|
1770
2011
|
method: "POST",
|
|
1771
|
-
body: JSON.stringify(
|
|
1772
|
-
title: options.title,
|
|
1773
|
-
description: options.description,
|
|
1774
|
-
assignee_ids: options.assignee_ids,
|
|
1775
|
-
milestone_id: options.milestone_id,
|
|
1776
|
-
labels: options.labels?.join(","),
|
|
1777
|
-
}),
|
|
2012
|
+
body: JSON.stringify(body),
|
|
1778
2013
|
});
|
|
1779
2014
|
// Handle bad request
|
|
1780
2015
|
if (response.status === 400) {
|
|
@@ -1924,6 +2159,1392 @@ async function deleteIssue(projectId, issueIid) {
|
|
|
1924
2159
|
});
|
|
1925
2160
|
await handleGitLabError(response);
|
|
1926
2161
|
}
|
|
2162
|
+
// --- GraphQL helper ---
|
|
2163
|
+
/**
|
|
2164
|
+
* Execute a GraphQL query against the GitLab instance.
|
|
2165
|
+
* Reusable helper for work item operations.
|
|
2166
|
+
*/
|
|
2167
|
+
async function executeGraphQL(query, variables = {}) {
|
|
2168
|
+
const apiUrl = new URL(getEffectiveApiUrl());
|
|
2169
|
+
const restPath = apiUrl.pathname || "";
|
|
2170
|
+
const idx = restPath.lastIndexOf("/api/v4");
|
|
2171
|
+
const prefix = idx >= 0 ? restPath.slice(0, idx) : "";
|
|
2172
|
+
const graphqlUrl = process.env.GITLAB_GRAPHQL_URL || `${apiUrl.origin}${prefix}/api/graphql`;
|
|
2173
|
+
const response = await fetch(graphqlUrl, {
|
|
2174
|
+
...getFetchConfig(),
|
|
2175
|
+
method: "POST",
|
|
2176
|
+
headers: {
|
|
2177
|
+
...BASE_HEADERS,
|
|
2178
|
+
...buildAuthHeaders(),
|
|
2179
|
+
},
|
|
2180
|
+
body: JSON.stringify({ query, variables }),
|
|
2181
|
+
});
|
|
2182
|
+
if (!response.ok) {
|
|
2183
|
+
const errorBody = await response.text();
|
|
2184
|
+
throw new Error(`GraphQL request failed (${response.status}): ${errorBody}`);
|
|
2185
|
+
}
|
|
2186
|
+
const json = await response.json();
|
|
2187
|
+
if (json.errors && json.errors.length > 0) {
|
|
2188
|
+
throw new Error(`GraphQL errors: ${json.errors.map((e) => e.message).join(", ")}`);
|
|
2189
|
+
}
|
|
2190
|
+
return json.data;
|
|
2191
|
+
}
|
|
2192
|
+
/**
|
|
2193
|
+
* Resolve a project path and issue IID to a work item GraphQL GID.
|
|
2194
|
+
*/
|
|
2195
|
+
async function resolveWorkItemGID(projectId, issueIid) {
|
|
2196
|
+
projectId = decodeURIComponent(projectId);
|
|
2197
|
+
const effectiveProjectId = getEffectiveProjectId(projectId);
|
|
2198
|
+
// First get the project path via REST (needed for GraphQL namespace query)
|
|
2199
|
+
const projectUrl = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}`);
|
|
2200
|
+
const projectResponse = await fetch(projectUrl.toString(), {
|
|
2201
|
+
...getFetchConfig(),
|
|
2202
|
+
});
|
|
2203
|
+
await handleGitLabError(projectResponse);
|
|
2204
|
+
const project = await projectResponse.json();
|
|
2205
|
+
const projectPath = project.path_with_namespace;
|
|
2206
|
+
// Resolve work item GID via GraphQL
|
|
2207
|
+
const data = await executeGraphQL(`query($path: ID!, $iid: String!) {
|
|
2208
|
+
namespace(fullPath: $path) {
|
|
2209
|
+
workItem(iid: $iid) {
|
|
2210
|
+
id
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
}`, { path: projectPath, iid: String(issueIid) });
|
|
2214
|
+
if (!data.namespace?.workItem?.id) {
|
|
2215
|
+
throw new Error(`Work item #${issueIid} not found in project ${projectPath}`);
|
|
2216
|
+
}
|
|
2217
|
+
return { workItemGID: data.namespace.workItem.id, projectPath };
|
|
2218
|
+
}
|
|
2219
|
+
/**
|
|
2220
|
+
* Resolve label names and usernames to GitLab GIDs in a single GraphQL call.
|
|
2221
|
+
*/
|
|
2222
|
+
async function resolveNamesToIds(projectPath, labelNames, usernames) {
|
|
2223
|
+
if (!labelNames?.length && !usernames?.length) {
|
|
2224
|
+
return { labelIds: [], userIds: [] };
|
|
2225
|
+
}
|
|
2226
|
+
const data = await executeGraphQL(`query($path: ID!, $usernames: [String!]!) {
|
|
2227
|
+
project(fullPath: $path) { labels(includeAncestorGroups: true, first: 250) { nodes { id title } } }
|
|
2228
|
+
users(usernames: $usernames) { nodes { id username } }
|
|
2229
|
+
}`, { path: projectPath, usernames: usernames || [] });
|
|
2230
|
+
const labelIds = (labelNames || []).map(name => {
|
|
2231
|
+
const label = data.project.labels.nodes.find(l => l.title === name);
|
|
2232
|
+
if (!label)
|
|
2233
|
+
throw new Error(`Label '${name}' not found in project`);
|
|
2234
|
+
return label.id;
|
|
2235
|
+
});
|
|
2236
|
+
const userIds = (usernames || []).map(name => {
|
|
2237
|
+
const user = data.users.nodes.find(u => u.username === name);
|
|
2238
|
+
if (!user)
|
|
2239
|
+
throw new Error(`User '${name}' not found`);
|
|
2240
|
+
return user.id;
|
|
2241
|
+
});
|
|
2242
|
+
return { labelIds, userIds };
|
|
2243
|
+
}
|
|
2244
|
+
// --- Work item type conversion ---
|
|
2245
|
+
/**
|
|
2246
|
+
* Map user-facing type names to GitLab WorkItemType names for GraphQL queries.
|
|
2247
|
+
*/
|
|
2248
|
+
const WORK_ITEM_TYPE_NAMES = {
|
|
2249
|
+
issue: "Issue",
|
|
2250
|
+
task: "Task",
|
|
2251
|
+
incident: "Incident",
|
|
2252
|
+
test_case: "Test Case",
|
|
2253
|
+
epic: "Epic",
|
|
2254
|
+
key_result: "Key Result",
|
|
2255
|
+
objective: "Objective",
|
|
2256
|
+
requirement: "Requirement",
|
|
2257
|
+
ticket: "Ticket",
|
|
2258
|
+
};
|
|
2259
|
+
/**
|
|
2260
|
+
* Get the GraphQL GID for a work item type by querying the project's available types.
|
|
2261
|
+
*/
|
|
2262
|
+
async function resolveWorkItemTypeGID(projectPath, typeName) {
|
|
2263
|
+
const targetName = WORK_ITEM_TYPE_NAMES[typeName];
|
|
2264
|
+
if (!targetName) {
|
|
2265
|
+
throw new Error(`Unknown work item type: ${typeName}`);
|
|
2266
|
+
}
|
|
2267
|
+
const data = await executeGraphQL(`query($path: ID!) {
|
|
2268
|
+
namespace(fullPath: $path) {
|
|
2269
|
+
workItemTypes {
|
|
2270
|
+
nodes {
|
|
2271
|
+
id
|
|
2272
|
+
name
|
|
2273
|
+
}
|
|
2274
|
+
}
|
|
2275
|
+
}
|
|
2276
|
+
}`, { path: projectPath });
|
|
2277
|
+
const typeNode = data.namespace?.workItemTypes?.nodes?.find((n) => n.name === targetName);
|
|
2278
|
+
if (!typeNode) {
|
|
2279
|
+
throw new Error(`Work item type '${targetName}' not found in project ${projectPath}`);
|
|
2280
|
+
}
|
|
2281
|
+
return typeNode.id;
|
|
2282
|
+
}
|
|
2283
|
+
/**
|
|
2284
|
+
* Convert an issue to a different work item type using GraphQL.
|
|
2285
|
+
*/
|
|
2286
|
+
async function convertIssueType(projectId, issueIid, newType) {
|
|
2287
|
+
const { workItemGID, projectPath } = await resolveWorkItemGID(projectId, issueIid);
|
|
2288
|
+
const workItemTypeGID = await resolveWorkItemTypeGID(projectPath, newType);
|
|
2289
|
+
const data = await executeGraphQL(`mutation($id: WorkItemID!, $typeId: WorkItemsTypeID!) {
|
|
2290
|
+
workItemConvert(input: { id: $id, workItemTypeId: $typeId }) {
|
|
2291
|
+
workItem {
|
|
2292
|
+
id
|
|
2293
|
+
workItemType { name }
|
|
2294
|
+
}
|
|
2295
|
+
errors
|
|
2296
|
+
}
|
|
2297
|
+
}`, { id: workItemGID, typeId: workItemTypeGID });
|
|
2298
|
+
if (data.workItemConvert.errors?.length > 0) {
|
|
2299
|
+
throw new Error(`Conversion failed: ${data.workItemConvert.errors.join(", ")}`);
|
|
2300
|
+
}
|
|
2301
|
+
return {
|
|
2302
|
+
id: data.workItemConvert.workItem.id,
|
|
2303
|
+
type: data.workItemConvert.workItem.workItemType.name,
|
|
2304
|
+
};
|
|
2305
|
+
}
|
|
2306
|
+
// --- Work item hierarchy ---
|
|
2307
|
+
/**
|
|
2308
|
+
* Set a parent for a work item (issue hierarchy).
|
|
2309
|
+
*/
|
|
2310
|
+
async function setIssueParent(projectId, issueIid, parentProjectId, parentIssueIid) {
|
|
2311
|
+
const { workItemGID } = await resolveWorkItemGID(projectId, issueIid);
|
|
2312
|
+
const { workItemGID: parentGID } = await resolveWorkItemGID(parentProjectId, parentIssueIid);
|
|
2313
|
+
const data = await executeGraphQL(`mutation($id: WorkItemID!, $parentId: WorkItemID!) {
|
|
2314
|
+
workItemUpdate(input: { id: $id, hierarchyWidget: { parentId: $parentId } }) {
|
|
2315
|
+
workItem { id }
|
|
2316
|
+
errors
|
|
2317
|
+
}
|
|
2318
|
+
}`, { id: workItemGID, parentId: parentGID });
|
|
2319
|
+
if (data.workItemUpdate.errors?.length > 0) {
|
|
2320
|
+
throw new Error(`Failed to set parent: ${data.workItemUpdate.errors.join(", ")}`);
|
|
2321
|
+
}
|
|
2322
|
+
return { id: workItemGID, parentId: parentGID };
|
|
2323
|
+
}
|
|
2324
|
+
/**
|
|
2325
|
+
* Remove the parent from a work item.
|
|
2326
|
+
*/
|
|
2327
|
+
async function removeIssueParent(projectId, issueIid) {
|
|
2328
|
+
const { workItemGID } = await resolveWorkItemGID(projectId, issueIid);
|
|
2329
|
+
const data = await executeGraphQL(`mutation($id: WorkItemID!) {
|
|
2330
|
+
workItemUpdate(input: { id: $id, hierarchyWidget: { parentId: null } }) {
|
|
2331
|
+
workItem { id }
|
|
2332
|
+
errors
|
|
2333
|
+
}
|
|
2334
|
+
}`, { id: workItemGID });
|
|
2335
|
+
if (data.workItemUpdate.errors?.length > 0) {
|
|
2336
|
+
throw new Error(`Failed to remove parent: ${data.workItemUpdate.errors.join(", ")}`);
|
|
2337
|
+
}
|
|
2338
|
+
}
|
|
2339
|
+
/**
|
|
2340
|
+
* List children of a work item (hierarchy widget).
|
|
2341
|
+
*/
|
|
2342
|
+
async function listIssueChildren(projectId, issueIid) {
|
|
2343
|
+
projectId = decodeURIComponent(projectId);
|
|
2344
|
+
const effectiveProjectId = getEffectiveProjectId(projectId);
|
|
2345
|
+
// Get project path
|
|
2346
|
+
const projectUrl = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}`);
|
|
2347
|
+
const projectResponse = await fetch(projectUrl.toString(), {
|
|
2348
|
+
...getFetchConfig(),
|
|
2349
|
+
});
|
|
2350
|
+
await handleGitLabError(projectResponse);
|
|
2351
|
+
const project = await projectResponse.json();
|
|
2352
|
+
const data = await executeGraphQL(`query($path: ID!, $iid: String!) {
|
|
2353
|
+
namespace(fullPath: $path) {
|
|
2354
|
+
workItem(iid: $iid) {
|
|
2355
|
+
id
|
|
2356
|
+
title
|
|
2357
|
+
widgets {
|
|
2358
|
+
__typename
|
|
2359
|
+
... on WorkItemWidgetHierarchy {
|
|
2360
|
+
parent {
|
|
2361
|
+
id
|
|
2362
|
+
title
|
|
2363
|
+
webUrl
|
|
2364
|
+
workItemType { name }
|
|
2365
|
+
}
|
|
2366
|
+
children {
|
|
2367
|
+
nodes {
|
|
2368
|
+
id
|
|
2369
|
+
title
|
|
2370
|
+
state
|
|
2371
|
+
webUrl
|
|
2372
|
+
workItemType { name }
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
}
|
|
2376
|
+
}
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
2379
|
+
}`, { path: project.path_with_namespace, iid: String(issueIid) });
|
|
2380
|
+
if (!data.namespace?.workItem) {
|
|
2381
|
+
throw new Error(`Work item #${issueIid} not found`);
|
|
2382
|
+
}
|
|
2383
|
+
// Extract hierarchy widget
|
|
2384
|
+
const hierarchyWidget = data.namespace.workItem.widgets?.find((w) => w.__typename === "WorkItemWidgetHierarchy");
|
|
2385
|
+
return {
|
|
2386
|
+
id: data.namespace.workItem.id,
|
|
2387
|
+
title: data.namespace.workItem.title,
|
|
2388
|
+
parent: hierarchyWidget?.parent || null,
|
|
2389
|
+
children: hierarchyWidget?.children?.nodes || [],
|
|
2390
|
+
};
|
|
2391
|
+
}
|
|
2392
|
+
/**
|
|
2393
|
+
* Add a child to a parent work item.
|
|
2394
|
+
*/
|
|
2395
|
+
async function addIssueChild(projectId, issueIid, childProjectId, childIssueIid) {
|
|
2396
|
+
const { workItemGID: parentGID } = await resolveWorkItemGID(projectId, issueIid);
|
|
2397
|
+
const { workItemGID: childGID } = await resolveWorkItemGID(childProjectId, childIssueIid);
|
|
2398
|
+
const data = await executeGraphQL(`mutation($id: WorkItemID!, $childId: WorkItemID!) {
|
|
2399
|
+
workItemUpdate(input: { id: $id, hierarchyWidget: { childrenIds: [$childId] } }) {
|
|
2400
|
+
workItem { id }
|
|
2401
|
+
errors
|
|
2402
|
+
}
|
|
2403
|
+
}`, { id: parentGID, childId: childGID });
|
|
2404
|
+
if (data.workItemUpdate.errors?.length > 0) {
|
|
2405
|
+
throw new Error(`Failed to add child: ${data.workItemUpdate.errors.join(", ")}`);
|
|
2406
|
+
}
|
|
2407
|
+
return { parentId: parentGID, childId: childGID };
|
|
2408
|
+
}
|
|
2409
|
+
/**
|
|
2410
|
+
* Remove a child from a parent work item by setting the child's parent to null.
|
|
2411
|
+
*/
|
|
2412
|
+
async function removeIssueChild(projectId, issueIid, childProjectId, childIssueIid) {
|
|
2413
|
+
// Removing a child is done by removing the parent from the child
|
|
2414
|
+
await removeIssueParent(childProjectId, childIssueIid);
|
|
2415
|
+
}
|
|
2416
|
+
// --- Work item status ---
|
|
2417
|
+
/**
|
|
2418
|
+
* List available statuses for a work item type in a project.
|
|
2419
|
+
* Requires Premium/Ultimate with configurable statuses enabled.
|
|
2420
|
+
*/
|
|
2421
|
+
async function listIssueStatuses(projectId, workItemType = "issue") {
|
|
2422
|
+
projectId = decodeURIComponent(projectId);
|
|
2423
|
+
const effectiveProjectId = getEffectiveProjectId(projectId);
|
|
2424
|
+
// Get project path
|
|
2425
|
+
const projectUrl = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}`);
|
|
2426
|
+
const projectResponse = await fetch(projectUrl.toString(), {
|
|
2427
|
+
...getFetchConfig(),
|
|
2428
|
+
});
|
|
2429
|
+
await handleGitLabError(projectResponse);
|
|
2430
|
+
const project = await projectResponse.json();
|
|
2431
|
+
const typeName = WORK_ITEM_TYPE_NAMES[workItemType] || "Issue";
|
|
2432
|
+
const data = await executeGraphQL(`query($path: ID!, $typeName: IssueType) {
|
|
2433
|
+
namespace(fullPath: $path) {
|
|
2434
|
+
workItemTypes(name: $typeName) {
|
|
2435
|
+
nodes {
|
|
2436
|
+
id
|
|
2437
|
+
name
|
|
2438
|
+
supportedConversionTypes { id name }
|
|
2439
|
+
widgetDefinitions {
|
|
2440
|
+
__typename
|
|
2441
|
+
... on WorkItemWidgetDefinitionStatus {
|
|
2442
|
+
allowedStatuses {
|
|
2443
|
+
id
|
|
2444
|
+
name
|
|
2445
|
+
iconName
|
|
2446
|
+
color
|
|
2447
|
+
position
|
|
2448
|
+
}
|
|
2449
|
+
}
|
|
2450
|
+
... on WorkItemWidgetDefinitionHierarchy {
|
|
2451
|
+
allowedChildTypes { nodes { id name } }
|
|
2452
|
+
allowedParentTypes { nodes { id name } }
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
}
|
|
2456
|
+
}
|
|
2457
|
+
}
|
|
2458
|
+
}`, { path: project.path_with_namespace, typeName: typeName.replace(/ /g, "_").toUpperCase() });
|
|
2459
|
+
const typeNodes = data.namespace?.workItemTypes?.nodes;
|
|
2460
|
+
if (!typeNodes || typeNodes.length === 0) {
|
|
2461
|
+
throw new Error(`Work item type '${typeName}' not found in project`);
|
|
2462
|
+
}
|
|
2463
|
+
const typeNode = typeNodes[0];
|
|
2464
|
+
// Extract statuses from the status widget definition
|
|
2465
|
+
const statusWidget = typeNode.widgetDefinitions?.find((w) => w.__typename === "WorkItemWidgetDefinitionStatus");
|
|
2466
|
+
const statuses = statusWidget?.allowedStatuses || [];
|
|
2467
|
+
// Extract hierarchy info
|
|
2468
|
+
const hierarchyWidget = typeNode.widgetDefinitions?.find((w) => w.__typename === "WorkItemWidgetDefinitionHierarchy");
|
|
2469
|
+
const result = {
|
|
2470
|
+
work_item_type: typeNode.name,
|
|
2471
|
+
statuses_available: statuses.length > 0,
|
|
2472
|
+
statuses,
|
|
2473
|
+
};
|
|
2474
|
+
// Add supported conversion types
|
|
2475
|
+
const conversionTypes = typeNode.supportedConversionTypes || [];
|
|
2476
|
+
if (conversionTypes.length > 0) {
|
|
2477
|
+
result.supported_conversion_types = conversionTypes.map((t) => t.name);
|
|
2478
|
+
}
|
|
2479
|
+
// Add allowed child/parent types
|
|
2480
|
+
const childTypes = hierarchyWidget?.allowedChildTypes?.nodes || [];
|
|
2481
|
+
const parentTypes = hierarchyWidget?.allowedParentTypes?.nodes || [];
|
|
2482
|
+
if (childTypes.length > 0) {
|
|
2483
|
+
result.allowed_child_types = childTypes.map((t) => t.name);
|
|
2484
|
+
}
|
|
2485
|
+
if (parentTypes.length > 0) {
|
|
2486
|
+
result.allowed_parent_types = parentTypes.map((t) => t.name);
|
|
2487
|
+
}
|
|
2488
|
+
return result;
|
|
2489
|
+
}
|
|
2490
|
+
/**
|
|
2491
|
+
* List available custom field definitions for a work item type.
|
|
2492
|
+
*/
|
|
2493
|
+
async function listCustomFieldDefinitions(projectId, workItemType = "issue") {
|
|
2494
|
+
const projectPath = await resolveProjectPath(projectId);
|
|
2495
|
+
const typeName = WORK_ITEM_TYPE_NAMES[workItemType] || "Issue";
|
|
2496
|
+
const data = await executeGraphQL(`query($path: ID!, $typeName: IssueType) {
|
|
2497
|
+
namespace(fullPath: $path) {
|
|
2498
|
+
workItemTypes(name: $typeName) {
|
|
2499
|
+
nodes {
|
|
2500
|
+
id
|
|
2501
|
+
name
|
|
2502
|
+
widgetDefinitions {
|
|
2503
|
+
__typename
|
|
2504
|
+
... on WorkItemWidgetDefinitionCustomFields {
|
|
2505
|
+
customFieldValues {
|
|
2506
|
+
customField {
|
|
2507
|
+
id
|
|
2508
|
+
name
|
|
2509
|
+
fieldType
|
|
2510
|
+
selectOptions { id value }
|
|
2511
|
+
workItemTypes { id name }
|
|
2512
|
+
}
|
|
2513
|
+
}
|
|
2514
|
+
}
|
|
2515
|
+
}
|
|
2516
|
+
}
|
|
2517
|
+
}
|
|
2518
|
+
}
|
|
2519
|
+
}`, { path: projectPath, typeName: typeName.replace(/ /g, "_").toUpperCase() });
|
|
2520
|
+
const typeNodes = data.namespace?.workItemTypes?.nodes;
|
|
2521
|
+
if (!typeNodes || typeNodes.length === 0) {
|
|
2522
|
+
throw new Error(`Work item type '${typeName}' not found in project`);
|
|
2523
|
+
}
|
|
2524
|
+
const typeNode = typeNodes[0];
|
|
2525
|
+
const customFieldsWidget = typeNode.widgetDefinitions?.find((w) => w.__typename === "WorkItemWidgetDefinitionCustomFields");
|
|
2526
|
+
const fields = (customFieldsWidget?.customFieldValues || []).map((cfv) => {
|
|
2527
|
+
const cf = cfv.customField;
|
|
2528
|
+
const field = {
|
|
2529
|
+
id: cf?.id,
|
|
2530
|
+
name: cf?.name,
|
|
2531
|
+
type: cf?.fieldType,
|
|
2532
|
+
};
|
|
2533
|
+
const options = cf?.selectOptions || [];
|
|
2534
|
+
if (options.length > 0)
|
|
2535
|
+
field.selectOptions = options;
|
|
2536
|
+
const types = (cf?.workItemTypes || []).map((t) => t.name);
|
|
2537
|
+
if (types.length > 0)
|
|
2538
|
+
field.workItemTypes = types;
|
|
2539
|
+
return field;
|
|
2540
|
+
});
|
|
2541
|
+
return {
|
|
2542
|
+
work_item_type: typeNode.name,
|
|
2543
|
+
custom_fields: fields,
|
|
2544
|
+
};
|
|
2545
|
+
}
|
|
2546
|
+
/**
|
|
2547
|
+
* Move a work item to a different project.
|
|
2548
|
+
*/
|
|
2549
|
+
async function moveWorkItem(projectId, iid, targetProjectId) {
|
|
2550
|
+
const projectPath = await resolveProjectPath(projectId);
|
|
2551
|
+
const targetPath = await resolveProjectPath(targetProjectId);
|
|
2552
|
+
const data = await executeGraphQL(`mutation($projectPath: ID!, $iid: String!, $targetProjectPath: ID!) {
|
|
2553
|
+
issueMove(input: { projectPath: $projectPath, iid: $iid, targetProjectPath: $targetProjectPath }) {
|
|
2554
|
+
issue { id iid webUrl }
|
|
2555
|
+
errors
|
|
2556
|
+
}
|
|
2557
|
+
}`, { projectPath: projectPath, iid: String(iid), targetProjectPath: targetPath });
|
|
2558
|
+
if (data.issueMove.errors?.length > 0) {
|
|
2559
|
+
throw new Error(`Failed to move work item: ${data.issueMove.errors.join(", ")}`);
|
|
2560
|
+
}
|
|
2561
|
+
return data.issueMove.issue;
|
|
2562
|
+
}
|
|
2563
|
+
/**
|
|
2564
|
+
* List notes/discussions on a work item.
|
|
2565
|
+
*/
|
|
2566
|
+
async function listWorkItemNotes(projectId, iid, options = {}) {
|
|
2567
|
+
const projectPath = await resolveProjectPath(projectId);
|
|
2568
|
+
const data = await executeGraphQL(`query($path: ID!, $iid: String!, $pageSize: Int, $after: String, $sort: WorkItemDiscussionsSort) {
|
|
2569
|
+
namespace(fullPath: $path) {
|
|
2570
|
+
workItem(iid: $iid) {
|
|
2571
|
+
id
|
|
2572
|
+
widgets(onlyTypes: [NOTES]) {
|
|
2573
|
+
... on WorkItemWidgetNotes {
|
|
2574
|
+
discussionLocked
|
|
2575
|
+
discussions(first: $pageSize, after: $after, filter: ALL_NOTES, sort: $sort) {
|
|
2576
|
+
pageInfo { hasNextPage endCursor }
|
|
2577
|
+
nodes {
|
|
2578
|
+
id
|
|
2579
|
+
resolved
|
|
2580
|
+
resolvable
|
|
2581
|
+
notes {
|
|
2582
|
+
nodes {
|
|
2583
|
+
id
|
|
2584
|
+
body
|
|
2585
|
+
system
|
|
2586
|
+
internal
|
|
2587
|
+
createdAt
|
|
2588
|
+
lastEditedAt
|
|
2589
|
+
author { username }
|
|
2590
|
+
}
|
|
2591
|
+
}
|
|
2592
|
+
}
|
|
2593
|
+
}
|
|
2594
|
+
}
|
|
2595
|
+
}
|
|
2596
|
+
}
|
|
2597
|
+
}
|
|
2598
|
+
}`, {
|
|
2599
|
+
path: projectPath,
|
|
2600
|
+
iid: String(iid),
|
|
2601
|
+
pageSize: options.page_size || 20,
|
|
2602
|
+
after: options.after || null,
|
|
2603
|
+
sort: options.sort || "CREATED_ASC",
|
|
2604
|
+
});
|
|
2605
|
+
const workItem = data.namespace?.workItem;
|
|
2606
|
+
if (!workItem) {
|
|
2607
|
+
throw new Error(`Work item #${iid} not found in project ${projectPath}`);
|
|
2608
|
+
}
|
|
2609
|
+
const notesWidget = workItem.widgets?.find((w) => w.discussions);
|
|
2610
|
+
const discussions = notesWidget?.discussions;
|
|
2611
|
+
// Flatten to lean output
|
|
2612
|
+
const items = (discussions?.nodes || []).map((d) => {
|
|
2613
|
+
const notes = (d.notes?.nodes || []).map((n) => {
|
|
2614
|
+
const note = {
|
|
2615
|
+
id: n.id,
|
|
2616
|
+
author: n.author?.username,
|
|
2617
|
+
body: n.body,
|
|
2618
|
+
createdAt: n.createdAt,
|
|
2619
|
+
};
|
|
2620
|
+
if (n.system)
|
|
2621
|
+
note.system = true;
|
|
2622
|
+
if (n.internal)
|
|
2623
|
+
note.internal = true;
|
|
2624
|
+
if (n.lastEditedAt)
|
|
2625
|
+
note.lastEditedAt = n.lastEditedAt;
|
|
2626
|
+
return note;
|
|
2627
|
+
});
|
|
2628
|
+
const discussion = { id: d.id, notes };
|
|
2629
|
+
if (d.resolved)
|
|
2630
|
+
discussion.resolved = true;
|
|
2631
|
+
if (d.resolvable)
|
|
2632
|
+
discussion.resolvable = true;
|
|
2633
|
+
return discussion;
|
|
2634
|
+
});
|
|
2635
|
+
return {
|
|
2636
|
+
discussions: items,
|
|
2637
|
+
pageInfo: discussions?.pageInfo || {},
|
|
2638
|
+
};
|
|
2639
|
+
}
|
|
2640
|
+
/**
|
|
2641
|
+
* Create a note on a work item.
|
|
2642
|
+
*/
|
|
2643
|
+
async function createWorkItemNote(projectId, iid, body, options = {}) {
|
|
2644
|
+
const { workItemGID } = await resolveWorkItemGID(projectId, iid);
|
|
2645
|
+
const varDefs = ["$noteableId: NoteableID!", "$body: String!"];
|
|
2646
|
+
const inputParts = ["noteableId: $noteableId", "body: $body"];
|
|
2647
|
+
const variables = { noteableId: workItemGID, body };
|
|
2648
|
+
if (options.internal) {
|
|
2649
|
+
varDefs.push("$internal: Boolean");
|
|
2650
|
+
inputParts.push("internal: $internal");
|
|
2651
|
+
variables.internal = true;
|
|
2652
|
+
}
|
|
2653
|
+
if (options.discussion_id) {
|
|
2654
|
+
varDefs.push("$discussionId: DiscussionID");
|
|
2655
|
+
inputParts.push("discussionId: $discussionId");
|
|
2656
|
+
variables.discussionId = options.discussion_id;
|
|
2657
|
+
}
|
|
2658
|
+
const data = await executeGraphQL(`mutation(${varDefs.join(", ")}) {
|
|
2659
|
+
createNote(input: { ${inputParts.join(", ")} }) {
|
|
2660
|
+
note {
|
|
2661
|
+
id
|
|
2662
|
+
body
|
|
2663
|
+
discussion { id }
|
|
2664
|
+
}
|
|
2665
|
+
errors
|
|
2666
|
+
}
|
|
2667
|
+
}`, variables);
|
|
2668
|
+
if (data.createNote.errors?.length > 0) {
|
|
2669
|
+
throw new Error(`Failed to create note: ${data.createNote.errors.join(", ")}`);
|
|
2670
|
+
}
|
|
2671
|
+
return data.createNote.note;
|
|
2672
|
+
}
|
|
2673
|
+
// --- Incident Timeline Events ---
|
|
2674
|
+
/**
|
|
2675
|
+
* List timeline events for an incident.
|
|
2676
|
+
*/
|
|
2677
|
+
async function getTimelineEvents(projectId, incidentIid) {
|
|
2678
|
+
const { workItemGID, projectPath } = await resolveWorkItemGID(projectId, incidentIid);
|
|
2679
|
+
// Timeline events expect gid://gitlab/Issue/... not gid://gitlab/WorkItem/...
|
|
2680
|
+
const incidentGID = workItemGID.replace("/WorkItem/", "/Issue/");
|
|
2681
|
+
const data = await executeGraphQL(`query($fullPath: ID!, $incidentId: IssueID!) {
|
|
2682
|
+
project(fullPath: $fullPath) {
|
|
2683
|
+
incidentManagementTimelineEvents(incidentId: $incidentId) {
|
|
2684
|
+
nodes {
|
|
2685
|
+
id
|
|
2686
|
+
note
|
|
2687
|
+
noteHtml
|
|
2688
|
+
action
|
|
2689
|
+
occurredAt
|
|
2690
|
+
createdAt
|
|
2691
|
+
timelineEventTags {
|
|
2692
|
+
nodes {
|
|
2693
|
+
id
|
|
2694
|
+
name
|
|
2695
|
+
}
|
|
2696
|
+
}
|
|
2697
|
+
}
|
|
2698
|
+
}
|
|
2699
|
+
}
|
|
2700
|
+
}`, { fullPath: projectPath, incidentId: incidentGID });
|
|
2701
|
+
const events = data.project?.incidentManagementTimelineEvents?.nodes || [];
|
|
2702
|
+
return events.map((e) => {
|
|
2703
|
+
const event = {
|
|
2704
|
+
id: e.id,
|
|
2705
|
+
note: e.note,
|
|
2706
|
+
action: e.action,
|
|
2707
|
+
occurredAt: e.occurredAt,
|
|
2708
|
+
createdAt: e.createdAt,
|
|
2709
|
+
};
|
|
2710
|
+
if (e.noteHtml)
|
|
2711
|
+
event.noteHtml = e.noteHtml;
|
|
2712
|
+
const tags = (e.timelineEventTags?.nodes || []).map((t) => t.name);
|
|
2713
|
+
if (tags.length > 0)
|
|
2714
|
+
event.tags = tags;
|
|
2715
|
+
return event;
|
|
2716
|
+
});
|
|
2717
|
+
}
|
|
2718
|
+
/**
|
|
2719
|
+
* Create a timeline event on an incident.
|
|
2720
|
+
*/
|
|
2721
|
+
async function createTimelineEvent(projectId, incidentIid, note, occurredAt, tagNames) {
|
|
2722
|
+
const { workItemGID } = await resolveWorkItemGID(projectId, incidentIid);
|
|
2723
|
+
// Timeline events expect gid://gitlab/Issue/... not gid://gitlab/WorkItem/...
|
|
2724
|
+
const incidentGID = workItemGID.replace("/WorkItem/", "/Issue/");
|
|
2725
|
+
const variables = {
|
|
2726
|
+
input: {
|
|
2727
|
+
incidentId: incidentGID,
|
|
2728
|
+
note,
|
|
2729
|
+
occurredAt,
|
|
2730
|
+
},
|
|
2731
|
+
};
|
|
2732
|
+
if (tagNames && tagNames.length > 0) {
|
|
2733
|
+
variables.input.timelineEventTagNames = tagNames;
|
|
2734
|
+
}
|
|
2735
|
+
const data = await executeGraphQL(`mutation CreateTimelineEvent($input: TimelineEventCreateInput!) {
|
|
2736
|
+
timelineEventCreate(input: $input) {
|
|
2737
|
+
timelineEvent {
|
|
2738
|
+
id
|
|
2739
|
+
note
|
|
2740
|
+
noteHtml
|
|
2741
|
+
action
|
|
2742
|
+
occurredAt
|
|
2743
|
+
createdAt
|
|
2744
|
+
timelineEventTags {
|
|
2745
|
+
nodes {
|
|
2746
|
+
id
|
|
2747
|
+
name
|
|
2748
|
+
}
|
|
2749
|
+
}
|
|
2750
|
+
}
|
|
2751
|
+
errors
|
|
2752
|
+
}
|
|
2753
|
+
}`, variables);
|
|
2754
|
+
if (data.timelineEventCreate.errors?.length > 0) {
|
|
2755
|
+
throw new Error(`Failed to create timeline event: ${data.timelineEventCreate.errors.join(", ")}`);
|
|
2756
|
+
}
|
|
2757
|
+
const e = data.timelineEventCreate.timelineEvent;
|
|
2758
|
+
const result = {
|
|
2759
|
+
id: e.id,
|
|
2760
|
+
note: e.note,
|
|
2761
|
+
action: e.action,
|
|
2762
|
+
occurredAt: e.occurredAt,
|
|
2763
|
+
createdAt: e.createdAt,
|
|
2764
|
+
};
|
|
2765
|
+
if (e.noteHtml)
|
|
2766
|
+
result.noteHtml = e.noteHtml;
|
|
2767
|
+
const tags = (e.timelineEventTags?.nodes || []).map((t) => t.name);
|
|
2768
|
+
if (tags.length > 0)
|
|
2769
|
+
result.tags = tags;
|
|
2770
|
+
return result;
|
|
2771
|
+
}
|
|
2772
|
+
/**
|
|
2773
|
+
* Update the severity of an incident.
|
|
2774
|
+
* Accepts projectPath directly to avoid redundant REST calls when called from updateWorkItem.
|
|
2775
|
+
*/
|
|
2776
|
+
async function updateIncidentSeverity(projectPath, incidentIid, severity) {
|
|
2777
|
+
const data = await executeGraphQL(`mutation($projectPath: ID!, $severity: IssuableSeverity!, $iid: String!) {
|
|
2778
|
+
issueSetSeverity(input: { iid: $iid, severity: $severity, projectPath: $projectPath }) {
|
|
2779
|
+
errors
|
|
2780
|
+
issue {
|
|
2781
|
+
iid
|
|
2782
|
+
id
|
|
2783
|
+
severity
|
|
2784
|
+
}
|
|
2785
|
+
}
|
|
2786
|
+
}`, { projectPath, severity, iid: String(incidentIid) });
|
|
2787
|
+
if (data.issueSetSeverity.errors?.length > 0) {
|
|
2788
|
+
throw new Error(`Failed to set severity: ${data.issueSetSeverity.errors.join(", ")}`);
|
|
2789
|
+
}
|
|
2790
|
+
return data.issueSetSeverity.issue;
|
|
2791
|
+
}
|
|
2792
|
+
/**
|
|
2793
|
+
* Update the escalation status of an incident.
|
|
2794
|
+
* Accepts projectPath directly to avoid redundant REST calls when called from updateWorkItem.
|
|
2795
|
+
*/
|
|
2796
|
+
async function updateIncidentEscalationStatus(projectPath, incidentIid, status) {
|
|
2797
|
+
const data = await executeGraphQL(`mutation($projectPath: ID!, $status: IssueEscalationStatus!, $iid: String!) {
|
|
2798
|
+
issueSetEscalationStatus(input: { projectPath: $projectPath, status: $status, iid: $iid }) {
|
|
2799
|
+
errors
|
|
2800
|
+
issue {
|
|
2801
|
+
id
|
|
2802
|
+
escalationStatus
|
|
2803
|
+
}
|
|
2804
|
+
}
|
|
2805
|
+
}`, { projectPath, status, iid: String(incidentIid) });
|
|
2806
|
+
if (data.issueSetEscalationStatus.errors?.length > 0) {
|
|
2807
|
+
throw new Error(`Failed to set escalation status: ${data.issueSetEscalationStatus.errors.join(", ")}`);
|
|
2808
|
+
}
|
|
2809
|
+
return data.issueSetEscalationStatus.issue;
|
|
2810
|
+
}
|
|
2811
|
+
/**
|
|
2812
|
+
* Set the status of a work item.
|
|
2813
|
+
*/
|
|
2814
|
+
async function setIssueStatus(projectId, issueIid, status) {
|
|
2815
|
+
const { workItemGID } = await resolveWorkItemGID(projectId, issueIid);
|
|
2816
|
+
const data = await executeGraphQL(`mutation($id: WorkItemID!, $status: WorkItemsStatusesStatusID!) {
|
|
2817
|
+
workItemUpdate(input: { id: $id, statusWidget: { status: $status } }) {
|
|
2818
|
+
workItem {
|
|
2819
|
+
id
|
|
2820
|
+
widgets {
|
|
2821
|
+
__typename
|
|
2822
|
+
... on WorkItemWidgetStatus {
|
|
2823
|
+
status { id name category color }
|
|
2824
|
+
}
|
|
2825
|
+
}
|
|
2826
|
+
}
|
|
2827
|
+
errors
|
|
2828
|
+
}
|
|
2829
|
+
}`, { id: workItemGID, status });
|
|
2830
|
+
if (data.workItemUpdate.errors?.length > 0) {
|
|
2831
|
+
throw new Error(`Failed to set status: ${data.workItemUpdate.errors.join(", ")}`);
|
|
2832
|
+
}
|
|
2833
|
+
// Extract the current status from the response
|
|
2834
|
+
const statusWidget = data.workItemUpdate.workItem?.widgets?.find((w) => w.__typename === "WorkItemWidgetStatus");
|
|
2835
|
+
return {
|
|
2836
|
+
id: data.workItemUpdate.workItem.id,
|
|
2837
|
+
status: statusWidget?.status || null,
|
|
2838
|
+
};
|
|
2839
|
+
}
|
|
2840
|
+
/**
|
|
2841
|
+
* Resolve a project ID (numeric or path) to its full path_with_namespace.
|
|
2842
|
+
*/
|
|
2843
|
+
async function resolveProjectPath(projectId) {
|
|
2844
|
+
projectId = decodeURIComponent(projectId);
|
|
2845
|
+
const effectiveProjectId = getEffectiveProjectId(projectId);
|
|
2846
|
+
const projectUrl = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}`);
|
|
2847
|
+
const projectResponse = await fetch(projectUrl.toString(), {
|
|
2848
|
+
...getFetchConfig(),
|
|
2849
|
+
});
|
|
2850
|
+
await handleGitLabError(projectResponse);
|
|
2851
|
+
const project = await projectResponse.json();
|
|
2852
|
+
return project.path_with_namespace;
|
|
2853
|
+
}
|
|
2854
|
+
/**
|
|
2855
|
+
* Get a single work item with all widget data.
|
|
2856
|
+
*/
|
|
2857
|
+
async function getWorkItem(projectId, iid) {
|
|
2858
|
+
const projectPath = await resolveProjectPath(projectId);
|
|
2859
|
+
const data = await executeGraphQL(`query($path: ID!, $iid: String!) {
|
|
2860
|
+
namespace(fullPath: $path) {
|
|
2861
|
+
workItem(iid: $iid) {
|
|
2862
|
+
id
|
|
2863
|
+
iid
|
|
2864
|
+
title
|
|
2865
|
+
state
|
|
2866
|
+
description
|
|
2867
|
+
webUrl
|
|
2868
|
+
confidential
|
|
2869
|
+
author { username }
|
|
2870
|
+
createdAt
|
|
2871
|
+
closedAt
|
|
2872
|
+
workItemType { name }
|
|
2873
|
+
widgets {
|
|
2874
|
+
__typename
|
|
2875
|
+
... on WorkItemWidgetHierarchy {
|
|
2876
|
+
hasChildren hasParent
|
|
2877
|
+
parent { id iid title webUrl workItemType { name } namespace { fullPath } }
|
|
2878
|
+
children { nodes { id iid title state webUrl workItemType { name } namespace { fullPath } } }
|
|
2879
|
+
}
|
|
2880
|
+
... on WorkItemWidgetStatus { status { id name category color iconName position } }
|
|
2881
|
+
... on WorkItemWidgetCustomFields {
|
|
2882
|
+
customFieldValues {
|
|
2883
|
+
__typename
|
|
2884
|
+
customField { id name fieldType }
|
|
2885
|
+
... on WorkItemNumberFieldValue { value }
|
|
2886
|
+
... on WorkItemTextFieldValue { value }
|
|
2887
|
+
... on WorkItemSelectFieldValue {
|
|
2888
|
+
selectedOptions { id value }
|
|
2889
|
+
}
|
|
2890
|
+
}
|
|
2891
|
+
}
|
|
2892
|
+
... on WorkItemWidgetLabels { labels { nodes { id title color } } }
|
|
2893
|
+
... on WorkItemWidgetAssignees { assignees { nodes { id username name } } }
|
|
2894
|
+
... on WorkItemWidgetWeight { weight rolledUpWeight rolledUpCompletedWeight }
|
|
2895
|
+
... on WorkItemWidgetHealthStatus { healthStatus }
|
|
2896
|
+
... on WorkItemWidgetStartAndDueDate { startDate dueDate }
|
|
2897
|
+
... on WorkItemWidgetMilestone { milestone { id title } }
|
|
2898
|
+
... on WorkItemWidgetLinkedItems {
|
|
2899
|
+
blocked blockedByCount blockingCount
|
|
2900
|
+
linkedItems { nodes { linkType workItem { id iid title state webUrl workItemType { name } namespace { fullPath } } } }
|
|
2901
|
+
}
|
|
2902
|
+
... on WorkItemWidgetTimeTracking {
|
|
2903
|
+
timeEstimate totalTimeSpent
|
|
2904
|
+
}
|
|
2905
|
+
... on WorkItemWidgetDevelopment {
|
|
2906
|
+
willAutoCloseByMergeRequest
|
|
2907
|
+
relatedBranches { nodes { name } }
|
|
2908
|
+
relatedMergeRequests {
|
|
2909
|
+
nodes { iid title webUrl state sourceBranch }
|
|
2910
|
+
}
|
|
2911
|
+
closingMergeRequests {
|
|
2912
|
+
nodes {
|
|
2913
|
+
mergeRequest { iid title webUrl state sourceBranch }
|
|
2914
|
+
}
|
|
2915
|
+
}
|
|
2916
|
+
featureFlags { nodes { name active } }
|
|
2917
|
+
}
|
|
2918
|
+
... on WorkItemWidgetIteration {
|
|
2919
|
+
iteration { id title startDate dueDate webUrl iterationCadence { id title } }
|
|
2920
|
+
}
|
|
2921
|
+
... on WorkItemWidgetProgress { progress }
|
|
2922
|
+
... on WorkItemWidgetColor { color textColor }
|
|
2923
|
+
}
|
|
2924
|
+
}
|
|
2925
|
+
}
|
|
2926
|
+
}`, { path: projectPath, iid: String(iid) });
|
|
2927
|
+
if (!data.namespace?.workItem) {
|
|
2928
|
+
throw new Error(`Work item #${iid} not found in project ${projectPath}`);
|
|
2929
|
+
}
|
|
2930
|
+
const wi = data.namespace.workItem;
|
|
2931
|
+
const widgets = wi.widgets || [];
|
|
2932
|
+
// Flatten widget data into a clean response
|
|
2933
|
+
const hierarchyWidget = widgets.find((w) => w.__typename === "WorkItemWidgetHierarchy");
|
|
2934
|
+
const statusWidget = widgets.find((w) => w.__typename === "WorkItemWidgetStatus");
|
|
2935
|
+
const labelsWidget = widgets.find((w) => w.__typename === "WorkItemWidgetLabels");
|
|
2936
|
+
const assigneesWidget = widgets.find((w) => w.__typename === "WorkItemWidgetAssignees");
|
|
2937
|
+
const weightWidget = widgets.find((w) => w.__typename === "WorkItemWidgetWeight");
|
|
2938
|
+
const healthStatusWidget = widgets.find((w) => w.__typename === "WorkItemWidgetHealthStatus");
|
|
2939
|
+
const datesWidget = widgets.find((w) => w.__typename === "WorkItemWidgetStartAndDueDate");
|
|
2940
|
+
const milestoneWidget = widgets.find((w) => w.__typename === "WorkItemWidgetMilestone");
|
|
2941
|
+
const linkedItemsWidget = widgets.find((w) => w.__typename === "WorkItemWidgetLinkedItems");
|
|
2942
|
+
const timeTrackingWidget = widgets.find((w) => w.__typename === "WorkItemWidgetTimeTracking");
|
|
2943
|
+
const developmentWidget = widgets.find((w) => w.__typename === "WorkItemWidgetDevelopment");
|
|
2944
|
+
const customFieldsWidget = widgets.find((w) => w.__typename === "WorkItemWidgetCustomFields");
|
|
2945
|
+
// Build response, omitting null/empty values to keep output lean
|
|
2946
|
+
const result = {
|
|
2947
|
+
id: wi.id,
|
|
2948
|
+
iid: wi.iid,
|
|
2949
|
+
title: wi.title,
|
|
2950
|
+
state: wi.state,
|
|
2951
|
+
type: wi.workItemType?.name,
|
|
2952
|
+
webUrl: wi.webUrl,
|
|
2953
|
+
};
|
|
2954
|
+
if (wi.description)
|
|
2955
|
+
result.description = wi.description;
|
|
2956
|
+
if (wi.confidential)
|
|
2957
|
+
result.confidential = true;
|
|
2958
|
+
if (wi.author?.username)
|
|
2959
|
+
result.author = wi.author.username;
|
|
2960
|
+
if (wi.createdAt)
|
|
2961
|
+
result.createdAt = wi.createdAt;
|
|
2962
|
+
if (wi.closedAt)
|
|
2963
|
+
result.closedAt = wi.closedAt;
|
|
2964
|
+
if (statusWidget?.status)
|
|
2965
|
+
result.status = { name: statusWidget.status.name, id: statusWidget.status.id, category: statusWidget.status.category };
|
|
2966
|
+
const labels = (labelsWidget?.labels?.nodes || []).map((l) => l.title);
|
|
2967
|
+
if (labels.length > 0)
|
|
2968
|
+
result.labels = labels;
|
|
2969
|
+
const assignees = (assigneesWidget?.assignees?.nodes || []).map((a) => a.username);
|
|
2970
|
+
if (assignees.length > 0)
|
|
2971
|
+
result.assignees = assignees;
|
|
2972
|
+
if (weightWidget?.weight != null) {
|
|
2973
|
+
result.weight = weightWidget.weight;
|
|
2974
|
+
if (weightWidget.rolledUpWeight != null)
|
|
2975
|
+
result.rolledUpWeight = weightWidget.rolledUpWeight;
|
|
2976
|
+
if (weightWidget.rolledUpCompletedWeight != null)
|
|
2977
|
+
result.rolledUpCompletedWeight = weightWidget.rolledUpCompletedWeight;
|
|
2978
|
+
}
|
|
2979
|
+
if (healthStatusWidget?.healthStatus)
|
|
2980
|
+
result.healthStatus = healthStatusWidget.healthStatus;
|
|
2981
|
+
if (datesWidget?.startDate)
|
|
2982
|
+
result.startDate = datesWidget.startDate;
|
|
2983
|
+
if (datesWidget?.dueDate)
|
|
2984
|
+
result.dueDate = datesWidget.dueDate;
|
|
2985
|
+
if (milestoneWidget?.milestone)
|
|
2986
|
+
result.milestone = { id: milestoneWidget.milestone.id, title: milestoneWidget.milestone.title };
|
|
2987
|
+
const iterationWidget = widgets.find((w) => w.__typename === "WorkItemWidgetIteration");
|
|
2988
|
+
if (iterationWidget?.iteration) {
|
|
2989
|
+
result.iteration = {
|
|
2990
|
+
id: iterationWidget.iteration.id,
|
|
2991
|
+
title: iterationWidget.iteration.title,
|
|
2992
|
+
startDate: iterationWidget.iteration.startDate,
|
|
2993
|
+
dueDate: iterationWidget.iteration.dueDate,
|
|
2994
|
+
};
|
|
2995
|
+
}
|
|
2996
|
+
const progressWidget = widgets.find((w) => w.__typename === "WorkItemWidgetProgress");
|
|
2997
|
+
if (progressWidget?.progress != null)
|
|
2998
|
+
result.progress = progressWidget.progress;
|
|
2999
|
+
const colorWidget = widgets.find((w) => w.__typename === "WorkItemWidgetColor");
|
|
3000
|
+
if (colorWidget?.color)
|
|
3001
|
+
result.color = colorWidget.color;
|
|
3002
|
+
if (hierarchyWidget?.parent)
|
|
3003
|
+
result.parent = { iid: hierarchyWidget.parent.iid, title: hierarchyWidget.parent.title, type: hierarchyWidget.parent.workItemType?.name, project: hierarchyWidget.parent.namespace?.fullPath, webUrl: hierarchyWidget.parent.webUrl };
|
|
3004
|
+
const children = hierarchyWidget?.children?.nodes || [];
|
|
3005
|
+
if (children.length > 0)
|
|
3006
|
+
result.children = children.map((c) => ({ iid: c.iid, title: c.title, state: c.state, type: c.workItemType?.name, project: c.namespace?.fullPath, webUrl: c.webUrl }));
|
|
3007
|
+
if (linkedItemsWidget?.blocked)
|
|
3008
|
+
result.blocked = true;
|
|
3009
|
+
if (linkedItemsWidget?.blockedByCount > 0)
|
|
3010
|
+
result.blockedByCount = linkedItemsWidget.blockedByCount;
|
|
3011
|
+
if (linkedItemsWidget?.blockingCount > 0)
|
|
3012
|
+
result.blockingCount = linkedItemsWidget.blockingCount;
|
|
3013
|
+
const linkedNodes = linkedItemsWidget?.linkedItems?.nodes || [];
|
|
3014
|
+
if (linkedNodes.length > 0) {
|
|
3015
|
+
result.linkedItems = linkedNodes.map((n) => ({
|
|
3016
|
+
linkType: n.linkType,
|
|
3017
|
+
iid: n.workItem?.iid,
|
|
3018
|
+
title: n.workItem?.title,
|
|
3019
|
+
state: n.workItem?.state,
|
|
3020
|
+
type: n.workItem?.workItemType?.name,
|
|
3021
|
+
project: n.workItem?.namespace?.fullPath,
|
|
3022
|
+
webUrl: n.workItem?.webUrl,
|
|
3023
|
+
}));
|
|
3024
|
+
}
|
|
3025
|
+
if (timeTrackingWidget?.timeEstimate > 0)
|
|
3026
|
+
result.timeEstimate = timeTrackingWidget.timeEstimate;
|
|
3027
|
+
if (timeTrackingWidget?.totalTimeSpent > 0)
|
|
3028
|
+
result.totalTimeSpent = timeTrackingWidget.totalTimeSpent;
|
|
3029
|
+
// Development: only include if there's actual data
|
|
3030
|
+
const relatedMRs = developmentWidget?.relatedMergeRequests?.nodes || [];
|
|
3031
|
+
const closingMRs = (developmentWidget?.closingMergeRequests?.nodes || []).map((n) => n.mergeRequest);
|
|
3032
|
+
const branches = developmentWidget?.relatedBranches?.nodes || [];
|
|
3033
|
+
const flags = developmentWidget?.featureFlags?.nodes || [];
|
|
3034
|
+
if (relatedMRs.length > 0 || closingMRs.length > 0 || branches.length > 0 || flags.length > 0) {
|
|
3035
|
+
const dev = {};
|
|
3036
|
+
if (relatedMRs.length > 0)
|
|
3037
|
+
dev.relatedMergeRequests = relatedMRs;
|
|
3038
|
+
if (closingMRs.length > 0)
|
|
3039
|
+
dev.closingMergeRequests = closingMRs;
|
|
3040
|
+
if (branches.length > 0)
|
|
3041
|
+
dev.relatedBranches = branches.map((b) => b.name);
|
|
3042
|
+
if (flags.length > 0)
|
|
3043
|
+
dev.featureFlags = flags;
|
|
3044
|
+
result.development = dev;
|
|
3045
|
+
}
|
|
3046
|
+
const cfValues = (customFieldsWidget?.customFieldValues || []).filter((cfv) => cfv.value != null || cfv.selectedOptions != null);
|
|
3047
|
+
if (cfValues.length > 0) {
|
|
3048
|
+
result.customFields = cfValues.map((cfv) => ({
|
|
3049
|
+
name: cfv.customField?.name,
|
|
3050
|
+
type: cfv.customField?.fieldType,
|
|
3051
|
+
value: cfv.value ?? cfv.selectedOptions ?? null,
|
|
3052
|
+
}));
|
|
3053
|
+
}
|
|
3054
|
+
return result;
|
|
3055
|
+
}
|
|
3056
|
+
/**
|
|
3057
|
+
* List work items in a project with filters.
|
|
3058
|
+
*/
|
|
3059
|
+
async function listWorkItems(projectId, options) {
|
|
3060
|
+
const projectPath = await resolveProjectPath(projectId);
|
|
3061
|
+
// Map type names to GraphQL enum values
|
|
3062
|
+
const typeMap = {
|
|
3063
|
+
issue: "ISSUE",
|
|
3064
|
+
task: "TASK",
|
|
3065
|
+
incident: "INCIDENT",
|
|
3066
|
+
test_case: "TEST_CASE",
|
|
3067
|
+
epic: "EPIC",
|
|
3068
|
+
key_result: "KEY_RESULT",
|
|
3069
|
+
objective: "OBJECTIVE",
|
|
3070
|
+
requirement: "REQUIREMENT",
|
|
3071
|
+
ticket: "TICKET",
|
|
3072
|
+
};
|
|
3073
|
+
const variables = {
|
|
3074
|
+
path: projectPath,
|
|
3075
|
+
first: options.first || 20,
|
|
3076
|
+
};
|
|
3077
|
+
if (options.types && options.types.length > 0) {
|
|
3078
|
+
variables.types = options.types.map((t) => typeMap[t] || t.replace(/ /g, "_").toUpperCase());
|
|
3079
|
+
}
|
|
3080
|
+
if (options.state) {
|
|
3081
|
+
variables.state = options.state === "opened" ? "opened" : "closed";
|
|
3082
|
+
}
|
|
3083
|
+
if (options.search) {
|
|
3084
|
+
variables.search = options.search;
|
|
3085
|
+
}
|
|
3086
|
+
if (options.assignee_usernames && options.assignee_usernames.length > 0) {
|
|
3087
|
+
variables.assigneeUsernames = options.assignee_usernames;
|
|
3088
|
+
}
|
|
3089
|
+
if (options.label_names && options.label_names.length > 0) {
|
|
3090
|
+
variables.labelName = options.label_names;
|
|
3091
|
+
}
|
|
3092
|
+
if (options.after) {
|
|
3093
|
+
variables.after = options.after;
|
|
3094
|
+
}
|
|
3095
|
+
const data = await executeGraphQL(`query($path: ID!, $types: [IssueType!], $state: IssuableState, $search: String, $assigneeUsernames: [String!], $labelName: [String!], $first: Int, $after: String) {
|
|
3096
|
+
project(fullPath: $path) {
|
|
3097
|
+
workItems(types: $types, state: $state, search: $search, assigneeUsernames: $assigneeUsernames, labelName: $labelName, first: $first, after: $after) {
|
|
3098
|
+
nodes {
|
|
3099
|
+
id iid title state webUrl workItemType { name }
|
|
3100
|
+
widgets {
|
|
3101
|
+
__typename
|
|
3102
|
+
... on WorkItemWidgetStatus { status { id name category color } }
|
|
3103
|
+
... on WorkItemWidgetLabels { labels { nodes { title } } }
|
|
3104
|
+
... on WorkItemWidgetAssignees { assignees { nodes { username } } }
|
|
3105
|
+
... on WorkItemWidgetWeight { weight }
|
|
3106
|
+
... on WorkItemWidgetHealthStatus { healthStatus }
|
|
3107
|
+
... on WorkItemWidgetStartAndDueDate { startDate dueDate }
|
|
3108
|
+
... on WorkItemWidgetMilestone { milestone { id title } }
|
|
3109
|
+
}
|
|
3110
|
+
}
|
|
3111
|
+
pageInfo { hasNextPage endCursor }
|
|
3112
|
+
}
|
|
3113
|
+
}
|
|
3114
|
+
}`, variables);
|
|
3115
|
+
const workItems = data.project?.workItems?.nodes || [];
|
|
3116
|
+
const pageInfo = data.project?.workItems?.pageInfo || {};
|
|
3117
|
+
// Flatten widget data for each item
|
|
3118
|
+
const items = workItems.map((wi) => {
|
|
3119
|
+
const widgets = wi.widgets || [];
|
|
3120
|
+
const statusWidget = widgets.find((w) => w.__typename === "WorkItemWidgetStatus");
|
|
3121
|
+
const labelsWidget = widgets.find((w) => w.__typename === "WorkItemWidgetLabels");
|
|
3122
|
+
const assigneesWidget = widgets.find((w) => w.__typename === "WorkItemWidgetAssignees");
|
|
3123
|
+
const weightWidget = widgets.find((w) => w.__typename === "WorkItemWidgetWeight");
|
|
3124
|
+
const healthStatusWidget = widgets.find((w) => w.__typename === "WorkItemWidgetHealthStatus");
|
|
3125
|
+
const datesWidget = widgets.find((w) => w.__typename === "WorkItemWidgetStartAndDueDate");
|
|
3126
|
+
const milestoneWidget = widgets.find((w) => w.__typename === "WorkItemWidgetMilestone");
|
|
3127
|
+
const item = {
|
|
3128
|
+
iid: wi.iid,
|
|
3129
|
+
title: wi.title,
|
|
3130
|
+
state: wi.state,
|
|
3131
|
+
type: wi.workItemType?.name,
|
|
3132
|
+
webUrl: wi.webUrl,
|
|
3133
|
+
};
|
|
3134
|
+
if (statusWidget?.status)
|
|
3135
|
+
item.status = statusWidget.status.name;
|
|
3136
|
+
const labels = (labelsWidget?.labels?.nodes || []).map((l) => l.title);
|
|
3137
|
+
if (labels.length > 0)
|
|
3138
|
+
item.labels = labels;
|
|
3139
|
+
const assignees = (assigneesWidget?.assignees?.nodes || []).map((a) => a.username);
|
|
3140
|
+
if (assignees.length > 0)
|
|
3141
|
+
item.assignees = assignees;
|
|
3142
|
+
if (weightWidget?.weight != null)
|
|
3143
|
+
item.weight = weightWidget.weight;
|
|
3144
|
+
if (healthStatusWidget?.healthStatus)
|
|
3145
|
+
item.healthStatus = healthStatusWidget.healthStatus;
|
|
3146
|
+
if (datesWidget?.startDate)
|
|
3147
|
+
item.startDate = datesWidget.startDate;
|
|
3148
|
+
if (datesWidget?.dueDate)
|
|
3149
|
+
item.dueDate = datesWidget.dueDate;
|
|
3150
|
+
if (milestoneWidget?.milestone)
|
|
3151
|
+
item.milestone = milestoneWidget.milestone.title;
|
|
3152
|
+
return item;
|
|
3153
|
+
});
|
|
3154
|
+
return { items, pageInfo };
|
|
3155
|
+
}
|
|
3156
|
+
/**
|
|
3157
|
+
* Create a new work item using GraphQL.
|
|
3158
|
+
*/
|
|
3159
|
+
async function createWorkItem(projectId, options) {
|
|
3160
|
+
const projectPath = await resolveProjectPath(projectId);
|
|
3161
|
+
const typeName = options.type || "issue";
|
|
3162
|
+
const typeGID = await resolveWorkItemTypeGID(projectPath, typeName);
|
|
3163
|
+
// Build the input dynamically - only include widgets that have values
|
|
3164
|
+
const inputFields = [
|
|
3165
|
+
"$projectPath: ID!",
|
|
3166
|
+
"$title: String!",
|
|
3167
|
+
"$typeId: WorkItemsTypeID!",
|
|
3168
|
+
];
|
|
3169
|
+
const inputValues = [
|
|
3170
|
+
"namespacePath: $projectPath",
|
|
3171
|
+
"title: $title",
|
|
3172
|
+
"workItemTypeId: $typeId",
|
|
3173
|
+
];
|
|
3174
|
+
const variables = {
|
|
3175
|
+
projectPath,
|
|
3176
|
+
title: options.title,
|
|
3177
|
+
typeId: typeGID,
|
|
3178
|
+
};
|
|
3179
|
+
if (options.description !== undefined) {
|
|
3180
|
+
inputFields.push("$description: String!");
|
|
3181
|
+
inputValues.push("descriptionWidget: { description: $description }");
|
|
3182
|
+
variables.description = options.description;
|
|
3183
|
+
}
|
|
3184
|
+
// Resolve label names and usernames to GIDs in a single GraphQL call
|
|
3185
|
+
const { labelIds, userIds } = await resolveNamesToIds(projectPath, options.labels, options.assignee_usernames);
|
|
3186
|
+
if (labelIds.length > 0) {
|
|
3187
|
+
inputFields.push("$labelIds: [LabelID!]!");
|
|
3188
|
+
inputValues.push("labelsWidget: { labelIds: $labelIds }");
|
|
3189
|
+
variables.labelIds = labelIds;
|
|
3190
|
+
}
|
|
3191
|
+
if (options.weight !== undefined) {
|
|
3192
|
+
inputFields.push("$weight: Int");
|
|
3193
|
+
inputValues.push("weightWidget: { weight: $weight }");
|
|
3194
|
+
variables.weight = options.weight;
|
|
3195
|
+
}
|
|
3196
|
+
// Resolve parent GID if provided
|
|
3197
|
+
if (options.parent_iid !== undefined) {
|
|
3198
|
+
const { workItemGID: parentGID } = await resolveWorkItemGID(projectId, options.parent_iid);
|
|
3199
|
+
inputFields.push("$parentId: WorkItemID");
|
|
3200
|
+
inputValues.push("hierarchyWidget: { parentId: $parentId }");
|
|
3201
|
+
variables.parentId = parentGID;
|
|
3202
|
+
}
|
|
3203
|
+
if (userIds.length > 0) {
|
|
3204
|
+
inputFields.push("$assigneeIds: [UserID!]!");
|
|
3205
|
+
inputValues.push("assigneesWidget: { assigneeIds: $assigneeIds }");
|
|
3206
|
+
variables.assigneeIds = userIds;
|
|
3207
|
+
}
|
|
3208
|
+
if (options.health_status !== undefined) {
|
|
3209
|
+
inputFields.push("$healthStatus: HealthStatus");
|
|
3210
|
+
inputValues.push("healthStatusWidget: { healthStatus: $healthStatus }");
|
|
3211
|
+
variables.healthStatus = options.health_status;
|
|
3212
|
+
}
|
|
3213
|
+
// Start and due date widget - combine into one widget
|
|
3214
|
+
if (options.start_date !== undefined || options.due_date !== undefined) {
|
|
3215
|
+
const dateParts = [];
|
|
3216
|
+
if (options.start_date !== undefined) {
|
|
3217
|
+
inputFields.push("$startDate: Date");
|
|
3218
|
+
dateParts.push("startDate: $startDate");
|
|
3219
|
+
variables.startDate = options.start_date;
|
|
3220
|
+
}
|
|
3221
|
+
if (options.due_date !== undefined) {
|
|
3222
|
+
inputFields.push("$dueDate: Date");
|
|
3223
|
+
dateParts.push("dueDate: $dueDate");
|
|
3224
|
+
variables.dueDate = options.due_date;
|
|
3225
|
+
}
|
|
3226
|
+
inputValues.push(`startAndDueDateWidget: { ${dateParts.join(", ")} }`);
|
|
3227
|
+
}
|
|
3228
|
+
if (options.milestone_id !== undefined) {
|
|
3229
|
+
// Convert numeric ID to GID format if needed
|
|
3230
|
+
const milestoneGID = options.milestone_id.startsWith("gid://")
|
|
3231
|
+
? options.milestone_id
|
|
3232
|
+
: `gid://gitlab/Milestone/${options.milestone_id}`;
|
|
3233
|
+
inputFields.push("$milestoneId: MilestoneID");
|
|
3234
|
+
inputValues.push("milestoneWidget: { milestoneId: $milestoneId }");
|
|
3235
|
+
variables.milestoneId = milestoneGID;
|
|
3236
|
+
}
|
|
3237
|
+
if (options.iteration_id !== undefined) {
|
|
3238
|
+
const iterationGID = options.iteration_id.startsWith("gid://")
|
|
3239
|
+
? options.iteration_id
|
|
3240
|
+
: `gid://gitlab/Iteration/${options.iteration_id}`;
|
|
3241
|
+
inputFields.push("$iterationId: IterationID");
|
|
3242
|
+
inputValues.push("iterationWidget: { iterationId: $iterationId }");
|
|
3243
|
+
variables.iterationId = iterationGID;
|
|
3244
|
+
}
|
|
3245
|
+
if (options.confidential !== undefined) {
|
|
3246
|
+
inputFields.push("$confidential: Boolean");
|
|
3247
|
+
inputValues.push("confidential: $confidential");
|
|
3248
|
+
variables.confidential = options.confidential;
|
|
3249
|
+
}
|
|
3250
|
+
const mutation = `mutation(${inputFields.join(", ")}) {
|
|
3251
|
+
workItemCreate(input: { ${inputValues.join(", ")} }) {
|
|
3252
|
+
workItem {
|
|
3253
|
+
id
|
|
3254
|
+
iid
|
|
3255
|
+
title
|
|
3256
|
+
webUrl
|
|
3257
|
+
workItemType { name }
|
|
3258
|
+
}
|
|
3259
|
+
errors
|
|
3260
|
+
}
|
|
3261
|
+
}`;
|
|
3262
|
+
const data = await executeGraphQL(mutation, variables);
|
|
3263
|
+
if (data.workItemCreate.errors?.length > 0) {
|
|
3264
|
+
throw new Error(`Failed to create work item: ${data.workItemCreate.errors.join(", ")}`);
|
|
3265
|
+
}
|
|
3266
|
+
const wi = data.workItemCreate.workItem;
|
|
3267
|
+
return {
|
|
3268
|
+
id: wi.id,
|
|
3269
|
+
iid: wi.iid,
|
|
3270
|
+
title: wi.title,
|
|
3271
|
+
type: wi.workItemType?.name,
|
|
3272
|
+
webUrl: wi.webUrl,
|
|
3273
|
+
};
|
|
3274
|
+
}
|
|
3275
|
+
/**
|
|
3276
|
+
* Update a work item - consolidated handler for title, description, labels, assignees,
|
|
3277
|
+
* weight, state, status, parent, and children operations.
|
|
3278
|
+
*/
|
|
3279
|
+
async function updateWorkItem(projectId, iid, options) {
|
|
3280
|
+
const { workItemGID, projectPath } = await resolveWorkItemGID(projectId, iid);
|
|
3281
|
+
// Build the main workItemUpdate mutation dynamically
|
|
3282
|
+
const inputParts = ["id: $id"];
|
|
3283
|
+
const varDefs = ["$id: WorkItemID!"];
|
|
3284
|
+
const variables = { id: workItemGID };
|
|
3285
|
+
if (options.title !== undefined) {
|
|
3286
|
+
varDefs.push("$title: String");
|
|
3287
|
+
inputParts.push("title: $title");
|
|
3288
|
+
variables.title = options.title;
|
|
3289
|
+
}
|
|
3290
|
+
if (options.description !== undefined) {
|
|
3291
|
+
varDefs.push("$description: String!");
|
|
3292
|
+
inputParts.push("descriptionWidget: { description: $description }");
|
|
3293
|
+
variables.description = options.description;
|
|
3294
|
+
}
|
|
3295
|
+
// Resolve label names and usernames to GIDs in a single GraphQL call
|
|
3296
|
+
const allLabelNames = [...(options.add_labels || []), ...(options.remove_labels || [])];
|
|
3297
|
+
const needsResolve = allLabelNames.length > 0 || options.assignee_usernames?.length;
|
|
3298
|
+
const { labelIds: resolvedLabelIds, userIds } = needsResolve
|
|
3299
|
+
? await resolveNamesToIds(projectPath, allLabelNames.length > 0 ? allLabelNames : undefined, options.assignee_usernames)
|
|
3300
|
+
: { labelIds: [], userIds: [] };
|
|
3301
|
+
if (options.add_labels || options.remove_labels) {
|
|
3302
|
+
const labelParts = [];
|
|
3303
|
+
let offset = 0;
|
|
3304
|
+
if (options.add_labels && options.add_labels.length > 0) {
|
|
3305
|
+
const addIds = resolvedLabelIds.slice(0, options.add_labels.length);
|
|
3306
|
+
offset = options.add_labels.length;
|
|
3307
|
+
varDefs.push("$addLabelIds: [LabelID!]");
|
|
3308
|
+
labelParts.push("addLabelIds: $addLabelIds");
|
|
3309
|
+
variables.addLabelIds = addIds;
|
|
3310
|
+
}
|
|
3311
|
+
if (options.remove_labels && options.remove_labels.length > 0) {
|
|
3312
|
+
const removeIds = resolvedLabelIds.slice(offset);
|
|
3313
|
+
varDefs.push("$removeLabelIds: [LabelID!]");
|
|
3314
|
+
labelParts.push("removeLabelIds: $removeLabelIds");
|
|
3315
|
+
variables.removeLabelIds = removeIds;
|
|
3316
|
+
}
|
|
3317
|
+
if (labelParts.length > 0) {
|
|
3318
|
+
inputParts.push(`labelsWidget: { ${labelParts.join(", ")} }`);
|
|
3319
|
+
}
|
|
3320
|
+
}
|
|
3321
|
+
if (userIds.length > 0) {
|
|
3322
|
+
varDefs.push("$assigneeIds: [UserID!]!");
|
|
3323
|
+
inputParts.push("assigneesWidget: { assigneeIds: $assigneeIds }");
|
|
3324
|
+
variables.assigneeIds = userIds;
|
|
3325
|
+
}
|
|
3326
|
+
if (options.state_event !== undefined) {
|
|
3327
|
+
varDefs.push("$stateEvent: WorkItemStateEvent");
|
|
3328
|
+
inputParts.push("stateEvent: $stateEvent");
|
|
3329
|
+
variables.stateEvent = options.state_event === "close" ? "CLOSE" : "REOPEN";
|
|
3330
|
+
}
|
|
3331
|
+
if (options.weight !== undefined) {
|
|
3332
|
+
varDefs.push("$weight: Int");
|
|
3333
|
+
inputParts.push("weightWidget: { weight: $weight }");
|
|
3334
|
+
variables.weight = options.weight;
|
|
3335
|
+
}
|
|
3336
|
+
if (options.status !== undefined) {
|
|
3337
|
+
varDefs.push("$status: WorkItemsStatusesStatusID");
|
|
3338
|
+
inputParts.push("statusWidget: { status: $status }");
|
|
3339
|
+
variables.status = options.status;
|
|
3340
|
+
}
|
|
3341
|
+
if (options.health_status !== undefined) {
|
|
3342
|
+
varDefs.push("$healthStatus: HealthStatus");
|
|
3343
|
+
inputParts.push("healthStatusWidget: { healthStatus: $healthStatus }");
|
|
3344
|
+
variables.healthStatus = options.health_status;
|
|
3345
|
+
}
|
|
3346
|
+
// Start and due date widget - combine into one widget
|
|
3347
|
+
if (options.start_date !== undefined || options.due_date !== undefined) {
|
|
3348
|
+
const dateParts = [];
|
|
3349
|
+
if (options.start_date !== undefined) {
|
|
3350
|
+
varDefs.push("$startDate: Date");
|
|
3351
|
+
dateParts.push("startDate: $startDate");
|
|
3352
|
+
variables.startDate = options.start_date;
|
|
3353
|
+
}
|
|
3354
|
+
if (options.due_date !== undefined) {
|
|
3355
|
+
varDefs.push("$dueDate: Date");
|
|
3356
|
+
dateParts.push("dueDate: $dueDate");
|
|
3357
|
+
variables.dueDate = options.due_date;
|
|
3358
|
+
}
|
|
3359
|
+
inputParts.push(`startAndDueDateWidget: { ${dateParts.join(", ")} }`);
|
|
3360
|
+
}
|
|
3361
|
+
if (options.milestone_id !== undefined) {
|
|
3362
|
+
// Convert numeric ID to GID format if needed
|
|
3363
|
+
const milestoneGID = options.milestone_id.startsWith("gid://")
|
|
3364
|
+
? options.milestone_id
|
|
3365
|
+
: `gid://gitlab/Milestone/${options.milestone_id}`;
|
|
3366
|
+
varDefs.push("$milestoneId: MilestoneID");
|
|
3367
|
+
inputParts.push("milestoneWidget: { milestoneId: $milestoneId }");
|
|
3368
|
+
variables.milestoneId = milestoneGID;
|
|
3369
|
+
}
|
|
3370
|
+
if (options.iteration_id !== undefined) {
|
|
3371
|
+
const iterationGID = options.iteration_id.startsWith("gid://")
|
|
3372
|
+
? options.iteration_id
|
|
3373
|
+
: `gid://gitlab/Iteration/${options.iteration_id}`;
|
|
3374
|
+
varDefs.push("$iterationId: IterationID");
|
|
3375
|
+
inputParts.push("iterationWidget: { iterationId: $iterationId }");
|
|
3376
|
+
variables.iterationId = iterationGID;
|
|
3377
|
+
}
|
|
3378
|
+
if (options.confidential !== undefined) {
|
|
3379
|
+
varDefs.push("$confidential: Boolean");
|
|
3380
|
+
inputParts.push("confidential: $confidential");
|
|
3381
|
+
variables.confidential = options.confidential;
|
|
3382
|
+
}
|
|
3383
|
+
// Custom fields widget
|
|
3384
|
+
if (options.custom_fields && options.custom_fields.length > 0) {
|
|
3385
|
+
const cfValues = options.custom_fields.map(cf => {
|
|
3386
|
+
const cfId = cf.custom_field_id.startsWith("gid://")
|
|
3387
|
+
? cf.custom_field_id
|
|
3388
|
+
: `gid://gitlab/IssuablesCustomField/${cf.custom_field_id}`;
|
|
3389
|
+
const val = { customFieldId: cfId };
|
|
3390
|
+
if (cf.text_value !== undefined)
|
|
3391
|
+
val.textValue = cf.text_value;
|
|
3392
|
+
if (cf.number_value !== undefined)
|
|
3393
|
+
val.numberValue = cf.number_value;
|
|
3394
|
+
if (cf.selected_option_ids !== undefined)
|
|
3395
|
+
val.selectedOptionIds = cf.selected_option_ids;
|
|
3396
|
+
if (cf.date_value !== undefined)
|
|
3397
|
+
val.dateValue = cf.date_value;
|
|
3398
|
+
return val;
|
|
3399
|
+
});
|
|
3400
|
+
varDefs.push("$customFieldsWidget: [WorkItemWidgetCustomFieldValueInputType!]");
|
|
3401
|
+
inputParts.push("customFieldsWidget: $customFieldsWidget");
|
|
3402
|
+
variables.customFieldsWidget = cfValues;
|
|
3403
|
+
}
|
|
3404
|
+
// Hierarchy: set parent or remove parent
|
|
3405
|
+
if (options.remove_parent) {
|
|
3406
|
+
inputParts.push("hierarchyWidget: { parentId: null }");
|
|
3407
|
+
}
|
|
3408
|
+
else if (options.parent_iid !== undefined) {
|
|
3409
|
+
const parentProjectId = options.parent_project_id || projectId;
|
|
3410
|
+
const { workItemGID: parentGID } = await resolveWorkItemGID(parentProjectId, options.parent_iid);
|
|
3411
|
+
varDefs.push("$parentId: WorkItemID");
|
|
3412
|
+
inputParts.push("hierarchyWidget: { parentId: $parentId }");
|
|
3413
|
+
variables.parentId = parentGID;
|
|
3414
|
+
}
|
|
3415
|
+
// Execute the main update mutation
|
|
3416
|
+
const mutation = `mutation(${varDefs.join(", ")}) {
|
|
3417
|
+
workItemUpdate(input: { ${inputParts.join(", ")} }) {
|
|
3418
|
+
workItem {
|
|
3419
|
+
id
|
|
3420
|
+
iid
|
|
3421
|
+
title
|
|
3422
|
+
state
|
|
3423
|
+
webUrl
|
|
3424
|
+
workItemType { name }
|
|
3425
|
+
widgets {
|
|
3426
|
+
__typename
|
|
3427
|
+
... on WorkItemWidgetStatus { status { id name category color } }
|
|
3428
|
+
... on WorkItemWidgetLabels { labels { nodes { title } } }
|
|
3429
|
+
... on WorkItemWidgetAssignees { assignees { nodes { username } } }
|
|
3430
|
+
... on WorkItemWidgetWeight { weight }
|
|
3431
|
+
... on WorkItemWidgetHierarchy {
|
|
3432
|
+
parent { id title workItemType { name } }
|
|
3433
|
+
}
|
|
3434
|
+
... on WorkItemWidgetHealthStatus { healthStatus }
|
|
3435
|
+
... on WorkItemWidgetStartAndDueDate { startDate dueDate }
|
|
3436
|
+
... on WorkItemWidgetMilestone { milestone { id title } }
|
|
3437
|
+
}
|
|
3438
|
+
}
|
|
3439
|
+
errors
|
|
3440
|
+
}
|
|
3441
|
+
}`;
|
|
3442
|
+
const data = await executeGraphQL(mutation, variables);
|
|
3443
|
+
if (data.workItemUpdate.errors?.length > 0) {
|
|
3444
|
+
throw new Error(`Failed to update work item: ${data.workItemUpdate.errors.join(", ")}`);
|
|
3445
|
+
}
|
|
3446
|
+
// Handle children_to_add: use separate workItemUpdate call with hierarchyWidget.childrenIds
|
|
3447
|
+
if (options.children_to_add && options.children_to_add.length > 0) {
|
|
3448
|
+
const childGIDs = [];
|
|
3449
|
+
for (const child of options.children_to_add) {
|
|
3450
|
+
const { workItemGID: childGID } = await resolveWorkItemGID(child.project_id, child.iid);
|
|
3451
|
+
childGIDs.push(childGID);
|
|
3452
|
+
}
|
|
3453
|
+
const addData = await executeGraphQL(`mutation($id: WorkItemID!, $childrenIds: [WorkItemID!]!) {
|
|
3454
|
+
workItemUpdate(input: { id: $id, hierarchyWidget: { childrenIds: $childrenIds } }) {
|
|
3455
|
+
errors
|
|
3456
|
+
}
|
|
3457
|
+
}`, { id: workItemGID, childrenIds: childGIDs });
|
|
3458
|
+
if (addData.workItemUpdate.errors?.length > 0) {
|
|
3459
|
+
throw new Error(`Failed to add children: ${addData.workItemUpdate.errors.join(", ")}`);
|
|
3460
|
+
}
|
|
3461
|
+
}
|
|
3462
|
+
// Handle children_to_remove: remove parent from each child
|
|
3463
|
+
if (options.children_to_remove && options.children_to_remove.length > 0) {
|
|
3464
|
+
for (const child of options.children_to_remove) {
|
|
3465
|
+
await removeIssueParent(child.project_id, child.iid);
|
|
3466
|
+
}
|
|
3467
|
+
}
|
|
3468
|
+
// Handle linked_items_to_add: use workItemAddLinkedItems mutation
|
|
3469
|
+
if (options.linked_items_to_add && options.linked_items_to_add.length > 0) {
|
|
3470
|
+
// Group by link_type since each mutation call needs a single linkType
|
|
3471
|
+
const groupedByType = {};
|
|
3472
|
+
for (const item of options.linked_items_to_add) {
|
|
3473
|
+
const linkType = item.link_type || "RELATED";
|
|
3474
|
+
if (!groupedByType[linkType])
|
|
3475
|
+
groupedByType[linkType] = [];
|
|
3476
|
+
const { workItemGID: targetGID } = await resolveWorkItemGID(item.project_id, item.iid);
|
|
3477
|
+
groupedByType[linkType].push(targetGID);
|
|
3478
|
+
}
|
|
3479
|
+
for (const [linkType, targetGIDs] of Object.entries(groupedByType)) {
|
|
3480
|
+
const addLinkedData = await executeGraphQL(`mutation($id: WorkItemID!, $workItemsIds: [WorkItemID!]!, $linkType: WorkItemRelatedLinkType!) {
|
|
3481
|
+
workItemAddLinkedItems(input: { id: $id, workItemsIds: $workItemsIds, linkType: $linkType }) {
|
|
3482
|
+
errors
|
|
3483
|
+
}
|
|
3484
|
+
}`, { id: workItemGID, workItemsIds: targetGIDs, linkType });
|
|
3485
|
+
if (addLinkedData.workItemAddLinkedItems.errors?.length > 0) {
|
|
3486
|
+
throw new Error(`Failed to add linked items: ${addLinkedData.workItemAddLinkedItems.errors.join(", ")}`);
|
|
3487
|
+
}
|
|
3488
|
+
}
|
|
3489
|
+
}
|
|
3490
|
+
// Handle linked_items_to_remove: use workItemRemoveLinkedItems mutation
|
|
3491
|
+
if (options.linked_items_to_remove && options.linked_items_to_remove.length > 0) {
|
|
3492
|
+
const targetGIDs = [];
|
|
3493
|
+
for (const item of options.linked_items_to_remove) {
|
|
3494
|
+
const { workItemGID: targetGID } = await resolveWorkItemGID(item.project_id, item.iid);
|
|
3495
|
+
targetGIDs.push(targetGID);
|
|
3496
|
+
}
|
|
3497
|
+
const removeLinkedData = await executeGraphQL(`mutation($id: WorkItemID!, $workItemsIds: [WorkItemID!]!) {
|
|
3498
|
+
workItemRemoveLinkedItems(input: { id: $id, workItemsIds: $workItemsIds }) {
|
|
3499
|
+
errors
|
|
3500
|
+
}
|
|
3501
|
+
}`, { id: workItemGID, workItemsIds: targetGIDs });
|
|
3502
|
+
if (removeLinkedData.workItemRemoveLinkedItems.errors?.length > 0) {
|
|
3503
|
+
throw new Error(`Failed to remove linked items: ${removeLinkedData.workItemRemoveLinkedItems.errors.join(", ")}`);
|
|
3504
|
+
}
|
|
3505
|
+
}
|
|
3506
|
+
// Handle incident-specific fields via separate mutations
|
|
3507
|
+
if (options.severity !== undefined) {
|
|
3508
|
+
await updateIncidentSeverity(projectPath, iid, options.severity);
|
|
3509
|
+
}
|
|
3510
|
+
if (options.escalation_status !== undefined) {
|
|
3511
|
+
await updateIncidentEscalationStatus(projectPath, iid, options.escalation_status);
|
|
3512
|
+
}
|
|
3513
|
+
// Flatten the response
|
|
3514
|
+
const wi = data.workItemUpdate.workItem;
|
|
3515
|
+
const widgets = wi?.widgets || [];
|
|
3516
|
+
const statusW = widgets.find((w) => w.__typename === "WorkItemWidgetStatus");
|
|
3517
|
+
const labelsW = widgets.find((w) => w.__typename === "WorkItemWidgetLabels");
|
|
3518
|
+
const assigneesW = widgets.find((w) => w.__typename === "WorkItemWidgetAssignees");
|
|
3519
|
+
const weightW = widgets.find((w) => w.__typename === "WorkItemWidgetWeight");
|
|
3520
|
+
const hierarchyW = widgets.find((w) => w.__typename === "WorkItemWidgetHierarchy");
|
|
3521
|
+
const healthStatusW = widgets.find((w) => w.__typename === "WorkItemWidgetHealthStatus");
|
|
3522
|
+
const datesW = widgets.find((w) => w.__typename === "WorkItemWidgetStartAndDueDate");
|
|
3523
|
+
const milestoneW = widgets.find((w) => w.__typename === "WorkItemWidgetMilestone");
|
|
3524
|
+
return {
|
|
3525
|
+
id: wi.id,
|
|
3526
|
+
iid: wi.iid,
|
|
3527
|
+
title: wi.title,
|
|
3528
|
+
state: wi.state,
|
|
3529
|
+
type: wi.workItemType?.name,
|
|
3530
|
+
webUrl: wi.webUrl,
|
|
3531
|
+
status: statusW?.status || null,
|
|
3532
|
+
labels: (labelsW?.labels?.nodes || []).map((l) => l.title),
|
|
3533
|
+
assignees: (assigneesW?.assignees?.nodes || []).map((a) => a.username),
|
|
3534
|
+
weight: weightW?.weight ?? null,
|
|
3535
|
+
parent: hierarchyW?.parent || null,
|
|
3536
|
+
healthStatus: healthStatusW?.healthStatus || null,
|
|
3537
|
+
startDate: datesW?.startDate || null,
|
|
3538
|
+
dueDate: datesW?.dueDate || null,
|
|
3539
|
+
milestone: milestoneW?.milestone || null,
|
|
3540
|
+
children_added: options.children_to_add?.length || 0,
|
|
3541
|
+
children_removed: options.children_to_remove?.length || 0,
|
|
3542
|
+
linked_items_added: options.linked_items_to_add?.length || 0,
|
|
3543
|
+
linked_items_removed: options.linked_items_to_remove?.length || 0,
|
|
3544
|
+
...(options.severity !== undefined && { severity: options.severity }),
|
|
3545
|
+
...(options.escalation_status !== undefined && { escalation_status: options.escalation_status }),
|
|
3546
|
+
};
|
|
3547
|
+
}
|
|
1927
3548
|
/**
|
|
1928
3549
|
* List all issue links for a specific issue
|
|
1929
3550
|
* 이슈 관계 목록 조회
|
|
@@ -2344,6 +3965,12 @@ async function updateMergeRequestNote(projectId, mergeRequestIid, noteId, body)
|
|
|
2344
3965
|
const data = await response.json();
|
|
2345
3966
|
return GitLabDiscussionNoteSchema.parse(data);
|
|
2346
3967
|
}
|
|
3968
|
+
function encodeRepoFilePayloadContent(content) {
|
|
3969
|
+
if (GITLAB_REPO_FILE_ENCODING === "base64") {
|
|
3970
|
+
return Buffer.from(content).toString("base64");
|
|
3971
|
+
}
|
|
3972
|
+
return content;
|
|
3973
|
+
}
|
|
2347
3974
|
/**
|
|
2348
3975
|
* Create or update a file in a GitLab project
|
|
2349
3976
|
* 파일 생성 또는 업데이트
|
|
@@ -2362,9 +3989,9 @@ async function createOrUpdateFile(projectId, filePath, content, commitMessage, b
|
|
|
2362
3989
|
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/repository/files/${encodedPath}`);
|
|
2363
3990
|
const body = {
|
|
2364
3991
|
branch,
|
|
2365
|
-
content,
|
|
3992
|
+
content: encodeRepoFilePayloadContent(content),
|
|
2366
3993
|
commit_message: commitMessage,
|
|
2367
|
-
encoding:
|
|
3994
|
+
encoding: GITLAB_REPO_FILE_ENCODING,
|
|
2368
3995
|
...(previousPath ? { previous_path: previousPath } : {}),
|
|
2369
3996
|
};
|
|
2370
3997
|
// Check if file exists
|
|
@@ -2436,8 +4063,8 @@ async function createTree(projectId, files, ref) {
|
|
|
2436
4063
|
body: JSON.stringify({
|
|
2437
4064
|
files: files.map(file => ({
|
|
2438
4065
|
file_path: file.path,
|
|
2439
|
-
content: file.content,
|
|
2440
|
-
encoding:
|
|
4066
|
+
content: encodeRepoFilePayloadContent(file.content),
|
|
4067
|
+
encoding: GITLAB_REPO_FILE_ENCODING,
|
|
2441
4068
|
})),
|
|
2442
4069
|
}),
|
|
2443
4070
|
});
|
|
@@ -2474,8 +4101,8 @@ async function createCommit(projectId, message, branch, actions) {
|
|
|
2474
4101
|
actions: actions.map(action => ({
|
|
2475
4102
|
action: "create",
|
|
2476
4103
|
file_path: action.path,
|
|
2477
|
-
content: action.content,
|
|
2478
|
-
encoding:
|
|
4104
|
+
content: encodeRepoFilePayloadContent(action.content),
|
|
4105
|
+
encoding: GITLAB_REPO_FILE_ENCODING,
|
|
2479
4106
|
})),
|
|
2480
4107
|
}),
|
|
2481
4108
|
});
|
|
@@ -2525,6 +4152,52 @@ async function searchProjects(query, page = 1, perPage = 20) {
|
|
|
2525
4152
|
items: projects,
|
|
2526
4153
|
});
|
|
2527
4154
|
}
|
|
4155
|
+
/**
|
|
4156
|
+
* Search for code blobs using GitLab Search API
|
|
4157
|
+
* Supports global, project-level, and group-level search
|
|
4158
|
+
*/
|
|
4159
|
+
async function searchBlobs(params) {
|
|
4160
|
+
let basePath;
|
|
4161
|
+
if (params.project_id) {
|
|
4162
|
+
const decodedProjectId = decodeURIComponent(params.project_id);
|
|
4163
|
+
const projectId = encodeURIComponent(getEffectiveProjectId(decodedProjectId));
|
|
4164
|
+
basePath = `${getEffectiveApiUrl()}/projects/${projectId}/search`;
|
|
4165
|
+
}
|
|
4166
|
+
else if (params.group_id) {
|
|
4167
|
+
const groupId = encodeURIComponent(decodeURIComponent(params.group_id));
|
|
4168
|
+
basePath = `${getEffectiveApiUrl()}/groups/${groupId}/search`;
|
|
4169
|
+
}
|
|
4170
|
+
else {
|
|
4171
|
+
basePath = `${getEffectiveApiUrl()}/search`;
|
|
4172
|
+
}
|
|
4173
|
+
const url = new URL(basePath);
|
|
4174
|
+
url.searchParams.append("scope", "blobs");
|
|
4175
|
+
url.searchParams.append("search", params.search);
|
|
4176
|
+
if (params.ref) {
|
|
4177
|
+
url.searchParams.append("ref", params.ref);
|
|
4178
|
+
}
|
|
4179
|
+
if (params.page) {
|
|
4180
|
+
url.searchParams.append("page", params.page.toString());
|
|
4181
|
+
}
|
|
4182
|
+
if (params.per_page) {
|
|
4183
|
+
url.searchParams.append("per_page", params.per_page.toString());
|
|
4184
|
+
}
|
|
4185
|
+
if (params.filename) {
|
|
4186
|
+
url.searchParams.append("filename", params.filename);
|
|
4187
|
+
}
|
|
4188
|
+
if (params.path) {
|
|
4189
|
+
url.searchParams.append("path", params.path);
|
|
4190
|
+
}
|
|
4191
|
+
if (params.extension) {
|
|
4192
|
+
url.searchParams.append("extension", params.extension);
|
|
4193
|
+
}
|
|
4194
|
+
const response = await fetch(url.toString(), {
|
|
4195
|
+
...getFetchConfig(),
|
|
4196
|
+
});
|
|
4197
|
+
await handleGitLabError(response);
|
|
4198
|
+
const data = await response.json();
|
|
4199
|
+
return z.array(GitLabSearchBlobResultSchema).parse(data);
|
|
4200
|
+
}
|
|
2528
4201
|
/**
|
|
2529
4202
|
* Create a new GitLab repository
|
|
2530
4203
|
* 새 저장소 생성
|
|
@@ -2850,14 +4523,107 @@ async function listMergeRequestDiffs(projectId, mergeRequestIid, branchName, pag
|
|
|
2850
4523
|
if (perPage) {
|
|
2851
4524
|
url.searchParams.append("per_page", perPage.toString());
|
|
2852
4525
|
}
|
|
2853
|
-
if (unidiff) {
|
|
2854
|
-
url.searchParams.append("unidiff", "true");
|
|
4526
|
+
if (unidiff) {
|
|
4527
|
+
url.searchParams.append("unidiff", "true");
|
|
4528
|
+
}
|
|
4529
|
+
const response = await fetch(url.toString(), {
|
|
4530
|
+
...getFetchConfig(),
|
|
4531
|
+
});
|
|
4532
|
+
await handleGitLabError(response);
|
|
4533
|
+
return await response.json(); // Return full response including commits, diff_refs, changes, etc.
|
|
4534
|
+
}
|
|
4535
|
+
/**
|
|
4536
|
+
* Returns the list of changed files in a merge request WITHOUT diff content.
|
|
4537
|
+
* Use this as STEP 1 of code review: get file paths, then fetch diffs in batches
|
|
4538
|
+
* with getMergeRequestFileDiff to avoid loading the entire diff payload at once.
|
|
4539
|
+
*
|
|
4540
|
+
* @param {string} projectId - The ID or URL-encoded path of the project
|
|
4541
|
+
* @param {number|string} [mergeRequestIid] - The internal ID of the merge request
|
|
4542
|
+
* @param {string} [branchName] - The name of the source branch (used to resolve MR if iid not provided)
|
|
4543
|
+
* @param {string[]} [excludedFilePatterns] - Regex patterns to exclude files from the result
|
|
4544
|
+
* @returns {Promise<any[]>} Array of changed file metadata (new_path, old_path, new_file, deleted_file, renamed_file)
|
|
4545
|
+
*/
|
|
4546
|
+
async function listMergeRequestChangedFiles(projectId, mergeRequestIid, branchName, excludedFilePatterns) {
|
|
4547
|
+
projectId = decodeURIComponent(projectId);
|
|
4548
|
+
if (!mergeRequestIid && !branchName) {
|
|
4549
|
+
throw new Error("Either mergeRequestIid or branchName must be provided");
|
|
4550
|
+
}
|
|
4551
|
+
if (branchName && !mergeRequestIid) {
|
|
4552
|
+
const mergeRequest = await getMergeRequest(projectId, undefined, branchName);
|
|
4553
|
+
mergeRequestIid = mergeRequest.iid;
|
|
4554
|
+
}
|
|
4555
|
+
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/changes`);
|
|
4556
|
+
const response = await fetch(url.toString(), { ...getFetchConfig() });
|
|
4557
|
+
await handleGitLabError(response);
|
|
4558
|
+
const data = (await response.json());
|
|
4559
|
+
const rawFiles = (data.changes || []).map((f) => ({
|
|
4560
|
+
new_path: f.new_path,
|
|
4561
|
+
old_path: f.old_path,
|
|
4562
|
+
new_file: f.new_file,
|
|
4563
|
+
deleted_file: f.deleted_file,
|
|
4564
|
+
renamed_file: f.renamed_file,
|
|
4565
|
+
}));
|
|
4566
|
+
return filterDiffsByPatterns(rawFiles, excludedFilePatterns);
|
|
4567
|
+
}
|
|
4568
|
+
/**
|
|
4569
|
+
* Get diffs for specific files from a merge request.
|
|
4570
|
+
* Use this as STEP 2 of code review: pass file paths obtained from
|
|
4571
|
+
* listMergeRequestChangedFiles to fetch their diffs efficiently.
|
|
4572
|
+
*
|
|
4573
|
+
* @param {string} projectId - The ID or URL-encoded path of the project
|
|
4574
|
+
* @param {string[]} filePaths - List of file paths to retrieve diffs for
|
|
4575
|
+
* @param {number|string} [mergeRequestIid] - The internal ID of the merge request
|
|
4576
|
+
* @param {string} [branchName] - The name of the source branch (used to resolve MR if iid not provided)
|
|
4577
|
+
* @param {boolean} [unidiff] - Return diff in unified diff format
|
|
4578
|
+
* @returns {Promise<any[]>} Array of diff objects for each requested file, or error objects for files not found
|
|
4579
|
+
*/
|
|
4580
|
+
async function getMergeRequestFileDiff(projectId, filePaths, mergeRequestIid, branchName, unidiff) {
|
|
4581
|
+
projectId = decodeURIComponent(projectId);
|
|
4582
|
+
if (!mergeRequestIid && !branchName) {
|
|
4583
|
+
throw new Error("Either mergeRequestIid or branchName must be provided");
|
|
4584
|
+
}
|
|
4585
|
+
if (branchName && !mergeRequestIid) {
|
|
4586
|
+
const mergeRequest = await getMergeRequest(projectId, undefined, branchName);
|
|
4587
|
+
mergeRequestIid = mergeRequest.iid;
|
|
4588
|
+
}
|
|
4589
|
+
// Paginate through /diffs once, collecting all requested files.
|
|
4590
|
+
// More efficient than N separate searches when fetching multiple files.
|
|
4591
|
+
const remaining = new Set(filePaths);
|
|
4592
|
+
const results = [];
|
|
4593
|
+
let page = 1;
|
|
4594
|
+
const perPage = 20;
|
|
4595
|
+
while (remaining.size > 0) {
|
|
4596
|
+
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/diffs`);
|
|
4597
|
+
url.searchParams.append("page", page.toString());
|
|
4598
|
+
url.searchParams.append("per_page", perPage.toString());
|
|
4599
|
+
if (unidiff) {
|
|
4600
|
+
url.searchParams.append("unidiff", "true");
|
|
4601
|
+
}
|
|
4602
|
+
const response = await fetch(url.toString(), { ...getFetchConfig() });
|
|
4603
|
+
await handleGitLabError(response);
|
|
4604
|
+
const items = (await response.json());
|
|
4605
|
+
if (!Array.isArray(items) || items.length === 0) {
|
|
4606
|
+
break;
|
|
4607
|
+
}
|
|
4608
|
+
for (const item of items) {
|
|
4609
|
+
if (remaining.has(item.new_path) || remaining.has(item.old_path)) {
|
|
4610
|
+
results.push(item);
|
|
4611
|
+
remaining.delete(item.new_path);
|
|
4612
|
+
remaining.delete(item.old_path);
|
|
4613
|
+
}
|
|
4614
|
+
}
|
|
4615
|
+
if (items.length < perPage) {
|
|
4616
|
+
break;
|
|
4617
|
+
}
|
|
4618
|
+
page++;
|
|
4619
|
+
}
|
|
4620
|
+
for (const notFound of remaining) {
|
|
4621
|
+
results.push({
|
|
4622
|
+
error: `File not found in merge request diffs: ${notFound}`,
|
|
4623
|
+
hint: "Use list_merge_request_changed_files to verify the correct file paths.",
|
|
4624
|
+
});
|
|
2855
4625
|
}
|
|
2856
|
-
|
|
2857
|
-
...getFetchConfig(),
|
|
2858
|
-
});
|
|
2859
|
-
await handleGitLabError(response);
|
|
2860
|
-
return await response.json(); // Return full response including commits, diff_refs, changes, etc.
|
|
4626
|
+
return results;
|
|
2861
4627
|
}
|
|
2862
4628
|
/**
|
|
2863
4629
|
* Get branch comparison diffs
|
|
@@ -3007,6 +4773,23 @@ async function getMergeRequestApprovalState(projectId, mergeRequestIid) {
|
|
|
3007
4773
|
source_endpoint: "approval_state",
|
|
3008
4774
|
};
|
|
3009
4775
|
}
|
|
4776
|
+
/**
|
|
4777
|
+
* Get the conflicts of a merge request
|
|
4778
|
+
*
|
|
4779
|
+
* @param {string} projectId - The ID or URL-encoded path of the project
|
|
4780
|
+
* @param {string | number} mergeRequestIid - The internal ID of the merge request
|
|
4781
|
+
* @returns {Promise<Record<string, unknown>>} The merge request conflicts
|
|
4782
|
+
*/
|
|
4783
|
+
async function getMergeRequestConflicts(projectId, mergeRequestIid) {
|
|
4784
|
+
projectId = decodeURIComponent(projectId);
|
|
4785
|
+
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/conflicts`);
|
|
4786
|
+
const response = await fetch(url.toString(), {
|
|
4787
|
+
...getFetchConfig(),
|
|
4788
|
+
method: "GET",
|
|
4789
|
+
});
|
|
4790
|
+
await handleGitLabError(response);
|
|
4791
|
+
return (await response.json());
|
|
4792
|
+
}
|
|
3010
4793
|
async function getMergeRequestApprovalsFallback(projectId, mergeRequestIid) {
|
|
3011
4794
|
const approvalsUrl = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/approvals`);
|
|
3012
4795
|
const approvalsResponse = await fetch(approvalsUrl.toString(), {
|
|
@@ -3088,7 +4871,7 @@ noteableIid, body) {
|
|
|
3088
4871
|
* @returns {Promise<GitLabDraftNote[]>} Array of draft notes
|
|
3089
4872
|
*/
|
|
3090
4873
|
async function getDraftNote(project_id, merge_request_iid, draft_note_id) {
|
|
3091
|
-
const response = await fetch(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(project_id)}/merge_requests/${merge_request_iid}/draft_notes/${draft_note_id}
|
|
4874
|
+
const response = await fetch(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(project_id)}/merge_requests/${merge_request_iid}/draft_notes/${draft_note_id}`, { ...getFetchConfig() });
|
|
3092
4875
|
if (!response.ok) {
|
|
3093
4876
|
const errorText = await response.text();
|
|
3094
4877
|
throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
|
|
@@ -3828,6 +5611,84 @@ async function deleteWikiPage(projectId, slug) {
|
|
|
3828
5611
|
});
|
|
3829
5612
|
await handleGitLabError(response);
|
|
3830
5613
|
}
|
|
5614
|
+
/**
|
|
5615
|
+
* List wiki pages in a GitLab group
|
|
5616
|
+
*/
|
|
5617
|
+
async function listGroupWikiPages(groupId, options = {}) {
|
|
5618
|
+
groupId = decodeURIComponent(groupId); // Decode group ID
|
|
5619
|
+
const url = new URL(`${getEffectiveApiUrl()}/groups/${encodeURIComponent(groupId)}/wikis`);
|
|
5620
|
+
if (options.page)
|
|
5621
|
+
url.searchParams.append("page", options.page.toString());
|
|
5622
|
+
if (options.per_page)
|
|
5623
|
+
url.searchParams.append("per_page", options.per_page.toString());
|
|
5624
|
+
if (options.with_content)
|
|
5625
|
+
url.searchParams.append("with_content", options.with_content.toString());
|
|
5626
|
+
const response = await fetch(url.toString(), {
|
|
5627
|
+
...getFetchConfig(),
|
|
5628
|
+
});
|
|
5629
|
+
await handleGitLabError(response);
|
|
5630
|
+
const data = await response.json();
|
|
5631
|
+
return GitLabWikiPageSchema.array().parse(data);
|
|
5632
|
+
}
|
|
5633
|
+
/**
|
|
5634
|
+
* Get a specific group wiki page
|
|
5635
|
+
*/
|
|
5636
|
+
async function getGroupWikiPage(groupId, slug) {
|
|
5637
|
+
groupId = decodeURIComponent(groupId); // Decode group ID
|
|
5638
|
+
const response = await fetch(`${getEffectiveApiUrl()}/groups/${encodeURIComponent(groupId)}/wikis/${encodeURIComponent(slug)}`, { ...getFetchConfig() });
|
|
5639
|
+
await handleGitLabError(response);
|
|
5640
|
+
const data = await response.json();
|
|
5641
|
+
return GitLabWikiPageSchema.parse(data);
|
|
5642
|
+
}
|
|
5643
|
+
/**
|
|
5644
|
+
* Create a new group wiki page
|
|
5645
|
+
*/
|
|
5646
|
+
async function createGroupWikiPage(groupId, title, content, format) {
|
|
5647
|
+
groupId = decodeURIComponent(groupId); // Decode group ID
|
|
5648
|
+
const body = { title, content };
|
|
5649
|
+
if (format)
|
|
5650
|
+
body.format = format;
|
|
5651
|
+
const response = await fetch(`${getEffectiveApiUrl()}/groups/${encodeURIComponent(groupId)}/wikis`, {
|
|
5652
|
+
...getFetchConfig(),
|
|
5653
|
+
method: "POST",
|
|
5654
|
+
body: JSON.stringify(body),
|
|
5655
|
+
});
|
|
5656
|
+
await handleGitLabError(response);
|
|
5657
|
+
const data = await response.json();
|
|
5658
|
+
return GitLabWikiPageSchema.parse(data);
|
|
5659
|
+
}
|
|
5660
|
+
/**
|
|
5661
|
+
* Update an existing group wiki page
|
|
5662
|
+
*/
|
|
5663
|
+
async function updateGroupWikiPage(groupId, slug, title, content, format) {
|
|
5664
|
+
groupId = decodeURIComponent(groupId); // Decode group ID
|
|
5665
|
+
const body = {};
|
|
5666
|
+
if (title)
|
|
5667
|
+
body.title = title;
|
|
5668
|
+
if (content)
|
|
5669
|
+
body.content = content;
|
|
5670
|
+
if (format)
|
|
5671
|
+
body.format = format;
|
|
5672
|
+
const response = await fetch(`${getEffectiveApiUrl()}/groups/${encodeURIComponent(groupId)}/wikis/${encodeURIComponent(slug)}`, {
|
|
5673
|
+
...getFetchConfig(),
|
|
5674
|
+
method: "PUT",
|
|
5675
|
+
body: JSON.stringify(body),
|
|
5676
|
+
});
|
|
5677
|
+
await handleGitLabError(response);
|
|
5678
|
+
const data = await response.json();
|
|
5679
|
+
return GitLabWikiPageSchema.parse(data);
|
|
5680
|
+
}
|
|
5681
|
+
/**
|
|
5682
|
+
* Delete a group wiki page
|
|
5683
|
+
*/
|
|
5684
|
+
async function deleteGroupWikiPage(groupId, slug) {
|
|
5685
|
+
groupId = decodeURIComponent(groupId); // Decode group ID
|
|
5686
|
+
const response = await fetch(`${getEffectiveApiUrl()}/groups/${encodeURIComponent(groupId)}/wikis/${encodeURIComponent(slug)}`, {
|
|
5687
|
+
...getFetchConfig(),
|
|
5688
|
+
method: "DELETE",
|
|
5689
|
+
});
|
|
5690
|
+
await handleGitLabError(response);
|
|
5691
|
+
}
|
|
3831
5692
|
/**
|
|
3832
5693
|
* List pipelines in a GitLab project
|
|
3833
5694
|
*
|
|
@@ -4181,11 +6042,8 @@ async function createPipeline(projectId, ref, variables, inputs) {
|
|
|
4181
6042
|
body.inputs = inputs;
|
|
4182
6043
|
}
|
|
4183
6044
|
const response = await fetch(url.toString(), {
|
|
6045
|
+
...getFetchConfig(),
|
|
4184
6046
|
method: "POST",
|
|
4185
|
-
headers: {
|
|
4186
|
-
...BASE_HEADERS,
|
|
4187
|
-
...buildAuthHeaders(),
|
|
4188
|
-
},
|
|
4189
6047
|
body: JSON.stringify(body),
|
|
4190
6048
|
});
|
|
4191
6049
|
await handleGitLabError(response);
|
|
@@ -4203,11 +6061,8 @@ async function retryPipeline(projectId, pipelineId) {
|
|
|
4203
6061
|
projectId = decodeURIComponent(projectId); // Decode project ID
|
|
4204
6062
|
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${pipelineId}/retry`);
|
|
4205
6063
|
const response = await fetch(url.toString(), {
|
|
6064
|
+
...getFetchConfig(),
|
|
4206
6065
|
method: "POST",
|
|
4207
|
-
headers: {
|
|
4208
|
-
...BASE_HEADERS,
|
|
4209
|
-
...buildAuthHeaders(),
|
|
4210
|
-
},
|
|
4211
6066
|
});
|
|
4212
6067
|
await handleGitLabError(response);
|
|
4213
6068
|
const data = await response.json();
|
|
@@ -4224,11 +6079,8 @@ async function cancelPipeline(projectId, pipelineId) {
|
|
|
4224
6079
|
projectId = decodeURIComponent(projectId); // Decode project ID
|
|
4225
6080
|
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${pipelineId}/cancel`);
|
|
4226
6081
|
const response = await fetch(url.toString(), {
|
|
6082
|
+
...getFetchConfig(),
|
|
4227
6083
|
method: "POST",
|
|
4228
|
-
headers: {
|
|
4229
|
-
...BASE_HEADERS,
|
|
4230
|
-
...buildAuthHeaders(),
|
|
4231
|
-
},
|
|
4232
6084
|
});
|
|
4233
6085
|
await handleGitLabError(response);
|
|
4234
6086
|
const data = await response.json();
|
|
@@ -4319,13 +6171,7 @@ async function getRepositoryTree(options) {
|
|
|
4319
6171
|
queryParams.append("page_token", options.page_token);
|
|
4320
6172
|
if (options.pagination)
|
|
4321
6173
|
queryParams.append("pagination", options.pagination);
|
|
4322
|
-
const
|
|
4323
|
-
...BASE_HEADERS,
|
|
4324
|
-
...buildAuthHeaders(),
|
|
4325
|
-
};
|
|
4326
|
-
const response = await fetch(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(options.project_id))}/repository/tree?${queryParams.toString()}`, {
|
|
4327
|
-
headers,
|
|
4328
|
-
});
|
|
6174
|
+
const response = await fetch(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(options.project_id))}/repository/tree?${queryParams.toString()}`, { ...getFetchConfig() });
|
|
4329
6175
|
if (response.status === 404) {
|
|
4330
6176
|
throw new Error("Repository or path not found");
|
|
4331
6177
|
}
|
|
@@ -4786,15 +6632,12 @@ async function markdownUpload(projectId, filePath) {
|
|
|
4786
6632
|
contentType: "application/octet-stream",
|
|
4787
6633
|
});
|
|
4788
6634
|
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/uploads`);
|
|
6635
|
+
const defaultFetchConfig = getFetchConfig();
|
|
6636
|
+
delete defaultFetchConfig.headers["Content-Type"]; // Let form-data set the correct Content-Type with boundary
|
|
4789
6637
|
const response = await fetch(url.toString(), {
|
|
6638
|
+
...defaultFetchConfig,
|
|
4790
6639
|
method: "POST",
|
|
4791
|
-
|
|
4792
|
-
...BASE_HEADERS,
|
|
4793
|
-
...buildAuthHeaders(),
|
|
4794
|
-
// Remove Content-Type header to let form-data set it with boundary
|
|
4795
|
-
"Content-Type": undefined,
|
|
4796
|
-
},
|
|
4797
|
-
body: form,
|
|
6640
|
+
body: form
|
|
4798
6641
|
});
|
|
4799
6642
|
if (!response.ok) {
|
|
4800
6643
|
await handleGitLabError(response);
|
|
@@ -4820,11 +6663,8 @@ async function downloadAttachment(projectId, secret, filename, localPath) {
|
|
|
4820
6663
|
const effectiveProjectId = getEffectiveProjectId(projectId);
|
|
4821
6664
|
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/uploads/${secret}/${filename}`);
|
|
4822
6665
|
const response = await fetch(url.toString(), {
|
|
6666
|
+
...getFetchConfig(),
|
|
4823
6667
|
method: "GET",
|
|
4824
|
-
headers: {
|
|
4825
|
-
...BASE_HEADERS,
|
|
4826
|
-
...buildAuthHeaders(),
|
|
4827
|
-
},
|
|
4828
6668
|
});
|
|
4829
6669
|
if (!response.ok) {
|
|
4830
6670
|
await handleGitLabError(response);
|
|
@@ -4871,13 +6711,7 @@ async function listEvents(options = {}) {
|
|
|
4871
6711
|
url.searchParams.append(key, value.toString());
|
|
4872
6712
|
}
|
|
4873
6713
|
});
|
|
4874
|
-
const response = await fetch(url.toString(), {
|
|
4875
|
-
method: "GET",
|
|
4876
|
-
headers: {
|
|
4877
|
-
...BASE_HEADERS,
|
|
4878
|
-
...buildAuthHeaders(),
|
|
4879
|
-
},
|
|
4880
|
-
});
|
|
6714
|
+
const response = await fetch(url.toString(), { ...getFetchConfig() });
|
|
4881
6715
|
if (!response.ok) {
|
|
4882
6716
|
await handleGitLabError(response);
|
|
4883
6717
|
}
|
|
@@ -4899,13 +6733,7 @@ async function getProjectEvents(projectId, options = {}) {
|
|
|
4899
6733
|
url.searchParams.append(key, value.toString());
|
|
4900
6734
|
}
|
|
4901
6735
|
});
|
|
4902
|
-
const response = await fetch(url.toString(), {
|
|
4903
|
-
method: "GET",
|
|
4904
|
-
headers: {
|
|
4905
|
-
...BASE_HEADERS,
|
|
4906
|
-
...buildAuthHeaders(),
|
|
4907
|
-
},
|
|
4908
|
-
});
|
|
6736
|
+
const response = await fetch(url.toString(), { ...getFetchConfig() });
|
|
4909
6737
|
if (!response.ok) {
|
|
4910
6738
|
await handleGitLabError(response);
|
|
4911
6739
|
}
|
|
@@ -5189,6 +7017,51 @@ async function handleToolCall(params) {
|
|
|
5189
7017
|
content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
|
|
5190
7018
|
};
|
|
5191
7019
|
}
|
|
7020
|
+
case "search_code": {
|
|
7021
|
+
const args = SearchCodeSchema.parse(params.arguments);
|
|
7022
|
+
const results = await searchBlobs({
|
|
7023
|
+
search: args.search,
|
|
7024
|
+
filename: args.filename,
|
|
7025
|
+
path: args.path,
|
|
7026
|
+
extension: args.extension,
|
|
7027
|
+
page: args.page,
|
|
7028
|
+
per_page: args.per_page,
|
|
7029
|
+
});
|
|
7030
|
+
return {
|
|
7031
|
+
content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
|
|
7032
|
+
};
|
|
7033
|
+
}
|
|
7034
|
+
case "search_project_code": {
|
|
7035
|
+
const args = SearchProjectCodeSchema.parse(params.arguments);
|
|
7036
|
+
const results = await searchBlobs({
|
|
7037
|
+
search: args.search,
|
|
7038
|
+
project_id: args.project_id,
|
|
7039
|
+
ref: args.ref,
|
|
7040
|
+
filename: args.filename,
|
|
7041
|
+
path: args.path,
|
|
7042
|
+
extension: args.extension,
|
|
7043
|
+
page: args.page,
|
|
7044
|
+
per_page: args.per_page,
|
|
7045
|
+
});
|
|
7046
|
+
return {
|
|
7047
|
+
content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
|
|
7048
|
+
};
|
|
7049
|
+
}
|
|
7050
|
+
case "search_group_code": {
|
|
7051
|
+
const args = SearchGroupCodeSchema.parse(params.arguments);
|
|
7052
|
+
const results = await searchBlobs({
|
|
7053
|
+
search: args.search,
|
|
7054
|
+
group_id: args.group_id,
|
|
7055
|
+
filename: args.filename,
|
|
7056
|
+
path: args.path,
|
|
7057
|
+
extension: args.extension,
|
|
7058
|
+
page: args.page,
|
|
7059
|
+
per_page: args.per_page,
|
|
7060
|
+
});
|
|
7061
|
+
return {
|
|
7062
|
+
content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
|
|
7063
|
+
};
|
|
7064
|
+
}
|
|
5192
7065
|
case "create_repository": {
|
|
5193
7066
|
if (GITLAB_PROJECT_ID) {
|
|
5194
7067
|
throw new Error("Direct project ID is set. So fork_repository is not allowed");
|
|
@@ -5338,6 +7211,13 @@ async function handleToolCall(params) {
|
|
|
5338
7211
|
content: [{ type: "text", text: JSON.stringify(filteredDiffs, null, 2) }],
|
|
5339
7212
|
};
|
|
5340
7213
|
}
|
|
7214
|
+
case "list_merge_request_changed_files": {
|
|
7215
|
+
const args = ListMergeRequestChangedFilesSchema.parse(params.arguments);
|
|
7216
|
+
const files = await listMergeRequestChangedFiles(args.project_id, args.merge_request_iid, args.source_branch, args.excluded_file_patterns);
|
|
7217
|
+
return {
|
|
7218
|
+
content: [{ type: "text", text: JSON.stringify(files, null, 2) }],
|
|
7219
|
+
};
|
|
7220
|
+
}
|
|
5341
7221
|
case "list_merge_request_diffs": {
|
|
5342
7222
|
const args = ListMergeRequestDiffsSchema.parse(params.arguments);
|
|
5343
7223
|
const changes = await listMergeRequestDiffs(args.project_id, args.merge_request_iid, args.source_branch, args.page, args.per_page, args.unidiff);
|
|
@@ -5345,6 +7225,13 @@ async function handleToolCall(params) {
|
|
|
5345
7225
|
content: [{ type: "text", text: JSON.stringify(changes, null, 2) }],
|
|
5346
7226
|
};
|
|
5347
7227
|
}
|
|
7228
|
+
case "get_merge_request_file_diff": {
|
|
7229
|
+
const args = GetMergeRequestFileDiffSchema.parse(params.arguments);
|
|
7230
|
+
const fileDiff = await getMergeRequestFileDiff(args.project_id, args.file_paths, args.merge_request_iid, args.source_branch, args.unidiff);
|
|
7231
|
+
return {
|
|
7232
|
+
content: [{ type: "text", text: JSON.stringify(fileDiff, null, 2) }],
|
|
7233
|
+
};
|
|
7234
|
+
}
|
|
5348
7235
|
case "list_merge_request_versions": {
|
|
5349
7236
|
const args = ListMergeRequestVersionsSchema.parse(params.arguments);
|
|
5350
7237
|
const versions = await listMergeRequestVersions(args.project_id, args.merge_request_iid);
|
|
@@ -5396,6 +7283,13 @@ async function handleToolCall(params) {
|
|
|
5396
7283
|
content: [{ type: "text", text: JSON.stringify(approvalState, null, 2) }],
|
|
5397
7284
|
};
|
|
5398
7285
|
}
|
|
7286
|
+
case "get_merge_request_conflicts": {
|
|
7287
|
+
const args = GetMergeRequestConflictsSchema.parse(params.arguments);
|
|
7288
|
+
const conflicts = await getMergeRequestConflicts(args.project_id, args.merge_request_iid);
|
|
7289
|
+
return {
|
|
7290
|
+
content: [{ type: "text", text: JSON.stringify(conflicts, null, 2) }],
|
|
7291
|
+
};
|
|
7292
|
+
}
|
|
5399
7293
|
case "mr_discussions": {
|
|
5400
7294
|
const args = ListMergeRequestDiscussionsSchema.parse(params.arguments);
|
|
5401
7295
|
const { project_id, merge_request_iid, ...options } = args;
|
|
@@ -5664,6 +7558,93 @@ async function handleToolCall(params) {
|
|
|
5664
7558
|
],
|
|
5665
7559
|
};
|
|
5666
7560
|
}
|
|
7561
|
+
case "get_work_item": {
|
|
7562
|
+
const args = GetWorkItemSchema.parse(params.arguments);
|
|
7563
|
+
const result = await getWorkItem(args.project_id, args.iid);
|
|
7564
|
+
return {
|
|
7565
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
7566
|
+
};
|
|
7567
|
+
}
|
|
7568
|
+
case "list_work_items": {
|
|
7569
|
+
const args = ListWorkItemsSchema.parse(params.arguments);
|
|
7570
|
+
const { project_id, ...options } = args;
|
|
7571
|
+
const result = await listWorkItems(project_id, options);
|
|
7572
|
+
return {
|
|
7573
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
7574
|
+
};
|
|
7575
|
+
}
|
|
7576
|
+
case "create_work_item": {
|
|
7577
|
+
const args = CreateWorkItemSchema.parse(params.arguments);
|
|
7578
|
+
const { project_id, ...options } = args;
|
|
7579
|
+
const result = await createWorkItem(project_id, options);
|
|
7580
|
+
return {
|
|
7581
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
7582
|
+
};
|
|
7583
|
+
}
|
|
7584
|
+
case "update_work_item": {
|
|
7585
|
+
const args = UpdateWorkItemSchema.parse(params.arguments);
|
|
7586
|
+
const { project_id, iid, ...options } = args;
|
|
7587
|
+
const result = await updateWorkItem(project_id, iid, options);
|
|
7588
|
+
return {
|
|
7589
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
7590
|
+
};
|
|
7591
|
+
}
|
|
7592
|
+
case "convert_work_item_type": {
|
|
7593
|
+
const args = ConvertWorkItemTypeSchema.parse(params.arguments);
|
|
7594
|
+
const result = await convertIssueType(args.project_id, args.iid, args.new_type);
|
|
7595
|
+
return {
|
|
7596
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
7597
|
+
};
|
|
7598
|
+
}
|
|
7599
|
+
case "list_work_item_statuses": {
|
|
7600
|
+
const args = ListWorkItemStatusesSchema.parse(params.arguments);
|
|
7601
|
+
const result = await listIssueStatuses(args.project_id, args.work_item_type);
|
|
7602
|
+
return {
|
|
7603
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
7604
|
+
};
|
|
7605
|
+
}
|
|
7606
|
+
case "list_custom_field_definitions": {
|
|
7607
|
+
const args = ListCustomFieldDefinitionsSchema.parse(params.arguments);
|
|
7608
|
+
const result = await listCustomFieldDefinitions(args.project_id, args.work_item_type);
|
|
7609
|
+
return {
|
|
7610
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
7611
|
+
};
|
|
7612
|
+
}
|
|
7613
|
+
case "move_work_item": {
|
|
7614
|
+
const args = MoveWorkItemSchema.parse(params.arguments);
|
|
7615
|
+
const result = await moveWorkItem(args.project_id, args.iid, args.target_project_id);
|
|
7616
|
+
return {
|
|
7617
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
7618
|
+
};
|
|
7619
|
+
}
|
|
7620
|
+
case "list_work_item_notes": {
|
|
7621
|
+
const args = ListWorkItemNotesSchema.parse(params.arguments);
|
|
7622
|
+
const result = await listWorkItemNotes(args.project_id, args.iid, args);
|
|
7623
|
+
return {
|
|
7624
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
7625
|
+
};
|
|
7626
|
+
}
|
|
7627
|
+
case "create_work_item_note": {
|
|
7628
|
+
const args = CreateWorkItemNoteSchema.parse(params.arguments);
|
|
7629
|
+
const result = await createWorkItemNote(args.project_id, args.iid, args.body, args);
|
|
7630
|
+
return {
|
|
7631
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
7632
|
+
};
|
|
7633
|
+
}
|
|
7634
|
+
case "get_timeline_events": {
|
|
7635
|
+
const args = GetTimelineEventsSchema.parse(params.arguments);
|
|
7636
|
+
const result = await getTimelineEvents(args.project_id, args.incident_iid);
|
|
7637
|
+
return {
|
|
7638
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
7639
|
+
};
|
|
7640
|
+
}
|
|
7641
|
+
case "create_timeline_event": {
|
|
7642
|
+
const args = CreateTimelineEventSchema.parse(params.arguments);
|
|
7643
|
+
const result = await createTimelineEvent(args.project_id, args.incident_iid, args.note, args.occurred_at, args.tag_names);
|
|
7644
|
+
return {
|
|
7645
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
7646
|
+
};
|
|
7647
|
+
}
|
|
5667
7648
|
case "list_labels": {
|
|
5668
7649
|
const args = ListLabelsSchema.parse(params.arguments);
|
|
5669
7650
|
const labels = await listLabels(args.project_id, args);
|
|
@@ -5759,6 +7740,53 @@ async function handleToolCall(params) {
|
|
|
5759
7740
|
],
|
|
5760
7741
|
};
|
|
5761
7742
|
}
|
|
7743
|
+
case "list_group_wiki_pages": {
|
|
7744
|
+
const { group_id, page, per_page, with_content } = ListGroupWikiPagesSchema.parse(params.arguments);
|
|
7745
|
+
const wikiPages = await listGroupWikiPages(group_id, {
|
|
7746
|
+
page,
|
|
7747
|
+
per_page,
|
|
7748
|
+
with_content,
|
|
7749
|
+
});
|
|
7750
|
+
return {
|
|
7751
|
+
content: [{ type: "text", text: JSON.stringify(wikiPages, null, 2) }],
|
|
7752
|
+
};
|
|
7753
|
+
}
|
|
7754
|
+
case "get_group_wiki_page": {
|
|
7755
|
+
const { group_id, slug } = GetGroupWikiPageSchema.parse(params.arguments);
|
|
7756
|
+
const wikiPage = await getGroupWikiPage(group_id, slug);
|
|
7757
|
+
return {
|
|
7758
|
+
content: [{ type: "text", text: JSON.stringify(wikiPage, null, 2) }],
|
|
7759
|
+
};
|
|
7760
|
+
}
|
|
7761
|
+
case "create_group_wiki_page": {
|
|
7762
|
+
const { group_id, title, content, format } = CreateGroupWikiPageSchema.parse(params.arguments);
|
|
7763
|
+
const wikiPage = await createGroupWikiPage(group_id, title, content, format);
|
|
7764
|
+
return {
|
|
7765
|
+
content: [{ type: "text", text: JSON.stringify(wikiPage, null, 2) }],
|
|
7766
|
+
};
|
|
7767
|
+
}
|
|
7768
|
+
case "update_group_wiki_page": {
|
|
7769
|
+
const { group_id, slug, title, content, format } = UpdateGroupWikiPageSchema.parse(params.arguments);
|
|
7770
|
+
const wikiPage = await updateGroupWikiPage(group_id, slug, title, content, format);
|
|
7771
|
+
return {
|
|
7772
|
+
content: [{ type: "text", text: JSON.stringify(wikiPage, null, 2) }],
|
|
7773
|
+
};
|
|
7774
|
+
}
|
|
7775
|
+
case "delete_group_wiki_page": {
|
|
7776
|
+
const { group_id, slug } = DeleteGroupWikiPageSchema.parse(params.arguments);
|
|
7777
|
+
await deleteGroupWikiPage(group_id, slug);
|
|
7778
|
+
return {
|
|
7779
|
+
content: [
|
|
7780
|
+
{
|
|
7781
|
+
type: "text",
|
|
7782
|
+
text: JSON.stringify({
|
|
7783
|
+
status: "success",
|
|
7784
|
+
message: "Group wiki page deleted successfully",
|
|
7785
|
+
}, null, 2),
|
|
7786
|
+
},
|
|
7787
|
+
],
|
|
7788
|
+
};
|
|
7789
|
+
}
|
|
5762
7790
|
case "get_repository_tree": {
|
|
5763
7791
|
const args = GetRepositoryTreeSchema.parse(params.arguments);
|
|
5764
7792
|
const tree = await getRepositoryTree(args);
|
|
@@ -5973,8 +8001,20 @@ async function handleToolCall(params) {
|
|
|
5973
8001
|
};
|
|
5974
8002
|
}
|
|
5975
8003
|
case "list_merge_requests": {
|
|
5976
|
-
const
|
|
5977
|
-
|
|
8004
|
+
const { project_id, ...options } = ListMergeRequestsSchema.parse(params.arguments);
|
|
8005
|
+
// GitLab API treats _id and _username as mutually exclusive for these fields.
|
|
8006
|
+
// When both are provided, prefer _username and remove _id to avoid 400 errors.
|
|
8007
|
+
const cleanedOptions = { ...options };
|
|
8008
|
+
if (cleanedOptions.author_id && cleanedOptions.author_username) {
|
|
8009
|
+
delete cleanedOptions.author_id;
|
|
8010
|
+
}
|
|
8011
|
+
if (cleanedOptions.assignee_id && cleanedOptions.assignee_username) {
|
|
8012
|
+
delete cleanedOptions.assignee_id;
|
|
8013
|
+
}
|
|
8014
|
+
if (cleanedOptions.reviewer_id && cleanedOptions.reviewer_username) {
|
|
8015
|
+
delete cleanedOptions.reviewer_id;
|
|
8016
|
+
}
|
|
8017
|
+
const mergeRequests = await listMergeRequests(project_id, cleanedOptions);
|
|
5978
8018
|
return {
|
|
5979
8019
|
content: [{ type: "text", text: JSON.stringify(mergeRequests, null, 2) }],
|
|
5980
8020
|
};
|
|
@@ -6306,6 +8346,10 @@ function determineTransportMode() {
|
|
|
6306
8346
|
async function startStdioServer() {
|
|
6307
8347
|
const serverInstance = createServer();
|
|
6308
8348
|
const transport = new StdioServerTransport();
|
|
8349
|
+
transport.onclose = () => {
|
|
8350
|
+
logger.info("Stdio transport closed, releasing client pool");
|
|
8351
|
+
clientPool.closeAll();
|
|
8352
|
+
};
|
|
6309
8353
|
await serverInstance.connect(transport);
|
|
6310
8354
|
}
|
|
6311
8355
|
/**
|
|
@@ -6314,6 +8358,7 @@ async function startStdioServer() {
|
|
|
6314
8358
|
async function startSSEServer() {
|
|
6315
8359
|
const app = express();
|
|
6316
8360
|
const transports = {};
|
|
8361
|
+
let shuttingDown = false;
|
|
6317
8362
|
app.get("/sse", async (_, res) => {
|
|
6318
8363
|
const serverInstance = createServer();
|
|
6319
8364
|
const transport = new SSEServerTransport("/messages", res);
|
|
@@ -6340,12 +8385,35 @@ async function startSSEServer() {
|
|
|
6340
8385
|
transport: TransportMode.SSE,
|
|
6341
8386
|
});
|
|
6342
8387
|
});
|
|
6343
|
-
app.listen(Number(PORT), HOST, () => {
|
|
8388
|
+
const httpServer = app.listen(Number(PORT), HOST, () => {
|
|
6344
8389
|
logger.info(`GitLab MCP Server running with SSE transport`);
|
|
6345
8390
|
const colorGreen = "\x1b[32m";
|
|
6346
8391
|
const colorReset = "\x1b[0m";
|
|
6347
8392
|
logger.info(`${colorGreen}Endpoint: http://${HOST}:${PORT}/sse${colorReset}`);
|
|
6348
8393
|
});
|
|
8394
|
+
const shutdown = async (signal) => {
|
|
8395
|
+
if (shuttingDown)
|
|
8396
|
+
return;
|
|
8397
|
+
shuttingDown = true;
|
|
8398
|
+
logger.info(`${signal} received, shutting down SSE server...`);
|
|
8399
|
+
httpServer.close(() => logger.info("SSE HTTP server closed"));
|
|
8400
|
+
await Promise.allSettled(Object.values(transports).map(async (transport) => {
|
|
8401
|
+
try {
|
|
8402
|
+
await transport.close();
|
|
8403
|
+
}
|
|
8404
|
+
catch (error) {
|
|
8405
|
+
logger.error("Error closing SSE transport:", error);
|
|
8406
|
+
}
|
|
8407
|
+
}));
|
|
8408
|
+
clientPool.closeAll();
|
|
8409
|
+
process.exit(0);
|
|
8410
|
+
};
|
|
8411
|
+
process.on("SIGTERM", () => {
|
|
8412
|
+
void shutdown("SIGTERM");
|
|
8413
|
+
});
|
|
8414
|
+
process.on("SIGINT", () => {
|
|
8415
|
+
void shutdown("SIGINT");
|
|
8416
|
+
});
|
|
6349
8417
|
}
|
|
6350
8418
|
/**
|
|
6351
8419
|
* Start server with Streamable HTTP transport
|
|
@@ -6399,10 +8467,12 @@ async function startStreamableHTTPServer() {
|
|
|
6399
8467
|
/**
|
|
6400
8468
|
* Parse authentication from request headers
|
|
6401
8469
|
* Returns null if no auth found or invalid format
|
|
8470
|
+
* Supports: JOB-TOKEN header, Private-Token header, Authorization Bearer header
|
|
6402
8471
|
*/
|
|
6403
8472
|
const parseAuthHeaders = (req) => {
|
|
6404
8473
|
const authHeader = req.headers["authorization"] || "";
|
|
6405
8474
|
const privateToken = req.headers["private-token"] || "";
|
|
8475
|
+
const jobToken = req.headers["job-token"] || "";
|
|
6406
8476
|
const dynamicApiUrl = req.headers["x-gitlab-api-url"]?.trim();
|
|
6407
8477
|
let apiUrl = GITLAB_API_URL; // Default API URL
|
|
6408
8478
|
// Only process dynamic URL if the feature is enabled
|
|
@@ -6419,7 +8489,11 @@ async function startStreamableHTTPServer() {
|
|
|
6419
8489
|
// Extract token
|
|
6420
8490
|
let token = null;
|
|
6421
8491
|
let header = null;
|
|
6422
|
-
if (
|
|
8492
|
+
if (jobToken) {
|
|
8493
|
+
token = jobToken.trim();
|
|
8494
|
+
header = "JOB-TOKEN";
|
|
8495
|
+
}
|
|
8496
|
+
else if (privateToken) {
|
|
6423
8497
|
token = privateToken.trim();
|
|
6424
8498
|
header = "Private-Token";
|
|
6425
8499
|
}
|
|
@@ -6476,13 +8550,43 @@ async function startStreamableHTTPServer() {
|
|
|
6476
8550
|
};
|
|
6477
8551
|
// Configure Express middleware
|
|
6478
8552
|
app.use(express.json());
|
|
8553
|
+
// MCP OAuth — mount auth router and prepare bearer-auth middleware
|
|
8554
|
+
if (GITLAB_MCP_OAUTH) {
|
|
8555
|
+
// Trust first proxy so express-rate-limit uses X-Forwarded-For for real client IP.
|
|
8556
|
+
// Only enabled in OAuth mode where the server is typically behind a reverse proxy.
|
|
8557
|
+
app.set("trust proxy", 1);
|
|
8558
|
+
const gitlabBaseUrl = GITLAB_API_URL.replace(/\/api\/v4\/?$/, "").replace(/\/$/, "");
|
|
8559
|
+
const issuerUrl = new URL(MCP_SERVER_URL);
|
|
8560
|
+
const oauthProvider = createGitLabOAuthProvider(gitlabBaseUrl, GITLAB_OAUTH_APP_ID, "GitLab MCP Server", GITLAB_READ_ONLY_MODE);
|
|
8561
|
+
// Mounts /.well-known/oauth-authorization-server,
|
|
8562
|
+
// /.well-known/oauth-protected-resource,
|
|
8563
|
+
// /authorize, /token, /register, /revoke
|
|
8564
|
+
app.use(mcpAuthRouter({
|
|
8565
|
+
provider: oauthProvider,
|
|
8566
|
+
issuerUrl,
|
|
8567
|
+
baseUrl: issuerUrl,
|
|
8568
|
+
scopesSupported: ["api", "read_api", "read_user"],
|
|
8569
|
+
resourceName: "GitLab MCP Server",
|
|
8570
|
+
}));
|
|
8571
|
+
// Expose provider so the /mcp route middleware can reference it
|
|
8572
|
+
app._mcpOAuthProvider = oauthProvider;
|
|
8573
|
+
}
|
|
8574
|
+
// Build bearer-auth middleware — no-op unless GITLAB_MCP_OAUTH is enabled.
|
|
8575
|
+
// Unauthenticated requests receive 401 + WWW-Authenticate header, which is
|
|
8576
|
+
// exactly what Claude.ai needs to trigger the OAuth browser flow.
|
|
8577
|
+
const mcpBearerAuth = GITLAB_MCP_OAUTH
|
|
8578
|
+
? requireBearerAuth({
|
|
8579
|
+
verifier: app._mcpOAuthProvider,
|
|
8580
|
+
requiredScopes: [],
|
|
8581
|
+
})
|
|
8582
|
+
: (_req, _res, next) => next();
|
|
6479
8583
|
// Streamable HTTP endpoint - handles both session creation and message handling
|
|
6480
|
-
app.post("/mcp", async (req, res) => {
|
|
8584
|
+
app.post("/mcp", mcpBearerAuth, async (req, res) => {
|
|
6481
8585
|
const sessionId = req.headers["mcp-session-id"];
|
|
6482
8586
|
// Track request
|
|
6483
8587
|
metrics.requestsProcessed++;
|
|
6484
8588
|
// Rate limiting check for existing sessions
|
|
6485
|
-
if (REMOTE_AUTHORIZATION && sessionId && !checkRateLimit(sessionId)) {
|
|
8589
|
+
if ((REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) && sessionId && !checkRateLimit(sessionId)) {
|
|
6486
8590
|
metrics.rejectedByRateLimit++;
|
|
6487
8591
|
res.status(429).json({
|
|
6488
8592
|
error: "Rate limit exceeded",
|
|
@@ -6507,8 +8611,8 @@ async function startStreamableHTTPServer() {
|
|
|
6507
8611
|
if (!authData) {
|
|
6508
8612
|
metrics.authFailures++;
|
|
6509
8613
|
res.status(401).json({
|
|
6510
|
-
error: "Missing Authorization
|
|
6511
|
-
message: "Remote authorization is enabled. Please provide Authorization
|
|
8614
|
+
error: "Missing Authorization, Private-Token, or JOB-TOKEN header",
|
|
8615
|
+
message: "Remote authorization is enabled. Please provide Authorization, Private-Token, or JOB-TOKEN header.",
|
|
6512
8616
|
});
|
|
6513
8617
|
return;
|
|
6514
8618
|
}
|
|
@@ -6532,6 +8636,31 @@ async function startStreamableHTTPServer() {
|
|
|
6532
8636
|
// First request without session - will fail in initialization
|
|
6533
8637
|
}
|
|
6534
8638
|
}
|
|
8639
|
+
// MCP OAuth mode — token already validated by requireBearerAuth middleware.
|
|
8640
|
+
// req.auth is populated by the middleware; store/refresh per session so that
|
|
8641
|
+
// buildAuthHeaders() can pick it up via AsyncLocalStorage, exactly like the
|
|
8642
|
+
// REMOTE_AUTHORIZATION path.
|
|
8643
|
+
if (GITLAB_MCP_OAUTH) {
|
|
8644
|
+
const authInfo = req.auth;
|
|
8645
|
+
if (authInfo?.token && sessionId) {
|
|
8646
|
+
if (!authBySession[sessionId]) {
|
|
8647
|
+
authBySession[sessionId] = {
|
|
8648
|
+
header: "Authorization",
|
|
8649
|
+
token: authInfo.token,
|
|
8650
|
+
lastUsed: Date.now(),
|
|
8651
|
+
apiUrl: GITLAB_API_URL,
|
|
8652
|
+
};
|
|
8653
|
+
logger.info(`Session ${sessionId}: stored OAuth token (client: ${authInfo.clientId})`);
|
|
8654
|
+
setAuthTimeout(sessionId);
|
|
8655
|
+
}
|
|
8656
|
+
else {
|
|
8657
|
+
// Update token on every request — the client may have refreshed it
|
|
8658
|
+
authBySession[sessionId].token = authInfo.token;
|
|
8659
|
+
authBySession[sessionId].lastUsed = Date.now();
|
|
8660
|
+
setAuthTimeout(sessionId);
|
|
8661
|
+
}
|
|
8662
|
+
}
|
|
8663
|
+
}
|
|
6535
8664
|
// Handle request with proper AsyncLocalStorage context
|
|
6536
8665
|
const handleRequest = async () => {
|
|
6537
8666
|
try {
|
|
@@ -6559,6 +8688,20 @@ async function startStreamableHTTPServer() {
|
|
|
6559
8688
|
setAuthTimeout(newSessionId);
|
|
6560
8689
|
}
|
|
6561
8690
|
}
|
|
8691
|
+
// Store OAuth token for newly created session in MCP OAuth mode
|
|
8692
|
+
if (GITLAB_MCP_OAUTH && !authBySession[newSessionId]) {
|
|
8693
|
+
const authInfo = req.auth;
|
|
8694
|
+
if (authInfo?.token) {
|
|
8695
|
+
authBySession[newSessionId] = {
|
|
8696
|
+
header: "Authorization",
|
|
8697
|
+
token: authInfo.token,
|
|
8698
|
+
lastUsed: Date.now(),
|
|
8699
|
+
apiUrl: GITLAB_API_URL,
|
|
8700
|
+
};
|
|
8701
|
+
logger.info(`Session ${newSessionId}: stored OAuth token (client: ${authInfo.clientId})`);
|
|
8702
|
+
setAuthTimeout(newSessionId);
|
|
8703
|
+
}
|
|
8704
|
+
}
|
|
6562
8705
|
},
|
|
6563
8706
|
});
|
|
6564
8707
|
// Set up cleanup handler when transport closes
|
|
@@ -6568,7 +8711,7 @@ async function startStreamableHTTPServer() {
|
|
|
6568
8711
|
logger.warn(`Streamable HTTP transport closed for session ${sid}, cleaning up`);
|
|
6569
8712
|
delete streamableTransports[sid];
|
|
6570
8713
|
metrics.activeSessions--;
|
|
6571
|
-
if (REMOTE_AUTHORIZATION) {
|
|
8714
|
+
if (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) {
|
|
6572
8715
|
cleanupSessionAuth(sid);
|
|
6573
8716
|
delete sessionRequestCounts[sid];
|
|
6574
8717
|
logger.info(`Session ${sid}: cleaned up auth mapping`);
|
|
@@ -6591,8 +8734,8 @@ async function startStreamableHTTPServer() {
|
|
|
6591
8734
|
});
|
|
6592
8735
|
}
|
|
6593
8736
|
};
|
|
6594
|
-
// Execute with auth context in remote mode
|
|
6595
|
-
if (REMOTE_AUTHORIZATION && sessionId && authBySession[sessionId]) {
|
|
8737
|
+
// Execute with auth context in remote mode (REMOTE_AUTHORIZATION or GITLAB_MCP_OAUTH)
|
|
8738
|
+
if ((REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) && sessionId && authBySession[sessionId]) {
|
|
6596
8739
|
const authData = authBySession[sessionId];
|
|
6597
8740
|
const ctx = {
|
|
6598
8741
|
sessionId,
|
|
@@ -6605,7 +8748,7 @@ async function startStreamableHTTPServer() {
|
|
|
6605
8748
|
await sessionAuthStore.run(ctx, handleRequest);
|
|
6606
8749
|
}
|
|
6607
8750
|
else {
|
|
6608
|
-
// Standard execution (no
|
|
8751
|
+
// Standard execution (no per-session auth or no session yet)
|
|
6609
8752
|
await handleRequest();
|
|
6610
8753
|
}
|
|
6611
8754
|
});
|
|
@@ -6623,6 +8766,7 @@ async function startStreamableHTTPServer() {
|
|
|
6623
8766
|
...metrics,
|
|
6624
8767
|
activeSessions: Object.keys(streamableTransports).length,
|
|
6625
8768
|
authenticatedSessions: Object.keys(authBySession).length,
|
|
8769
|
+
gitlabClientPool: clientPool.getStats(),
|
|
6626
8770
|
uptime: process.uptime(),
|
|
6627
8771
|
memoryUsage: process.memoryUsage(),
|
|
6628
8772
|
config: {
|
|
@@ -6630,6 +8774,7 @@ async function startStreamableHTTPServer() {
|
|
|
6630
8774
|
maxRequestsPerMinute: MAX_REQUESTS_PER_MINUTE,
|
|
6631
8775
|
sessionTimeoutSeconds: SESSION_TIMEOUT_SECONDS,
|
|
6632
8776
|
remoteAuthEnabled: REMOTE_AUTHORIZATION,
|
|
8777
|
+
mcpOAuthEnabled: GITLAB_MCP_OAUTH,
|
|
6633
8778
|
},
|
|
6634
8779
|
});
|
|
6635
8780
|
});
|
|
@@ -6655,7 +8800,7 @@ async function startStreamableHTTPServer() {
|
|
|
6655
8800
|
try {
|
|
6656
8801
|
await transport.close();
|
|
6657
8802
|
logger.info(`Explicitly closed session via DELETE request: ${sessionId}`);
|
|
6658
|
-
if (REMOTE_AUTHORIZATION) {
|
|
8803
|
+
if (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) {
|
|
6659
8804
|
cleanupSessionAuth(sessionId);
|
|
6660
8805
|
delete sessionRequestCounts[sessionId];
|
|
6661
8806
|
logger.info(`Session ${sessionId}: cleaned up auth mapping on DELETE`);
|
|
@@ -6691,7 +8836,7 @@ async function startStreamableHTTPServer() {
|
|
|
6691
8836
|
const transport = streamableTransports[sessionId];
|
|
6692
8837
|
if (transport) {
|
|
6693
8838
|
await transport.close();
|
|
6694
|
-
if (REMOTE_AUTHORIZATION) {
|
|
8839
|
+
if (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) {
|
|
6695
8840
|
cleanupSessionAuth(sessionId);
|
|
6696
8841
|
delete sessionRequestCounts[sessionId];
|
|
6697
8842
|
}
|
|
@@ -6706,6 +8851,7 @@ async function startStreamableHTTPServer() {
|
|
|
6706
8851
|
Object.keys(authTimeouts).forEach(sessionId => {
|
|
6707
8852
|
clearAuthTimeout(sessionId);
|
|
6708
8853
|
});
|
|
8854
|
+
clientPool.closeAll();
|
|
6709
8855
|
logger.info("Graceful shutdown complete");
|
|
6710
8856
|
process.exit(0);
|
|
6711
8857
|
};
|