@zereight/mcp-gitlab 2.0.2 → 2.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/index.js CHANGED
@@ -1,250 +1,4119 @@
1
1
  #!/usr/bin/env node
2
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
2
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
3
  import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
4
- import express from "express";
5
- import { mcpserver } from "./src/mcpserver.js";
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
5
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
7
- import { config, validateConfiguration } from "./src/config.js";
8
- import { configureAuthentication } from "./src/authentication.js";
9
- import { logger } from "./src/logger.js";
10
- import argon2 from "./src/argon2wrapper.js";
6
+ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
7
+ import express from "express";
8
+ import fetchCookie from "fetch-cookie";
9
+ import fs from "fs";
10
+ import { HttpProxyAgent } from "http-proxy-agent";
11
+ import { HttpsProxyAgent } from "https-proxy-agent";
12
+ import nodeFetch from "node-fetch";
13
+ import path, { dirname } from "path";
14
+ import { SocksProxyAgent } from "socks-proxy-agent";
15
+ import { CookieJar, parse as parseCookie } from "tough-cookie";
16
+ import { fileURLToPath } from "url";
17
+ import { z } from "zod";
18
+ import { zodToJsonSchema } from "zod-to-json-schema";
19
+ // Add type imports for proxy agents
20
+ import { Agent } from "http";
21
+ import { Agent as HttpsAgent } from "https";
22
+ import { URL } from "url";
23
+ import { BulkPublishDraftNotesSchema, CancelPipelineSchema, CreateBranchSchema, CreateDraftNoteSchema, CreateIssueLinkSchema, CreateIssueNoteSchema, CreateIssueSchema, CreateLabelSchema, // Added
24
+ CreateMergeRequestNoteSchema, CreateMergeRequestSchema, CreateMergeRequestThreadSchema, CreateNoteSchema, CreateOrUpdateFileSchema, CreatePipelineSchema, CreateProjectMilestoneSchema, CreateRepositorySchema, CreateWikiPageSchema, DeleteDraftNoteSchema, DeleteIssueLinkSchema, DeleteIssueSchema, DeleteLabelSchema, DeleteProjectMilestoneSchema, DeleteWikiPageSchema, EditProjectMilestoneSchema, ForkRepositorySchema, GetBranchDiffsSchema, GetCommitDiffSchema, GetCommitSchema, GetDraftNoteSchema, GetFileContentsSchema, GetIssueLinkSchema, GetIssueSchema, GetLabelSchema, GetMergeRequestDiffsSchema, GetMergeRequestSchema, GetMilestoneBurndownEventsSchema, GetMilestoneIssuesSchema, GetMilestoneMergeRequestsSchema, GetNamespaceSchema,
25
+ // pipeline job schemas
26
+ GetPipelineJobOutputSchema, GetPipelineSchema, GetProjectMilestoneSchema, GetProjectSchema, GetRepositoryTreeSchema, GetUsersSchema, GetWikiPageSchema, GitLabCommitSchema, GitLabCompareResultSchema, GitLabContentSchema, GitLabCreateUpdateFileResponseSchema, GitLabDiffSchema,
27
+ // Discussion Schemas
28
+ GitLabDiscussionNoteSchema, // Added
29
+ GitLabDiscussionSchema,
30
+ // Draft Notes Schemas
31
+ GitLabDraftNoteSchema, GitLabForkSchema, GitLabIssueLinkSchema, GitLabIssueSchema, GitLabIssueWithLinkDetailsSchema, GitLabMarkdownUploadSchema, GitLabMergeRequestSchema, GitLabMilestonesSchema, GitLabNamespaceExistsResponseSchema, GitLabNamespaceSchema, GitLabPipelineJobSchema, GitLabPipelineSchema, GitLabPipelineTriggerJobSchema, GitLabProjectMemberSchema, GitLabProjectSchema, GitLabReferenceSchema, GitLabRepositorySchema, GitLabSearchResponseSchema, GitLabTreeItemSchema, GitLabTreeSchema, GitLabUserSchema, GitLabUsersResponseSchema, GitLabWikiPageSchema, GroupIteration, ListCommitsSchema, ListDraftNotesSchema, ListGroupIterationsSchema, ListGroupProjectsSchema, ListIssueDiscussionsSchema, ListIssueLinksSchema, ListIssuesSchema, ListLabelsSchema, ListMergeRequestDiffsSchema, // Added
32
+ ListMergeRequestDiscussionsSchema, ListMergeRequestsSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelinesSchema, ListPipelineTriggerJobsSchema, ListProjectMembersSchema, ListProjectMilestonesSchema, ListProjectsSchema, ListWikiPagesSchema, MarkdownUploadSchema, DownloadAttachmentSchema, MergeMergeRequestSchema, MyIssuesSchema, PaginatedDiscussionsResponseSchema, PromoteProjectMilestoneSchema, PublishDraftNoteSchema, PushFilesSchema, RetryPipelineSchema, SearchRepositoriesSchema, UpdateDraftNoteSchema, UpdateIssueNoteSchema, UpdateIssueSchema, UpdateLabelSchema, UpdateMergeRequestNoteSchema, UpdateMergeRequestSchema, UpdateWikiPageSchema, VerifyNamespaceSchema } from "./schemas.js";
11
33
  import { randomUUID } from "crypto";
12
- validateConfiguration();
34
+ import { pino } from "pino";
35
+ const logger = pino({
36
+ level: process.env.LOG_LEVEL || "info",
37
+ transport: {
38
+ target: "pino-pretty",
39
+ options: {
40
+ colorize: true,
41
+ levelFirst: true,
42
+ destination: 2,
43
+ },
44
+ },
45
+ });
46
+ /**
47
+ * Available transport modes for MCP server
48
+ */
49
+ var TransportMode;
50
+ (function (TransportMode) {
51
+ TransportMode["STDIO"] = "stdio";
52
+ TransportMode["SSE"] = "sse";
53
+ TransportMode["STREAMABLE_HTTP"] = "streamable-http";
54
+ })(TransportMode || (TransportMode = {}));
55
+ /**
56
+ * Read version from package.json
57
+ */
58
+ const __filename = fileURLToPath(import.meta.url);
59
+ const __dirname = dirname(__filename);
60
+ const packageJsonPath = path.resolve(__dirname, "../package.json");
61
+ let SERVER_VERSION = "unknown";
62
+ try {
63
+ if (fs.existsSync(packageJsonPath)) {
64
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
65
+ SERVER_VERSION = packageJson.version || SERVER_VERSION;
66
+ }
67
+ }
68
+ catch (error) {
69
+ // Warning: Could not read version from package.json - silently continue
70
+ }
71
+ const server = new Server({
72
+ name: "better-gitlab-mcp-server",
73
+ version: SERVER_VERSION,
74
+ }, {
75
+ capabilities: {
76
+ tools: {},
77
+ },
78
+ });
79
+ const GITLAB_PERSONAL_ACCESS_TOKEN = process.env.GITLAB_PERSONAL_ACCESS_TOKEN;
80
+ const GITLAB_AUTH_COOKIE_PATH = process.env.GITLAB_AUTH_COOKIE_PATH;
81
+ const IS_OLD = process.env.GITLAB_IS_OLD === "true";
82
+ const GITLAB_READ_ONLY_MODE = process.env.GITLAB_READ_ONLY_MODE === "true";
83
+ const GITLAB_DENIED_TOOLS_REGEX = process.env.GITLAB_DENIED_TOOLS_REGEX ? new RegExp(process.env.GITLAB_DENIED_TOOLS_REGEX) : undefined;
84
+ const USE_GITLAB_WIKI = process.env.USE_GITLAB_WIKI === "true";
85
+ const USE_MILESTONE = process.env.USE_MILESTONE === "true";
86
+ const USE_PIPELINE = process.env.USE_PIPELINE === "true";
87
+ const SSE = process.env.SSE === "true";
88
+ const STREAMABLE_HTTP = process.env.STREAMABLE_HTTP === "true";
89
+ const HOST = process.env.HOST || "0.0.0.0";
90
+ const PORT = process.env.PORT || 3002;
91
+ // Add proxy configuration
92
+ const HTTP_PROXY = process.env.HTTP_PROXY;
93
+ const HTTPS_PROXY = process.env.HTTPS_PROXY;
94
+ const NODE_TLS_REJECT_UNAUTHORIZED = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
95
+ const GITLAB_CA_CERT_PATH = process.env.GITLAB_CA_CERT_PATH;
96
+ let sslOptions = undefined;
97
+ if (NODE_TLS_REJECT_UNAUTHORIZED === "0") {
98
+ sslOptions = { rejectUnauthorized: false };
99
+ }
100
+ else if (GITLAB_CA_CERT_PATH) {
101
+ const ca = fs.readFileSync(GITLAB_CA_CERT_PATH);
102
+ sslOptions = { ca };
103
+ }
104
+ // Configure proxy agents if proxies are set
105
+ let httpAgent = undefined;
106
+ let httpsAgent = undefined;
107
+ if (HTTP_PROXY) {
108
+ if (HTTP_PROXY.startsWith("socks")) {
109
+ httpAgent = new SocksProxyAgent(HTTP_PROXY);
110
+ }
111
+ else {
112
+ httpAgent = new HttpProxyAgent(HTTP_PROXY);
113
+ }
114
+ }
115
+ if (HTTPS_PROXY) {
116
+ if (HTTPS_PROXY.startsWith("socks")) {
117
+ httpsAgent = new SocksProxyAgent(HTTPS_PROXY);
118
+ }
119
+ else {
120
+ httpsAgent = new HttpsProxyAgent(HTTPS_PROXY, sslOptions);
121
+ }
122
+ }
123
+ httpsAgent = httpsAgent || new HttpsAgent(sslOptions);
124
+ httpAgent = httpAgent || new Agent();
125
+ // Create cookie jar with clean Netscape file parsing
126
+ const createCookieJar = () => {
127
+ if (!GITLAB_AUTH_COOKIE_PATH)
128
+ return null;
129
+ try {
130
+ const cookiePath = GITLAB_AUTH_COOKIE_PATH.startsWith("~/")
131
+ ? path.join(process.env.HOME || "", GITLAB_AUTH_COOKIE_PATH.slice(2))
132
+ : GITLAB_AUTH_COOKIE_PATH;
133
+ const jar = new CookieJar();
134
+ const cookieContent = fs.readFileSync(cookiePath, "utf8");
135
+ cookieContent.split("\n").forEach(line => {
136
+ // Handle #HttpOnly_ prefix
137
+ if (line.startsWith("#HttpOnly_")) {
138
+ line = line.slice(10);
139
+ }
140
+ // Skip comments and empty lines
141
+ if (line.startsWith("#") || !line.trim()) {
142
+ return;
143
+ }
144
+ // Parse Netscape format: domain, flag, path, secure, expires, name, value
145
+ const parts = line.split("\t");
146
+ if (parts.length >= 7) {
147
+ const [domain, , path, secure, expires, name, value] = parts;
148
+ // Build cookie string in standard format
149
+ const cookieStr = `${name}=${value}; Domain=${domain}; Path=${path}${secure === "TRUE" ? "; Secure" : ""}${expires !== "0" ? `; Expires=${new Date(parseInt(expires) * 1000).toUTCString()}` : ""}`;
150
+ // Use tough-cookie's parse function for robust parsing
151
+ const cookie = parseCookie(cookieStr);
152
+ if (cookie) {
153
+ const url = `${secure === "TRUE" ? "https" : "http"}://${domain.startsWith(".") ? domain.slice(1) : domain}`;
154
+ jar.setCookieSync(cookie, url);
155
+ }
156
+ }
157
+ });
158
+ return jar;
159
+ }
160
+ catch (error) {
161
+ logger.error("Error loading cookie file:", error);
162
+ return null;
163
+ }
164
+ };
165
+ // Initialize cookie jar and fetch
166
+ const cookieJar = createCookieJar();
167
+ const fetch = cookieJar ? fetchCookie(nodeFetch, cookieJar) : nodeFetch;
168
+ // Ensure session is established for the current request
169
+ async function ensureSessionForRequest() {
170
+ if (!cookieJar || !GITLAB_AUTH_COOKIE_PATH)
171
+ return;
172
+ // Extract the base URL from GITLAB_API_URL
173
+ const apiUrl = new URL(GITLAB_API_URL);
174
+ const baseUrl = `${apiUrl.protocol}//${apiUrl.hostname}`;
175
+ // Check if we already have GitLab session cookies
176
+ const gitlabCookies = cookieJar.getCookiesSync(baseUrl);
177
+ const hasSessionCookie = gitlabCookies.some(cookie => cookie.key === "_gitlab_session" || cookie.key === "remember_user_token");
178
+ if (!hasSessionCookie) {
179
+ try {
180
+ // Establish session with a lightweight request
181
+ await fetch(`${GITLAB_API_URL}/user`, {
182
+ ...DEFAULT_FETCH_CONFIG,
183
+ redirect: "follow",
184
+ }).catch(() => {
185
+ // Ignore errors - the important thing is that cookies get set during redirects
186
+ });
187
+ // Small delay to ensure cookies are fully processed
188
+ await new Promise(resolve => setTimeout(resolve, 100));
189
+ }
190
+ catch (error) {
191
+ // Ignore session establishment errors
192
+ }
193
+ }
194
+ }
195
+ // Modify DEFAULT_HEADERS to include agent configuration
196
+ const DEFAULT_HEADERS = {
197
+ Accept: "application/json",
198
+ "Content-Type": "application/json",
199
+ };
200
+ if (IS_OLD) {
201
+ DEFAULT_HEADERS["Private-Token"] = `${GITLAB_PERSONAL_ACCESS_TOKEN}`;
202
+ }
203
+ else {
204
+ DEFAULT_HEADERS["Authorization"] = `Bearer ${GITLAB_PERSONAL_ACCESS_TOKEN}`;
205
+ }
206
+ // Create a default fetch configuration object that includes proxy agents if set
207
+ const DEFAULT_FETCH_CONFIG = {
208
+ headers: DEFAULT_HEADERS,
209
+ agent: (parsedUrl) => {
210
+ if (parsedUrl.protocol === "https:") {
211
+ return httpsAgent;
212
+ }
213
+ return httpAgent;
214
+ },
215
+ };
216
+ // Define all available tools
217
+ const allTools = [
218
+ {
219
+ name: "merge_merge_request",
220
+ description: "Merge a merge request in a GitLab project",
221
+ inputSchema: zodToJsonSchema(MergeMergeRequestSchema),
222
+ },
223
+ {
224
+ name: "create_or_update_file",
225
+ description: "Create or update a single file in a GitLab project",
226
+ inputSchema: zodToJsonSchema(CreateOrUpdateFileSchema),
227
+ },
228
+ {
229
+ name: "search_repositories",
230
+ description: "Search for GitLab projects",
231
+ inputSchema: zodToJsonSchema(SearchRepositoriesSchema),
232
+ },
233
+ {
234
+ name: "create_repository",
235
+ description: "Create a new GitLab project",
236
+ inputSchema: zodToJsonSchema(CreateRepositorySchema),
237
+ },
238
+ {
239
+ name: "get_file_contents",
240
+ description: "Get the contents of a file or directory from a GitLab project",
241
+ inputSchema: zodToJsonSchema(GetFileContentsSchema),
242
+ },
243
+ {
244
+ name: "push_files",
245
+ description: "Push multiple files to a GitLab project in a single commit",
246
+ inputSchema: zodToJsonSchema(PushFilesSchema),
247
+ },
248
+ {
249
+ name: "create_issue",
250
+ description: "Create a new issue in a GitLab project",
251
+ inputSchema: zodToJsonSchema(CreateIssueSchema),
252
+ },
253
+ {
254
+ name: "create_merge_request",
255
+ description: "Create a new merge request in a GitLab project",
256
+ inputSchema: zodToJsonSchema(CreateMergeRequestSchema),
257
+ },
258
+ {
259
+ name: "fork_repository",
260
+ description: "Fork a GitLab project to your account or specified namespace",
261
+ inputSchema: zodToJsonSchema(ForkRepositorySchema),
262
+ },
263
+ {
264
+ name: "create_branch",
265
+ description: "Create a new branch in a GitLab project",
266
+ inputSchema: zodToJsonSchema(CreateBranchSchema),
267
+ },
268
+ {
269
+ name: "get_merge_request",
270
+ description: "Get details of a merge request (Either mergeRequestIid or branchName must be provided)",
271
+ inputSchema: zodToJsonSchema(GetMergeRequestSchema),
272
+ },
273
+ {
274
+ name: "get_merge_request_diffs",
275
+ description: "Get the changes/diffs of a merge request (Either mergeRequestIid or branchName must be provided)",
276
+ inputSchema: zodToJsonSchema(GetMergeRequestDiffsSchema),
277
+ },
278
+ {
279
+ name: "list_merge_request_diffs",
280
+ description: "List merge request diffs with pagination support (Either mergeRequestIid or branchName must be provided)",
281
+ inputSchema: zodToJsonSchema(ListMergeRequestDiffsSchema),
282
+ },
283
+ {
284
+ name: "get_branch_diffs",
285
+ description: "Get the changes/diffs between two branches or commits in a GitLab project",
286
+ inputSchema: zodToJsonSchema(GetBranchDiffsSchema),
287
+ },
288
+ {
289
+ name: "update_merge_request",
290
+ description: "Update a merge request (Either mergeRequestIid or branchName must be provided)",
291
+ inputSchema: zodToJsonSchema(UpdateMergeRequestSchema),
292
+ },
293
+ {
294
+ name: "create_note",
295
+ description: "Create a new note (comment) to an issue or merge request",
296
+ inputSchema: zodToJsonSchema(CreateNoteSchema),
297
+ },
298
+ {
299
+ name: "create_merge_request_thread",
300
+ description: "Create a new thread on a merge request",
301
+ inputSchema: zodToJsonSchema(CreateMergeRequestThreadSchema),
302
+ },
303
+ {
304
+ name: "mr_discussions",
305
+ description: "List discussion items for a merge request",
306
+ inputSchema: zodToJsonSchema(ListMergeRequestDiscussionsSchema),
307
+ },
308
+ {
309
+ name: "update_merge_request_note",
310
+ description: "Modify an existing merge request thread note",
311
+ inputSchema: zodToJsonSchema(UpdateMergeRequestNoteSchema),
312
+ },
313
+ {
314
+ name: "create_merge_request_note",
315
+ description: "Add a new note to an existing merge request thread",
316
+ inputSchema: zodToJsonSchema(CreateMergeRequestNoteSchema),
317
+ },
318
+ {
319
+ name: "get_draft_note",
320
+ description: "Get a single draft note from a merge request",
321
+ inputSchema: zodToJsonSchema(GetDraftNoteSchema),
322
+ },
323
+ {
324
+ name: "list_draft_notes",
325
+ description: "List draft notes for a merge request",
326
+ inputSchema: zodToJsonSchema(ListDraftNotesSchema),
327
+ },
328
+ {
329
+ name: "create_draft_note",
330
+ description: "Create a draft note for a merge request",
331
+ inputSchema: zodToJsonSchema(CreateDraftNoteSchema),
332
+ },
333
+ {
334
+ name: "update_draft_note",
335
+ description: "Update an existing draft note",
336
+ inputSchema: zodToJsonSchema(UpdateDraftNoteSchema),
337
+ },
338
+ {
339
+ name: "delete_draft_note",
340
+ description: "Delete a draft note",
341
+ inputSchema: zodToJsonSchema(DeleteDraftNoteSchema),
342
+ },
343
+ {
344
+ name: "publish_draft_note",
345
+ description: "Publish a single draft note",
346
+ inputSchema: zodToJsonSchema(PublishDraftNoteSchema),
347
+ },
348
+ {
349
+ name: "bulk_publish_draft_notes",
350
+ description: "Publish all draft notes for a merge request",
351
+ inputSchema: zodToJsonSchema(BulkPublishDraftNotesSchema),
352
+ },
353
+ {
354
+ name: "update_issue_note",
355
+ description: "Modify an existing issue thread note",
356
+ inputSchema: zodToJsonSchema(UpdateIssueNoteSchema),
357
+ },
358
+ {
359
+ name: "create_issue_note",
360
+ description: "Add a new note to an existing issue thread",
361
+ inputSchema: zodToJsonSchema(CreateIssueNoteSchema),
362
+ },
363
+ {
364
+ name: "list_issues",
365
+ description: "List issues (default: created by current user only; use scope='all' for all accessible issues)",
366
+ inputSchema: zodToJsonSchema(ListIssuesSchema),
367
+ },
368
+ {
369
+ name: "my_issues",
370
+ description: "List issues assigned to the authenticated user (defaults to open issues)",
371
+ inputSchema: zodToJsonSchema(MyIssuesSchema),
372
+ },
373
+ {
374
+ name: "get_issue",
375
+ description: "Get details of a specific issue in a GitLab project",
376
+ inputSchema: zodToJsonSchema(GetIssueSchema),
377
+ },
378
+ {
379
+ name: "update_issue",
380
+ description: "Update an issue in a GitLab project",
381
+ inputSchema: zodToJsonSchema(UpdateIssueSchema),
382
+ },
383
+ {
384
+ name: "delete_issue",
385
+ description: "Delete an issue from a GitLab project",
386
+ inputSchema: zodToJsonSchema(DeleteIssueSchema),
387
+ },
388
+ {
389
+ name: "list_issue_links",
390
+ description: "List all issue links for a specific issue",
391
+ inputSchema: zodToJsonSchema(ListIssueLinksSchema),
392
+ },
393
+ {
394
+ name: "list_issue_discussions",
395
+ description: "List discussions for an issue in a GitLab project",
396
+ inputSchema: zodToJsonSchema(ListIssueDiscussionsSchema),
397
+ },
398
+ {
399
+ name: "get_issue_link",
400
+ description: "Get a specific issue link",
401
+ inputSchema: zodToJsonSchema(GetIssueLinkSchema),
402
+ },
403
+ {
404
+ name: "create_issue_link",
405
+ description: "Create an issue link between two issues",
406
+ inputSchema: zodToJsonSchema(CreateIssueLinkSchema),
407
+ },
408
+ {
409
+ name: "delete_issue_link",
410
+ description: "Delete an issue link",
411
+ inputSchema: zodToJsonSchema(DeleteIssueLinkSchema),
412
+ },
413
+ {
414
+ name: "list_namespaces",
415
+ description: "List all namespaces available to the current user",
416
+ inputSchema: zodToJsonSchema(ListNamespacesSchema),
417
+ },
418
+ {
419
+ name: "get_namespace",
420
+ description: "Get details of a namespace by ID or path",
421
+ inputSchema: zodToJsonSchema(GetNamespaceSchema),
422
+ },
423
+ {
424
+ name: "verify_namespace",
425
+ description: "Verify if a namespace path exists",
426
+ inputSchema: zodToJsonSchema(VerifyNamespaceSchema),
427
+ },
428
+ {
429
+ name: "get_project",
430
+ description: "Get details of a specific project",
431
+ inputSchema: zodToJsonSchema(GetProjectSchema),
432
+ },
433
+ {
434
+ name: "list_projects",
435
+ description: "List projects accessible by the current user",
436
+ inputSchema: zodToJsonSchema(ListProjectsSchema),
437
+ },
438
+ {
439
+ name: "list_project_members",
440
+ description: "List members of a GitLab project",
441
+ inputSchema: zodToJsonSchema(ListProjectMembersSchema),
442
+ },
443
+ {
444
+ name: "list_labels",
445
+ description: "List labels for a project",
446
+ inputSchema: zodToJsonSchema(ListLabelsSchema),
447
+ },
448
+ {
449
+ name: "get_label",
450
+ description: "Get a single label from a project",
451
+ inputSchema: zodToJsonSchema(GetLabelSchema),
452
+ },
453
+ {
454
+ name: "create_label",
455
+ description: "Create a new label in a project",
456
+ inputSchema: zodToJsonSchema(CreateLabelSchema),
457
+ },
458
+ {
459
+ name: "update_label",
460
+ description: "Update an existing label in a project",
461
+ inputSchema: zodToJsonSchema(UpdateLabelSchema),
462
+ },
463
+ {
464
+ name: "delete_label",
465
+ description: "Delete a label from a project",
466
+ inputSchema: zodToJsonSchema(DeleteLabelSchema),
467
+ },
468
+ {
469
+ name: "list_group_projects",
470
+ description: "List projects in a GitLab group with filtering options",
471
+ inputSchema: zodToJsonSchema(ListGroupProjectsSchema),
472
+ },
473
+ {
474
+ name: "list_wiki_pages",
475
+ description: "List wiki pages in a GitLab project",
476
+ inputSchema: zodToJsonSchema(ListWikiPagesSchema),
477
+ },
478
+ {
479
+ name: "get_wiki_page",
480
+ description: "Get details of a specific wiki page",
481
+ inputSchema: zodToJsonSchema(GetWikiPageSchema),
482
+ },
483
+ {
484
+ name: "create_wiki_page",
485
+ description: "Create a new wiki page in a GitLab project",
486
+ inputSchema: zodToJsonSchema(CreateWikiPageSchema),
487
+ },
488
+ {
489
+ name: "update_wiki_page",
490
+ description: "Update an existing wiki page in a GitLab project",
491
+ inputSchema: zodToJsonSchema(UpdateWikiPageSchema),
492
+ },
493
+ {
494
+ name: "delete_wiki_page",
495
+ description: "Delete a wiki page from a GitLab project",
496
+ inputSchema: zodToJsonSchema(DeleteWikiPageSchema),
497
+ },
498
+ {
499
+ name: "get_repository_tree",
500
+ description: "Get the repository tree for a GitLab project (list files and directories)",
501
+ inputSchema: zodToJsonSchema(GetRepositoryTreeSchema),
502
+ },
503
+ {
504
+ name: "list_pipelines",
505
+ description: "List pipelines in a GitLab project with filtering options",
506
+ inputSchema: zodToJsonSchema(ListPipelinesSchema),
507
+ },
508
+ {
509
+ name: "get_pipeline",
510
+ description: "Get details of a specific pipeline in a GitLab project",
511
+ inputSchema: zodToJsonSchema(GetPipelineSchema),
512
+ },
513
+ {
514
+ name: "list_pipeline_jobs",
515
+ description: "List all jobs in a specific pipeline",
516
+ inputSchema: zodToJsonSchema(ListPipelineJobsSchema),
517
+ },
518
+ {
519
+ name: "list_pipeline_trigger_jobs",
520
+ description: "List all trigger jobs (bridges) in a specific pipeline that trigger downstream pipelines",
521
+ inputSchema: zodToJsonSchema(ListPipelineTriggerJobsSchema),
522
+ },
523
+ {
524
+ name: "get_pipeline_job",
525
+ description: "Get details of a GitLab pipeline job number",
526
+ inputSchema: zodToJsonSchema(GetPipelineJobOutputSchema),
527
+ },
528
+ {
529
+ name: "get_pipeline_job_output",
530
+ description: "Get the output/trace of a GitLab pipeline job with optional pagination to limit context window usage",
531
+ inputSchema: zodToJsonSchema(GetPipelineJobOutputSchema),
532
+ },
533
+ {
534
+ name: "create_pipeline",
535
+ description: "Create a new pipeline for a branch or tag",
536
+ inputSchema: zodToJsonSchema(CreatePipelineSchema),
537
+ },
538
+ {
539
+ name: "retry_pipeline",
540
+ description: "Retry a failed or canceled pipeline",
541
+ inputSchema: zodToJsonSchema(RetryPipelineSchema),
542
+ },
543
+ {
544
+ name: "cancel_pipeline",
545
+ description: "Cancel a running pipeline",
546
+ inputSchema: zodToJsonSchema(CancelPipelineSchema),
547
+ },
548
+ {
549
+ name: "list_merge_requests",
550
+ description: "List merge requests in a GitLab project with filtering options",
551
+ inputSchema: zodToJsonSchema(ListMergeRequestsSchema),
552
+ },
553
+ {
554
+ name: "list_milestones",
555
+ description: "List milestones in a GitLab project with filtering options",
556
+ inputSchema: zodToJsonSchema(ListProjectMilestonesSchema),
557
+ },
558
+ {
559
+ name: "get_milestone",
560
+ description: "Get details of a specific milestone",
561
+ inputSchema: zodToJsonSchema(GetProjectMilestoneSchema),
562
+ },
563
+ {
564
+ name: "create_milestone",
565
+ description: "Create a new milestone in a GitLab project",
566
+ inputSchema: zodToJsonSchema(CreateProjectMilestoneSchema),
567
+ },
568
+ {
569
+ name: "edit_milestone",
570
+ description: "Edit an existing milestone in a GitLab project",
571
+ inputSchema: zodToJsonSchema(EditProjectMilestoneSchema),
572
+ },
573
+ {
574
+ name: "delete_milestone",
575
+ description: "Delete a milestone from a GitLab project",
576
+ inputSchema: zodToJsonSchema(DeleteProjectMilestoneSchema),
577
+ },
578
+ {
579
+ name: "get_milestone_issue",
580
+ description: "Get issues associated with a specific milestone",
581
+ inputSchema: zodToJsonSchema(GetMilestoneIssuesSchema),
582
+ },
583
+ {
584
+ name: "get_milestone_merge_requests",
585
+ description: "Get merge requests associated with a specific milestone",
586
+ inputSchema: zodToJsonSchema(GetMilestoneMergeRequestsSchema),
587
+ },
588
+ {
589
+ name: "promote_milestone",
590
+ description: "Promote a milestone to the next stage",
591
+ inputSchema: zodToJsonSchema(PromoteProjectMilestoneSchema),
592
+ },
593
+ {
594
+ name: "get_milestone_burndown_events",
595
+ description: "Get burndown events for a specific milestone",
596
+ inputSchema: zodToJsonSchema(GetMilestoneBurndownEventsSchema),
597
+ },
598
+ {
599
+ name: "get_users",
600
+ description: "Get GitLab user details by usernames",
601
+ inputSchema: zodToJsonSchema(GetUsersSchema),
602
+ },
603
+ {
604
+ name: "list_commits",
605
+ description: "List repository commits with filtering options",
606
+ inputSchema: zodToJsonSchema(ListCommitsSchema),
607
+ },
608
+ {
609
+ name: "get_commit",
610
+ description: "Get details of a specific commit",
611
+ inputSchema: zodToJsonSchema(GetCommitSchema),
612
+ },
613
+ {
614
+ name: "get_commit_diff",
615
+ description: "Get changes/diffs of a specific commit",
616
+ inputSchema: zodToJsonSchema(GetCommitDiffSchema),
617
+ },
618
+ {
619
+ name: "list_group_iterations",
620
+ description: "List group iterations with filtering options",
621
+ inputSchema: zodToJsonSchema(ListGroupIterationsSchema),
622
+ },
623
+ {
624
+ name: "upload_markdown",
625
+ description: "Upload a file to a GitLab project for use in markdown content",
626
+ inputSchema: zodToJsonSchema(MarkdownUploadSchema),
627
+ },
628
+ {
629
+ name: "download_attachment",
630
+ description: "Download an uploaded file from a GitLab project by secret and filename",
631
+ inputSchema: zodToJsonSchema(DownloadAttachmentSchema),
632
+ },
633
+ ];
634
+ // Define which tools are read-only
635
+ const readOnlyTools = [
636
+ "search_repositories",
637
+ "get_file_contents",
638
+ "get_merge_request",
639
+ "get_merge_request_diffs",
640
+ "get_branch_diffs",
641
+ "mr_discussions",
642
+ "list_issues",
643
+ "my_issues",
644
+ "list_merge_requests",
645
+ "get_issue",
646
+ "list_issue_links",
647
+ "list_issue_discussions",
648
+ "get_issue_link",
649
+ "list_namespaces",
650
+ "get_namespace",
651
+ "verify_namespace",
652
+ "get_project",
653
+ "list_projects",
654
+ "list_project_members",
655
+ "get_pipeline",
656
+ "list_pipelines",
657
+ "list_pipeline_jobs",
658
+ "list_pipeline_trigger_jobs",
659
+ "get_pipeline_job",
660
+ "get_pipeline_job_output",
661
+ "list_labels",
662
+ "get_label",
663
+ "list_group_projects",
664
+ "get_repository_tree",
665
+ "list_milestones",
666
+ "get_milestone",
667
+ "get_milestone_issue",
668
+ "get_milestone_merge_requests",
669
+ "get_milestone_burndown_events",
670
+ "list_wiki_pages",
671
+ "get_wiki_page",
672
+ "get_users",
673
+ "list_commits",
674
+ "get_commit",
675
+ "get_commit_diff",
676
+ "list_group_iterations",
677
+ "get_group_iteration",
678
+ "download_attachment",
679
+ ];
680
+ // Define which tools are related to wiki and can be toggled by USE_GITLAB_WIKI
681
+ const wikiToolNames = [
682
+ "list_wiki_pages",
683
+ "get_wiki_page",
684
+ "create_wiki_page",
685
+ "update_wiki_page",
686
+ "delete_wiki_page",
687
+ "upload_wiki_attachment",
688
+ ];
689
+ // Define which tools are related to milestones and can be toggled by USE_MILESTONE
690
+ const milestoneToolNames = [
691
+ "list_milestones",
692
+ "get_milestone",
693
+ "create_milestone",
694
+ "edit_milestone",
695
+ "delete_milestone",
696
+ "get_milestone_issue",
697
+ "get_milestone_merge_requests",
698
+ "promote_milestone",
699
+ "get_milestone_burndown_events",
700
+ ];
701
+ // Define which tools are related to pipelines and can be toggled by USE_PIPELINE
702
+ const pipelineToolNames = [
703
+ "list_pipelines",
704
+ "get_pipeline",
705
+ "list_pipeline_jobs",
706
+ "list_pipeline_trigger_jobs",
707
+ "get_pipeline_job",
708
+ "get_pipeline_job_output",
709
+ "create_pipeline",
710
+ "retry_pipeline",
711
+ "cancel_pipeline",
712
+ ];
713
+ /**
714
+ * Smart URL handling for GitLab API
715
+ *
716
+ * @param {string | undefined} url - Input GitLab API URL
717
+ * @returns {string} Normalized GitLab API URL with /api/v4 path
718
+ */
719
+ function normalizeGitLabApiUrl(url) {
720
+ if (!url) {
721
+ return "https://gitlab.com/api/v4";
722
+ }
723
+ // Remove trailing slash if present
724
+ let normalizedUrl = url.endsWith("/") ? url.slice(0, -1) : url;
725
+ // Check if URL already has /api/v4
726
+ if (!normalizedUrl.endsWith("/api/v4") && !normalizedUrl.endsWith("/api/v4/")) {
727
+ // Append /api/v4 if not already present
728
+ normalizedUrl = `${normalizedUrl}/api/v4`;
729
+ }
730
+ return normalizedUrl;
731
+ }
732
+ // Use the normalizeGitLabApiUrl function to handle various URL formats
733
+ const GITLAB_API_URL = normalizeGitLabApiUrl(process.env.GITLAB_API_URL || "");
734
+ const GITLAB_PROJECT_ID = process.env.GITLAB_PROJECT_ID;
735
+ const GITLAB_ALLOWED_PROJECT_IDS = process.env.GITLAB_ALLOWED_PROJECT_IDS?.split(',').map(id => id.trim()).filter(Boolean) || [];
736
+ if (!GITLAB_PERSONAL_ACCESS_TOKEN) {
737
+ logger.error("GITLAB_PERSONAL_ACCESS_TOKEN environment variable is not set");
738
+ process.exit(1);
739
+ }
740
+ /**
741
+ * Utility function for handling GitLab API errors
742
+ * API 에러 처리를 위한 유틸리티 함수 (Utility function for handling API errors)
743
+ *
744
+ * @param {import("node-fetch").Response} response - The response from GitLab API
745
+ * @throws {Error} Throws an error with response details if the request failed
746
+ */
747
+ async function handleGitLabError(response) {
748
+ if (!response.ok) {
749
+ const errorBody = await response.text();
750
+ // Check specifically for Rate Limit error
751
+ if (response.status === 403 && errorBody.includes("User API Key Rate limit exceeded")) {
752
+ logger.error("GitLab API Rate Limit Exceeded:", errorBody);
753
+ logger.error("User API Key Rate limit exceeded. Please try again later.");
754
+ throw new Error(`GitLab API Rate Limit Exceeded: ${errorBody}`);
755
+ }
756
+ else {
757
+ // Handle other API errors
758
+ throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
759
+ }
760
+ }
761
+ }
762
+ /**
763
+ * @param {string} projectId - The project ID parameter passed to the function
764
+ * @returns {string} The project ID to use for the API call
765
+ * @throws {Error} If GITLAB_ALLOWED_PROJECT_IDS is set and the requested project is not in the whitelist
766
+ */
767
+ function getEffectiveProjectId(projectId) {
768
+ if (GITLAB_ALLOWED_PROJECT_IDS.length > 0) {
769
+ // If there's only one allowed project, use it as default
770
+ if (GITLAB_ALLOWED_PROJECT_IDS.length === 1 && !projectId) {
771
+ return GITLAB_ALLOWED_PROJECT_IDS[0];
772
+ }
773
+ // If a project ID is provided, check if it's in the whitelist
774
+ if (projectId && !GITLAB_ALLOWED_PROJECT_IDS.includes(projectId)) {
775
+ throw new Error(`Access denied: Project ${projectId} is not in the allowed project list: ${GITLAB_ALLOWED_PROJECT_IDS.join(', ')}`);
776
+ }
777
+ // If no project ID provided but we have multiple allowed projects, require an explicit choice
778
+ if (!projectId && GITLAB_ALLOWED_PROJECT_IDS.length > 1) {
779
+ throw new Error(`Multiple projects allowed (${GITLAB_ALLOWED_PROJECT_IDS.join(', ')}). Please specify a project ID.`);
780
+ }
781
+ return projectId || GITLAB_ALLOWED_PROJECT_IDS[0];
782
+ }
783
+ return GITLAB_PROJECT_ID || projectId;
784
+ }
785
+ /**
786
+ * Create a fork of a GitLab project
787
+ * 프로젝트 포크 생성 (Create a project fork)
788
+ *
789
+ * @param {string} projectId - The ID or URL-encoded path of the project
790
+ * @param {string} [namespace] - The namespace to fork the project to
791
+ * @returns {Promise<GitLabFork>} The created fork
792
+ */
793
+ async function forkProject(projectId, namespace) {
794
+ projectId = decodeURIComponent(projectId); // Decode project ID
795
+ const effectiveProjectId = getEffectiveProjectId(projectId);
796
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/fork`);
797
+ if (namespace) {
798
+ url.searchParams.append("namespace", namespace);
799
+ }
800
+ const response = await fetch(url.toString(), {
801
+ ...DEFAULT_FETCH_CONFIG,
802
+ method: "POST",
803
+ });
804
+ // 이미 존재하는 프로젝트인 경우 처리
805
+ if (response.status === 409) {
806
+ throw new Error("Project already exists in the target namespace");
807
+ }
808
+ await handleGitLabError(response);
809
+ const data = await response.json();
810
+ return GitLabForkSchema.parse(data);
811
+ }
812
+ /**
813
+ * Create a new branch in a GitLab project
814
+ * 새로운 브랜치 생성 (Create a new branch)
815
+ *
816
+ * @param {string} projectId - The ID or URL-encoded path of the project
817
+ * @param {z.infer<typeof CreateBranchOptionsSchema>} options - Branch creation options
818
+ * @returns {Promise<GitLabReference>} The created branch reference
819
+ */
820
+ async function createBranch(projectId, options) {
821
+ projectId = decodeURIComponent(projectId); // Decode project ID
822
+ const effectiveProjectId = getEffectiveProjectId(projectId);
823
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/repository/branches`);
824
+ const response = await fetch(url.toString(), {
825
+ ...DEFAULT_FETCH_CONFIG,
826
+ method: "POST",
827
+ body: JSON.stringify({
828
+ branch: options.name,
829
+ ref: options.ref,
830
+ }),
831
+ });
832
+ await handleGitLabError(response);
833
+ return GitLabReferenceSchema.parse(await response.json());
834
+ }
835
+ /**
836
+ * Get the default branch for a GitLab project
837
+ * 프로젝트의 기본 브랜치 조회 (Get the default branch of a project)
838
+ *
839
+ * @param {string} projectId - The ID or URL-encoded path of the project
840
+ * @returns {Promise<string>} The name of the default branch
841
+ */
842
+ async function getDefaultBranchRef(projectId) {
843
+ projectId = decodeURIComponent(projectId); // Decode project ID
844
+ const effectiveProjectId = getEffectiveProjectId(projectId);
845
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}`);
846
+ const response = await fetch(url.toString(), {
847
+ ...DEFAULT_FETCH_CONFIG,
848
+ });
849
+ await handleGitLabError(response);
850
+ const project = GitLabRepositorySchema.parse(await response.json());
851
+ return project.default_branch ?? "main";
852
+ }
853
+ /**
854
+ * Get the contents of a file from a GitLab project
855
+ * 파일 내용 조회 (Get file contents)
856
+ *
857
+ * @param {string} projectId - The ID or URL-encoded path of the project
858
+ * @param {string} filePath - The path of the file to get
859
+ * @param {string} [ref] - The name of the branch, tag or commit
860
+ * @returns {Promise<GitLabContent>} The file content
861
+ */
862
+ async function getFileContents(projectId, filePath, ref) {
863
+ projectId = decodeURIComponent(projectId); // Decode project ID
864
+ const effectiveProjectId = getEffectiveProjectId(projectId);
865
+ const encodedPath = encodeURIComponent(filePath);
866
+ // ref가 없는 경우 default branch를 가져옴
867
+ if (!ref) {
868
+ ref = await getDefaultBranchRef(projectId);
869
+ }
870
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/repository/files/${encodedPath}`);
871
+ url.searchParams.append("ref", ref);
872
+ const response = await fetch(url.toString(), {
873
+ ...DEFAULT_FETCH_CONFIG,
874
+ });
875
+ // 파일을 찾을 수 없는 경우 처리
876
+ if (response.status === 404) {
877
+ throw new Error(`File not found: ${filePath}`);
878
+ }
879
+ await handleGitLabError(response);
880
+ const data = await response.json();
881
+ const parsedData = GitLabContentSchema.parse(data);
882
+ // Base64로 인코딩된 파일 내용을 UTF-8로 디코딩
883
+ if (!Array.isArray(parsedData) && parsedData.content) {
884
+ parsedData.content = Buffer.from(parsedData.content, "base64").toString("utf8");
885
+ parsedData.encoding = "utf8";
886
+ }
887
+ return parsedData;
888
+ }
889
+ /**
890
+ * Create a new issue in a GitLab project
891
+ * 이슈 생성 (Create an issue)
892
+ *
893
+ * @param {string} projectId - The ID or URL-encoded path of the project
894
+ * @param {z.infer<typeof CreateIssueOptionsSchema>} options - Issue creation options
895
+ * @returns {Promise<GitLabIssue>} The created issue
896
+ */
897
+ async function createIssue(projectId, options) {
898
+ projectId = decodeURIComponent(projectId); // Decode project ID
899
+ const effectiveProjectId = getEffectiveProjectId(projectId);
900
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/issues`);
901
+ const response = await fetch(url.toString(), {
902
+ ...DEFAULT_FETCH_CONFIG,
903
+ method: "POST",
904
+ body: JSON.stringify({
905
+ title: options.title,
906
+ description: options.description,
907
+ assignee_ids: options.assignee_ids,
908
+ milestone_id: options.milestone_id,
909
+ labels: options.labels?.join(","),
910
+ }),
911
+ });
912
+ // 잘못된 요청 처리
913
+ if (response.status === 400) {
914
+ const errorBody = await response.text();
915
+ throw new Error(`Invalid request: ${errorBody}`);
916
+ }
917
+ await handleGitLabError(response);
918
+ const data = await response.json();
919
+ return GitLabIssueSchema.parse(data);
920
+ }
921
+ /**
922
+ * List issues across all accessible projects or within a specific project
923
+ * 프로젝트의 이슈 목록 조회
924
+ *
925
+ * @param {string} projectId - The ID or URL-encoded path of the project (optional)
926
+ * @param {Object} options - Options for listing issues
927
+ * @returns {Promise<GitLabIssue[]>} List of issues
928
+ */
929
+ async function listIssues(projectId, options = {}) {
930
+ let url;
931
+ if (projectId) {
932
+ projectId = decodeURIComponent(projectId); // Decode project ID
933
+ const effectiveProjectId = getEffectiveProjectId(projectId);
934
+ url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/issues`);
935
+ }
936
+ else {
937
+ url = new URL(`${GITLAB_API_URL}/issues`);
938
+ }
939
+ // Add all query parameters
940
+ Object.entries(options).forEach(([key, value]) => {
941
+ if (value !== undefined) {
942
+ const keys = ["labels", "assignee_username"];
943
+ if (keys.includes(key)) {
944
+ if (Array.isArray(value)) {
945
+ // Handle array of labels
946
+ value.forEach(label => {
947
+ url.searchParams.append(`${key}[]`, label.toString());
948
+ });
949
+ }
950
+ else if (value) {
951
+ url.searchParams.append(`${key}[]`, value.toString());
952
+ }
953
+ }
954
+ else {
955
+ url.searchParams.append(key, String(value));
956
+ }
957
+ }
958
+ });
959
+ const response = await fetch(url.toString(), {
960
+ ...DEFAULT_FETCH_CONFIG,
961
+ });
962
+ await handleGitLabError(response);
963
+ const data = await response.json();
964
+ return z.array(GitLabIssueSchema).parse(data);
965
+ }
966
+ /**
967
+ * List merge requests in a GitLab project with optional filtering
968
+ *
969
+ * @param {string} projectId - The ID or URL-encoded path of the project
970
+ * @param {Object} options - Optional filtering parameters
971
+ * @returns {Promise<GitLabMergeRequest[]>} List of merge requests
972
+ */
973
+ async function listMergeRequests(projectId, options = {}) {
974
+ projectId = decodeURIComponent(projectId); // Decode project ID
975
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests`);
976
+ // Add all query parameters
977
+ Object.entries(options).forEach(([key, value]) => {
978
+ if (value !== undefined) {
979
+ if (key === "labels" && Array.isArray(value)) {
980
+ // Handle array of labels
981
+ url.searchParams.append(key, value.join(","));
982
+ }
983
+ else {
984
+ url.searchParams.append(key, String(value));
985
+ }
986
+ }
987
+ });
988
+ const response = await fetch(url.toString(), {
989
+ ...DEFAULT_FETCH_CONFIG,
990
+ });
991
+ await handleGitLabError(response);
992
+ const data = await response.json();
993
+ return z.array(GitLabMergeRequestSchema).parse(data);
994
+ }
995
+ /**
996
+ * Get a single issue from a GitLab project
997
+ * 단일 이슈 조회
998
+ *
999
+ * @param {string} projectId - The ID or URL-encoded path of the project
1000
+ * @param {number} issueIid - The internal ID of the project issue
1001
+ * @returns {Promise<GitLabIssue>} The issue
1002
+ */
1003
+ async function getIssue(projectId, issueIid) {
1004
+ projectId = decodeURIComponent(projectId); // Decode project ID
1005
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/issues/${issueIid}`);
1006
+ const response = await fetch(url.toString(), {
1007
+ ...DEFAULT_FETCH_CONFIG,
1008
+ });
1009
+ await handleGitLabError(response);
1010
+ const data = await response.json();
1011
+ return GitLabIssueSchema.parse(data);
1012
+ }
1013
+ /**
1014
+ * Update an issue in a GitLab project
1015
+ * 이슈 업데이트
1016
+ *
1017
+ * @param {string} projectId - The ID or URL-encoded path of the project
1018
+ * @param {number} issueIid - The internal ID of the project issue
1019
+ * @param {Object} options - Update options for the issue
1020
+ * @returns {Promise<GitLabIssue>} The updated issue
1021
+ */
1022
+ async function updateIssue(projectId, issueIid, options) {
1023
+ projectId = decodeURIComponent(projectId); // Decode project ID
1024
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/issues/${issueIid}`);
1025
+ // Convert labels array to comma-separated string if present
1026
+ const body = { ...options };
1027
+ if (body.labels && Array.isArray(body.labels)) {
1028
+ body.labels = body.labels.join(",");
1029
+ }
1030
+ const response = await fetch(url.toString(), {
1031
+ ...DEFAULT_FETCH_CONFIG,
1032
+ method: "PUT",
1033
+ body: JSON.stringify(body),
1034
+ });
1035
+ await handleGitLabError(response);
1036
+ const data = await response.json();
1037
+ return GitLabIssueSchema.parse(data);
1038
+ }
1039
+ /**
1040
+ * Delete an issue from a GitLab project
1041
+ * 이슈 삭제
1042
+ *
1043
+ * @param {string} projectId - The ID or URL-encoded path of the project
1044
+ * @param {number} issueIid - The internal ID of the project issue
1045
+ * @returns {Promise<void>}
1046
+ */
1047
+ async function deleteIssue(projectId, issueIid) {
1048
+ projectId = decodeURIComponent(projectId); // Decode project ID
1049
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/issues/${issueIid}`);
1050
+ const response = await fetch(url.toString(), {
1051
+ ...DEFAULT_FETCH_CONFIG,
1052
+ method: "DELETE",
1053
+ });
1054
+ await handleGitLabError(response);
1055
+ }
1056
+ /**
1057
+ * List all issue links for a specific issue
1058
+ * 이슈 관계 목록 조회
1059
+ *
1060
+ * @param {string} projectId - The ID or URL-encoded path of the project
1061
+ * @param {number} issueIid - The internal ID of the project issue
1062
+ * @returns {Promise<GitLabIssueWithLinkDetails[]>} List of issues with link details
1063
+ */
1064
+ async function listIssueLinks(projectId, issueIid) {
1065
+ projectId = decodeURIComponent(projectId); // Decode project ID
1066
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/issues/${issueIid}/links`);
1067
+ const response = await fetch(url.toString(), {
1068
+ ...DEFAULT_FETCH_CONFIG,
1069
+ });
1070
+ await handleGitLabError(response);
1071
+ const data = await response.json();
1072
+ return z.array(GitLabIssueWithLinkDetailsSchema).parse(data);
1073
+ }
1074
+ /**
1075
+ * Get a specific issue link
1076
+ * 특정 이슈 관계 조회
1077
+ *
1078
+ * @param {string} projectId - The ID or URL-encoded path of the project
1079
+ * @param {number} issueIid - The internal ID of the project issue
1080
+ * @param {number} issueLinkId - The ID of the issue link
1081
+ * @returns {Promise<GitLabIssueLink>} The issue link
1082
+ */
1083
+ async function getIssueLink(projectId, issueIid, issueLinkId) {
1084
+ projectId = decodeURIComponent(projectId); // Decode project ID
1085
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/issues/${issueIid}/links/${issueLinkId}`);
1086
+ const response = await fetch(url.toString(), {
1087
+ ...DEFAULT_FETCH_CONFIG,
1088
+ });
1089
+ await handleGitLabError(response);
1090
+ const data = await response.json();
1091
+ return GitLabIssueLinkSchema.parse(data);
1092
+ }
1093
+ /**
1094
+ * Create an issue link between two issues
1095
+ * 이슈 관계 생성
1096
+ *
1097
+ * @param {string} projectId - The ID or URL-encoded path of the project
1098
+ * @param {number} issueIid - The internal ID of the project issue
1099
+ * @param {string} targetProjectId - The ID or URL-encoded path of the target project
1100
+ * @param {number} targetIssueIid - The internal ID of the target project issue
1101
+ * @param {string} linkType - The type of the relation (relates_to, blocks, is_blocked_by)
1102
+ * @returns {Promise<GitLabIssueLink>} The created issue link
1103
+ */
1104
+ async function createIssueLink(projectId, issueIid, targetProjectId, targetIssueIid, linkType = "relates_to") {
1105
+ projectId = decodeURIComponent(projectId); // Decode project ID
1106
+ targetProjectId = decodeURIComponent(targetProjectId); // Decode target project ID as well
1107
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/issues/${issueIid}/links`);
1108
+ const response = await fetch(url.toString(), {
1109
+ ...DEFAULT_FETCH_CONFIG,
1110
+ method: "POST",
1111
+ body: JSON.stringify({
1112
+ target_project_id: targetProjectId,
1113
+ target_issue_iid: targetIssueIid,
1114
+ link_type: linkType,
1115
+ }),
1116
+ });
1117
+ await handleGitLabError(response);
1118
+ const data = await response.json();
1119
+ return GitLabIssueLinkSchema.parse(data);
1120
+ }
1121
+ /**
1122
+ * Delete an issue link
1123
+ * 이슈 관계 삭제
1124
+ *
1125
+ * @param {string} projectId - The ID or URL-encoded path of the project
1126
+ * @param {number} issueIid - The internal ID of the project issue
1127
+ * @param {number} issueLinkId - The ID of the issue link
1128
+ * @returns {Promise<void>}
1129
+ */
1130
+ async function deleteIssueLink(projectId, issueIid, issueLinkId) {
1131
+ projectId = decodeURIComponent(projectId); // Decode project ID
1132
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/issues/${issueIid}/links/${issueLinkId}`);
1133
+ const response = await fetch(url.toString(), {
1134
+ ...DEFAULT_FETCH_CONFIG,
1135
+ method: "DELETE",
1136
+ });
1137
+ await handleGitLabError(response);
1138
+ }
1139
+ /**
1140
+ * Create a new merge request in a GitLab project
1141
+ * 병합 요청 생성
1142
+ *
1143
+ * @param {string} projectId - The ID or URL-encoded path of the project
1144
+ * @param {z.infer<typeof CreateMergeRequestOptionsSchema>} options - Merge request creation options
1145
+ * @returns {Promise<GitLabMergeRequest>} The created merge request
1146
+ */
1147
+ async function createMergeRequest(projectId, options) {
1148
+ projectId = decodeURIComponent(projectId); // Decode project ID
1149
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests`);
1150
+ const response = await fetch(url.toString(), {
1151
+ ...DEFAULT_FETCH_CONFIG,
1152
+ method: "POST",
1153
+ body: JSON.stringify({
1154
+ title: options.title,
1155
+ description: options.description,
1156
+ source_branch: options.source_branch,
1157
+ target_branch: options.target_branch,
1158
+ target_project_id: options.target_project_id,
1159
+ assignee_ids: options.assignee_ids,
1160
+ reviewer_ids: options.reviewer_ids,
1161
+ labels: options.labels?.join(","),
1162
+ allow_collaboration: options.allow_collaboration,
1163
+ draft: options.draft,
1164
+ remove_source_branch: options.remove_source_branch,
1165
+ squash: options.squash,
1166
+ }),
1167
+ });
1168
+ if (response.status === 400) {
1169
+ const errorBody = await response.text();
1170
+ throw new Error(`Invalid request: ${errorBody}`);
1171
+ }
1172
+ if (!response.ok) {
1173
+ const errorBody = await response.text();
1174
+ throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
1175
+ }
1176
+ const data = await response.json();
1177
+ return GitLabMergeRequestSchema.parse(data);
1178
+ }
1179
+ /**
1180
+ * Shared helper function for listing discussions
1181
+ * 토론 목록 조회를 위한 공유 헬퍼 함수
1182
+ *
1183
+ * @param {string} projectId - The ID or URL-encoded path of the project
1184
+ * @param {"issues" | "merge_requests"} resourceType - The type of resource (issues or merge_requests)
1185
+ * @param {number} resourceIid - The IID of the issue or merge request
1186
+ * @param {PaginationOptions} options - Pagination and sorting options
1187
+ * @returns {Promise<PaginatedDiscussionsResponse>} Paginated list of discussions
1188
+ */
1189
+ async function listDiscussions(projectId, resourceType, resourceIid, options = {}) {
1190
+ projectId = decodeURIComponent(projectId); // Decode project ID
1191
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/${resourceType}/${resourceIid}/discussions`);
1192
+ // Add query parameters for pagination and sorting
1193
+ if (options.page) {
1194
+ url.searchParams.append("page", options.page.toString());
1195
+ }
1196
+ if (options.per_page) {
1197
+ url.searchParams.append("per_page", options.per_page.toString());
1198
+ }
1199
+ const response = await fetch(url.toString(), {
1200
+ ...DEFAULT_FETCH_CONFIG,
1201
+ });
1202
+ await handleGitLabError(response);
1203
+ const discussions = await response.json();
1204
+ // Extract pagination headers
1205
+ const pagination = {
1206
+ x_next_page: response.headers.get("x-next-page")
1207
+ ? parseInt(response.headers.get("x-next-page"))
1208
+ : null,
1209
+ x_page: response.headers.get("x-page") ? parseInt(response.headers.get("x-page")) : undefined,
1210
+ x_per_page: response.headers.get("x-per-page")
1211
+ ? parseInt(response.headers.get("x-per-page"))
1212
+ : undefined,
1213
+ x_prev_page: response.headers.get("x-prev-page")
1214
+ ? parseInt(response.headers.get("x-prev-page"))
1215
+ : null,
1216
+ x_total: response.headers.get("x-total") ? parseInt(response.headers.get("x-total")) : null,
1217
+ x_total_pages: response.headers.get("x-total-pages")
1218
+ ? parseInt(response.headers.get("x-total-pages"))
1219
+ : null,
1220
+ };
1221
+ return PaginatedDiscussionsResponseSchema.parse({
1222
+ items: discussions,
1223
+ pagination: pagination,
1224
+ });
1225
+ }
1226
+ /**
1227
+ * List merge request discussion items
1228
+ * 병합 요청 토론 목록 조회
1229
+ *
1230
+ * @param {string} projectId - The ID or URL-encoded path of the project
1231
+ * @param {number} mergeRequestIid - The IID of a merge request
1232
+ * @param {DiscussionPaginationOptions} options - Pagination and sorting options
1233
+ * @returns {Promise<GitLabDiscussion[]>} List of discussions
1234
+ */
1235
+ async function listMergeRequestDiscussions(projectId, mergeRequestIid, options = {}) {
1236
+ return listDiscussions(projectId, "merge_requests", mergeRequestIid, options);
1237
+ }
1238
+ /**
1239
+ * List discussions for an issue
1240
+ *
1241
+ * @param {string} projectId - The ID or URL-encoded path of the project
1242
+ * @param {number} issueIid - The internal ID of the project issue
1243
+ * @param {DiscussionPaginationOptions} options - Pagination and sorting options
1244
+ * @returns {Promise<GitLabDiscussion[]>} List of issue discussions
1245
+ */
1246
+ async function listIssueDiscussions(projectId, issueIid, options = {}) {
1247
+ return listDiscussions(projectId, "issues", issueIid, options);
1248
+ }
1249
+ /**
1250
+ * Modify an existing merge request thread note
1251
+ * 병합 요청 토론 노트 수정
1252
+ *
1253
+ * @param {string} projectId - The ID or URL-encoded path of the project
1254
+ * @param {number} mergeRequestIid - The IID of a merge request
1255
+ * @param {string} discussionId - The ID of a thread
1256
+ * @param {number} noteId - The ID of a thread note
1257
+ * @param {string} body - The new content of the note
1258
+ * @param {boolean} [resolved] - Resolve/unresolve state
1259
+ * @returns {Promise<GitLabDiscussionNote>} The updated note
1260
+ */
1261
+ async function updateMergeRequestNote(projectId, mergeRequestIid, discussionId, noteId, body, resolved) {
1262
+ projectId = decodeURIComponent(projectId); // Decode project ID
1263
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/discussions/${discussionId}/notes/${noteId}`);
1264
+ // Only one of body or resolved can be sent according to GitLab API
1265
+ const payload = {};
1266
+ if (body !== undefined) {
1267
+ payload.body = body;
1268
+ }
1269
+ else if (resolved !== undefined) {
1270
+ payload.resolved = resolved;
1271
+ }
1272
+ const response = await fetch(url.toString(), {
1273
+ ...DEFAULT_FETCH_CONFIG,
1274
+ method: "PUT",
1275
+ body: JSON.stringify(payload),
1276
+ });
1277
+ await handleGitLabError(response);
1278
+ const data = await response.json();
1279
+ return GitLabDiscussionNoteSchema.parse(data);
1280
+ }
1281
+ /**
1282
+ * Update an issue discussion note
1283
+ * @param {string} projectId - The ID or URL-encoded path of the project
1284
+ * @param {number} issueIid - The IID of an issue
1285
+ * @param {string} discussionId - The ID of a thread
1286
+ * @param {number} noteId - The ID of a thread note
1287
+ * @param {string} body - The new content of the note
1288
+ * @returns {Promise<GitLabDiscussionNote>} The updated note
1289
+ */
1290
+ async function updateIssueNote(projectId, issueIid, discussionId, noteId, body) {
1291
+ projectId = decodeURIComponent(projectId); // Decode project ID
1292
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/issues/${issueIid}/discussions/${discussionId}/notes/${noteId}`);
1293
+ const payload = { body };
1294
+ const response = await fetch(url.toString(), {
1295
+ ...DEFAULT_FETCH_CONFIG,
1296
+ method: "PUT",
1297
+ body: JSON.stringify(payload),
1298
+ });
1299
+ await handleGitLabError(response);
1300
+ const data = await response.json();
1301
+ return GitLabDiscussionNoteSchema.parse(data);
1302
+ }
1303
+ /**
1304
+ * Create a note in an issue discussion
1305
+ * @param {string} projectId - The ID or URL-encoded path of the project
1306
+ * @param {number} issueIid - The IID of an issue
1307
+ * @param {string} discussionId - The ID of a thread
1308
+ * @param {string} body - The content of the new note
1309
+ * @param {string} [createdAt] - The creation date of the note (ISO 8601 format)
1310
+ * @returns {Promise<GitLabDiscussionNote>} The created note
1311
+ */
1312
+ async function createIssueNote(projectId, issueIid, discussionId, body, createdAt) {
1313
+ projectId = decodeURIComponent(projectId); // Decode project ID
1314
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/issues/${issueIid}/discussions/${discussionId}/notes`);
1315
+ const payload = { body };
1316
+ if (createdAt) {
1317
+ payload.created_at = createdAt;
1318
+ }
1319
+ const response = await fetch(url.toString(), {
1320
+ ...DEFAULT_FETCH_CONFIG,
1321
+ method: "POST",
1322
+ body: JSON.stringify(payload),
1323
+ });
1324
+ await handleGitLabError(response);
1325
+ const data = await response.json();
1326
+ return GitLabDiscussionNoteSchema.parse(data);
1327
+ }
1328
+ /**
1329
+ * Add a new note to an existing merge request thread
1330
+ * 기존 병합 요청 스레드에 새 노트 추가
1331
+ *
1332
+ * @param {string} projectId - The ID or URL-encoded path of the project
1333
+ * @param {number} mergeRequestIid - The IID of a merge request
1334
+ * @param {string} discussionId - The ID of a thread
1335
+ * @param {string} body - The content of the new note
1336
+ * @param {string} [createdAt] - The creation date of the note (ISO 8601 format)
1337
+ * @returns {Promise<GitLabDiscussionNote>} The created note
1338
+ */
1339
+ async function createMergeRequestNote(projectId, mergeRequestIid, discussionId, body, createdAt) {
1340
+ projectId = decodeURIComponent(projectId); // Decode project ID
1341
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/discussions/${discussionId}/notes`);
1342
+ const payload = { body };
1343
+ if (createdAt) {
1344
+ payload.created_at = createdAt;
1345
+ }
1346
+ const response = await fetch(url.toString(), {
1347
+ ...DEFAULT_FETCH_CONFIG,
1348
+ method: "POST",
1349
+ body: JSON.stringify(payload),
1350
+ });
1351
+ await handleGitLabError(response);
1352
+ const data = await response.json();
1353
+ return GitLabDiscussionNoteSchema.parse(data);
1354
+ }
1355
+ /**
1356
+ * Create or update a file in a GitLab project
1357
+ * 파일 생성 또는 업데이트
1358
+ *
1359
+ * @param {string} projectId - The ID or URL-encoded path of the project
1360
+ * @param {string} filePath - The path of the file to create or update
1361
+ * @param {string} content - The content of the file
1362
+ * @param {string} commitMessage - The commit message
1363
+ * @param {string} branch - The branch name
1364
+ * @param {string} [previousPath] - The previous path of the file in case of rename
1365
+ * @returns {Promise<GitLabCreateUpdateFileResponse>} The file update response
1366
+ */
1367
+ async function createOrUpdateFile(projectId, filePath, content, commitMessage, branch, previousPath, last_commit_id, commit_id) {
1368
+ projectId = decodeURIComponent(projectId); // Decode project ID
1369
+ const encodedPath = encodeURIComponent(filePath);
1370
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/repository/files/${encodedPath}`);
1371
+ const body = {
1372
+ branch,
1373
+ content,
1374
+ commit_message: commitMessage,
1375
+ encoding: "text",
1376
+ ...(previousPath ? { previous_path: previousPath } : {}),
1377
+ };
1378
+ // Check if file exists
1379
+ let method = "POST";
1380
+ try {
1381
+ // Get file contents to check existence and retrieve commit IDs
1382
+ const fileData = await getFileContents(projectId, filePath, branch);
1383
+ method = "PUT";
1384
+ // If fileData is not an array, it's a file content object with commit IDs
1385
+ if (!Array.isArray(fileData)) {
1386
+ // Use commit IDs from the file data if not provided in parameters
1387
+ if (!commit_id && fileData.commit_id) {
1388
+ body.commit_id = fileData.commit_id;
1389
+ }
1390
+ else if (commit_id) {
1391
+ body.commit_id = commit_id;
1392
+ }
1393
+ if (!last_commit_id && fileData.last_commit_id) {
1394
+ body.last_commit_id = fileData.last_commit_id;
1395
+ }
1396
+ else if (last_commit_id) {
1397
+ body.last_commit_id = last_commit_id;
1398
+ }
1399
+ }
1400
+ }
1401
+ catch (error) {
1402
+ if (!(error instanceof Error && error.message.includes("File not found"))) {
1403
+ throw error;
1404
+ }
1405
+ // File doesn't exist, use POST - no need for commit IDs for new files
1406
+ // But still use any provided as parameters if they exist
1407
+ if (commit_id) {
1408
+ body.commit_id = commit_id;
1409
+ }
1410
+ if (last_commit_id) {
1411
+ body.last_commit_id = last_commit_id;
1412
+ }
1413
+ }
1414
+ const response = await fetch(url.toString(), {
1415
+ ...DEFAULT_FETCH_CONFIG,
1416
+ method,
1417
+ body: JSON.stringify(body),
1418
+ });
1419
+ if (!response.ok) {
1420
+ const errorBody = await response.text();
1421
+ throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
1422
+ }
1423
+ const data = await response.json();
1424
+ return GitLabCreateUpdateFileResponseSchema.parse(data);
1425
+ }
1426
+ /**
1427
+ * Create a tree structure in a GitLab project repository
1428
+ * 저장소에 트리 구조 생성
1429
+ *
1430
+ * @param {string} projectId - The ID or URL-encoded path of the project
1431
+ * @param {FileOperation[]} files - Array of file operations
1432
+ * @param {string} [ref] - The name of the branch, tag or commit
1433
+ * @returns {Promise<GitLabTree>} The created tree
1434
+ */
1435
+ async function createTree(projectId, files, ref) {
1436
+ projectId = decodeURIComponent(projectId); // Decode project ID
1437
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/repository/tree`);
1438
+ if (ref) {
1439
+ url.searchParams.append("ref", ref);
1440
+ }
1441
+ const response = await fetch(url.toString(), {
1442
+ ...DEFAULT_FETCH_CONFIG,
1443
+ method: "POST",
1444
+ body: JSON.stringify({
1445
+ files: files.map(file => ({
1446
+ file_path: file.path,
1447
+ content: file.content,
1448
+ encoding: "text",
1449
+ })),
1450
+ }),
1451
+ });
1452
+ if (response.status === 400) {
1453
+ const errorBody = await response.text();
1454
+ throw new Error(`Invalid request: ${errorBody}`);
1455
+ }
1456
+ if (!response.ok) {
1457
+ const errorBody = await response.text();
1458
+ throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
1459
+ }
1460
+ const data = await response.json();
1461
+ return GitLabTreeSchema.parse(data);
1462
+ }
1463
+ /**
1464
+ * Create a commit in a GitLab project repository
1465
+ * 저장소에 커밋 생성
1466
+ *
1467
+ * @param {string} projectId - The ID or URL-encoded path of the project
1468
+ * @param {string} message - The commit message
1469
+ * @param {string} branch - The branch name
1470
+ * @param {FileOperation[]} actions - Array of file operations for the commit
1471
+ * @returns {Promise<GitLabCommit>} The created commit
1472
+ */
1473
+ async function createCommit(projectId, message, branch, actions) {
1474
+ projectId = decodeURIComponent(projectId); // Decode project ID
1475
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/repository/commits`);
1476
+ const response = await fetch(url.toString(), {
1477
+ ...DEFAULT_FETCH_CONFIG,
1478
+ method: "POST",
1479
+ body: JSON.stringify({
1480
+ branch,
1481
+ commit_message: message,
1482
+ actions: actions.map(action => ({
1483
+ action: "create",
1484
+ file_path: action.path,
1485
+ content: action.content,
1486
+ encoding: "text",
1487
+ })),
1488
+ }),
1489
+ });
1490
+ if (response.status === 400) {
1491
+ const errorBody = await response.text();
1492
+ throw new Error(`Invalid request: ${errorBody}`);
1493
+ }
1494
+ if (!response.ok) {
1495
+ const errorBody = await response.text();
1496
+ throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
1497
+ }
1498
+ const data = await response.json();
1499
+ return GitLabCommitSchema.parse(data);
1500
+ }
1501
+ /**
1502
+ * Search for GitLab projects
1503
+ * 프로젝트 검색
1504
+ *
1505
+ * @param {string} query - The search query
1506
+ * @param {number} [page=1] - The page number
1507
+ * @param {number} [perPage=20] - Number of items per page
1508
+ * @returns {Promise<GitLabSearchResponse>} The search results
1509
+ */
1510
+ async function searchProjects(query, page = 1, perPage = 20) {
1511
+ const url = new URL(`${GITLAB_API_URL}/projects`);
1512
+ url.searchParams.append("search", query);
1513
+ url.searchParams.append("page", page.toString());
1514
+ url.searchParams.append("per_page", perPage.toString());
1515
+ url.searchParams.append("order_by", "id");
1516
+ url.searchParams.append("sort", "desc");
1517
+ const response = await fetch(url.toString(), {
1518
+ ...DEFAULT_FETCH_CONFIG,
1519
+ });
1520
+ if (!response.ok) {
1521
+ const errorBody = await response.text();
1522
+ throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
1523
+ }
1524
+ const projects = (await response.json());
1525
+ const totalCount = response.headers.get("x-total");
1526
+ const totalPages = response.headers.get("x-total-pages");
1527
+ // GitLab API doesn't return these headers for results > 10,000
1528
+ const count = totalCount ? parseInt(totalCount) : projects.length;
1529
+ return GitLabSearchResponseSchema.parse({
1530
+ count,
1531
+ total_pages: totalPages ? parseInt(totalPages) : Math.ceil(count / perPage),
1532
+ current_page: page,
1533
+ items: projects,
1534
+ });
1535
+ }
1536
+ /**
1537
+ * Create a new GitLab repository
1538
+ * 새 저장소 생성
1539
+ *
1540
+ * @param {z.infer<typeof CreateRepositoryOptionsSchema>} options - Repository creation options
1541
+ * @returns {Promise<GitLabRepository>} The created repository
1542
+ */
1543
+ async function createRepository(options) {
1544
+ const response = await fetch(`${GITLAB_API_URL}/projects`, {
1545
+ ...DEFAULT_FETCH_CONFIG,
1546
+ method: "POST",
1547
+ body: JSON.stringify({
1548
+ name: options.name,
1549
+ description: options.description,
1550
+ visibility: options.visibility,
1551
+ initialize_with_readme: options.initialize_with_readme,
1552
+ default_branch: "main",
1553
+ path: options.name.toLowerCase().replace(/\s+/g, "-"),
1554
+ }),
1555
+ });
1556
+ if (!response.ok) {
1557
+ const errorBody = await response.text();
1558
+ throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
1559
+ }
1560
+ const data = await response.json();
1561
+ return GitLabRepositorySchema.parse(data);
1562
+ }
1563
+ /**
1564
+ * Get merge request details
1565
+ * MR 조회 함수 (Function to retrieve merge request)
1566
+ *
1567
+ * @param {string} projectId - The ID or URL-encoded path of the project
1568
+ * @param {number} mergeRequestIid - The internal ID of the merge request (Optional)
1569
+ * @param {string} [branchName] - The name of the branch to search for merge request by branch name (Optional)
1570
+ * @returns {Promise<GitLabMergeRequest>} The merge request details
1571
+ */
1572
+ async function getMergeRequest(projectId, mergeRequestIid, branchName) {
1573
+ projectId = decodeURIComponent(projectId); // Decode project ID
1574
+ let url;
1575
+ if (mergeRequestIid) {
1576
+ url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}`);
1577
+ }
1578
+ else if (branchName) {
1579
+ url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests?source_branch=${encodeURIComponent(branchName)}`);
1580
+ }
1581
+ else {
1582
+ throw new Error("Either mergeRequestIid or branchName must be provided");
1583
+ }
1584
+ const response = await fetch(url.toString(), {
1585
+ ...DEFAULT_FETCH_CONFIG,
1586
+ });
1587
+ await handleGitLabError(response);
1588
+ const data = await response.json();
1589
+ // If response is an array (Comes from branchName search), return the first item if exist
1590
+ if (Array.isArray(data) && data.length > 0) {
1591
+ return GitLabMergeRequestSchema.parse(data[0]);
1592
+ }
1593
+ return GitLabMergeRequestSchema.parse(data);
1594
+ }
1595
+ /**
1596
+ * Get merge request changes/diffs
1597
+ * MR 변경사항 조회 함수 (Function to retrieve merge request changes)
1598
+ *
1599
+ * @param {string} projectId - The ID or URL-encoded path of the project
1600
+ * @param {number} mergeRequestIid - The internal ID of the merge request (Either mergeRequestIid or branchName must be provided)
1601
+ * @param {string} [branchName] - The name of the branch to search for merge request by branch name (Either mergeRequestIid or branchName must be provided)
1602
+ * @param {string} [view] - The view type for the diff (inline or parallel)
1603
+ * @returns {Promise<GitLabMergeRequestDiff[]>} The merge request diffs
1604
+ */
1605
+ async function getMergeRequestDiffs(projectId, mergeRequestIid, branchName, view) {
1606
+ projectId = decodeURIComponent(projectId); // Decode project ID
1607
+ if (!mergeRequestIid && !branchName) {
1608
+ throw new Error("Either mergeRequestIid or branchName must be provided");
1609
+ }
1610
+ if (branchName && !mergeRequestIid) {
1611
+ const mergeRequest = await getMergeRequest(projectId, undefined, branchName);
1612
+ mergeRequestIid = mergeRequest.iid;
1613
+ }
1614
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/changes`);
1615
+ if (view) {
1616
+ url.searchParams.append("view", view);
1617
+ }
1618
+ const response = await fetch(url.toString(), {
1619
+ ...DEFAULT_FETCH_CONFIG,
1620
+ });
1621
+ await handleGitLabError(response);
1622
+ const data = (await response.json());
1623
+ return z.array(GitLabDiffSchema).parse(data.changes);
1624
+ }
1625
+ /**
1626
+ * Get merge request changes with detailed information including commits, diff_refs, and more
1627
+ * 마지막으로 추가된 상세한 MR 변경사항 조회 함수 (Detailed merge request changes retrieval function)
1628
+ *
1629
+ * @param {string} projectId - The ID or URL-encoded path of the project
1630
+ * @param {number} mergeRequestIid - The internal ID of the merge request (Either mergeRequestIid or branchName must be provided)
1631
+ * @param {string} [branchName] - The name of the branch to search for merge request by branch name (Either mergeRequestIid or branchName must be provided)
1632
+ * @param {boolean} [unidiff] - Return diff in unidiff format
1633
+ * @returns {Promise<any>} The complete merge request changes response
1634
+ */
1635
+ async function listMergeRequestDiffs(projectId, mergeRequestIid, branchName, page, perPage, unidiff) {
1636
+ projectId = decodeURIComponent(projectId); // Decode project ID
1637
+ if (!mergeRequestIid && !branchName) {
1638
+ throw new Error("Either mergeRequestIid or branchName must be provided");
1639
+ }
1640
+ if (branchName && !mergeRequestIid) {
1641
+ const mergeRequest = await getMergeRequest(projectId, undefined, branchName);
1642
+ mergeRequestIid = mergeRequest.iid;
1643
+ }
1644
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/diffs`);
1645
+ if (page) {
1646
+ url.searchParams.append("page", page.toString());
1647
+ }
1648
+ if (perPage) {
1649
+ url.searchParams.append("per_page", perPage.toString());
1650
+ }
1651
+ if (unidiff) {
1652
+ url.searchParams.append("unidiff", "true");
1653
+ }
1654
+ const response = await fetch(url.toString(), {
1655
+ ...DEFAULT_FETCH_CONFIG,
1656
+ });
1657
+ await handleGitLabError(response);
1658
+ return await response.json(); // Return full response including commits, diff_refs, changes, etc.
1659
+ }
1660
+ /**
1661
+ * Get branch comparison diffs
1662
+ *
1663
+ * @param {string} projectId - The ID or URL-encoded path of the project
1664
+ * @param {string} from - The branch name or commit SHA to compare from
1665
+ * @param {string} to - The branch name or commit SHA to compare to
1666
+ * @param {boolean} [straight] - Comparison method: false for '...' (default), true for '--'
1667
+ * @returns {Promise<GitLabCompareResult>} Branch comparison results
1668
+ */
1669
+ async function getBranchDiffs(projectId, from, to, straight) {
1670
+ projectId = decodeURIComponent(projectId); // Decode project ID
1671
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/repository/compare`);
1672
+ url.searchParams.append("from", from);
1673
+ url.searchParams.append("to", to);
1674
+ if (straight !== undefined) {
1675
+ url.searchParams.append("straight", straight.toString());
1676
+ }
1677
+ const response = await fetch(url.toString(), {
1678
+ ...DEFAULT_FETCH_CONFIG,
1679
+ });
1680
+ if (!response.ok) {
1681
+ const errorBody = await response.text();
1682
+ throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
1683
+ }
1684
+ const data = await response.json();
1685
+ return GitLabCompareResultSchema.parse(data);
1686
+ }
1687
+ /**
1688
+ * Update a merge request
1689
+ * MR 업데이트 함수 (Function to update merge request)
1690
+ *
1691
+ * @param {string} projectId - The ID or URL-encoded path of the project
1692
+ * @param {number} mergeRequestIid - The internal ID of the merge request (Optional)
1693
+ * @param {string} branchName - The name of the branch to search for merge request by branch name (Optional)
1694
+ * @param {Object} options - The update options
1695
+ * @returns {Promise<GitLabMergeRequest>} The updated merge request
1696
+ */
1697
+ async function updateMergeRequest(projectId, options, mergeRequestIid, branchName) {
1698
+ projectId = decodeURIComponent(projectId); // Decode project ID
1699
+ if (!mergeRequestIid && !branchName) {
1700
+ throw new Error("Either mergeRequestIid or branchName must be provided");
1701
+ }
1702
+ if (branchName && !mergeRequestIid) {
1703
+ const mergeRequest = await getMergeRequest(projectId, undefined, branchName);
1704
+ mergeRequestIid = mergeRequest.iid;
1705
+ }
1706
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}`);
1707
+ const response = await fetch(url.toString(), {
1708
+ ...DEFAULT_FETCH_CONFIG,
1709
+ method: "PUT",
1710
+ body: JSON.stringify(options),
1711
+ });
1712
+ await handleGitLabError(response);
1713
+ return GitLabMergeRequestSchema.parse(await response.json());
1714
+ }
1715
+ /**
1716
+ * Merge a merge request
1717
+ * マージリクエストをマージする
1718
+ *
1719
+ * @param {string} projectId - The ID or URL-encoded path of the project
1720
+ * @param {number} mergeRequestIid - The internal ID of the merge request
1721
+ * @param {Object} options - Options for merging the merge request
1722
+ * @returns {Promise<GitLabMergeRequest>} The merged merge request
1723
+ */
1724
+ async function mergeMergeRequest(projectId, options, mergeRequestIid) {
1725
+ projectId = decodeURIComponent(projectId); // Decode project ID
1726
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/merge`);
1727
+ const response = await fetch(url.toString(), {
1728
+ ...DEFAULT_FETCH_CONFIG,
1729
+ method: "PUT",
1730
+ body: JSON.stringify(options),
1731
+ });
1732
+ await handleGitLabError(response);
1733
+ return GitLabMergeRequestSchema.parse(await response.json());
1734
+ }
1735
+ /**
1736
+ * Create a new note (comment) on an issue or merge request
1737
+ * 📦 새로운 함수: createNote - 이슈 또는 병합 요청에 노트(댓글)를 추가하는 함수
1738
+ * (New function: createNote - Function to add a note (comment) to an issue or merge request)
1739
+ *
1740
+ * @param {string} projectId - The ID or URL-encoded path of the project
1741
+ * @param {"issue" | "merge_request"} noteableType - The type of the item to add a note to (issue or merge_request)
1742
+ * @param {number} noteableIid - The internal ID of the issue or merge request
1743
+ * @param {string} body - The content of the note
1744
+ * @returns {Promise<any>} The created note
1745
+ */
1746
+ async function createNote(projectId, noteableType, // 'issue' 또는 'merge_request' 타입 명시
1747
+ noteableIid, body) {
1748
+ projectId = decodeURIComponent(projectId); // Decode project ID
1749
+ // ⚙️ 응답 타입은 GitLab API 문서에 따라 조정 가능
1750
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/${noteableType}s/${noteableIid}/notes` // Using plural form (issues/merge_requests) as per GitLab API documentation
1751
+ );
1752
+ const response = await fetch(url.toString(), {
1753
+ ...DEFAULT_FETCH_CONFIG,
1754
+ method: "POST",
1755
+ body: JSON.stringify({ body }),
1756
+ });
1757
+ if (!response.ok) {
1758
+ const errorText = await response.text();
1759
+ throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
1760
+ }
1761
+ return await response.json();
1762
+ }
1763
+ /**
1764
+ * List draft notes for a merge request
1765
+ * @param {string} projectId - The ID or URL-encoded path of the project
1766
+ * @param {number|string} mergeRequestIid - The internal ID of the merge request
1767
+ * @returns {Promise<GitLabDraftNote[]>} Array of draft notes
1768
+ */
1769
+ async function getDraftNote(project_id, merge_request_iid, draft_note_id) {
1770
+ const response = await fetch(`/projects/${encodeURIComponent(project_id)}/merge_requests/${merge_request_iid}/draft_notes/${draft_note_id}`);
1771
+ if (!response.ok) {
1772
+ const errorText = await response.text();
1773
+ throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
1774
+ }
1775
+ const data = await response.json();
1776
+ return GitLabDraftNoteSchema.parse(data);
1777
+ }
1778
+ async function listDraftNotes(projectId, mergeRequestIid) {
1779
+ projectId = decodeURIComponent(projectId);
1780
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/draft_notes`);
1781
+ const response = await fetch(url.toString(), {
1782
+ ...DEFAULT_FETCH_CONFIG,
1783
+ method: "GET",
1784
+ });
1785
+ if (!response.ok) {
1786
+ const errorText = await response.text();
1787
+ throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
1788
+ }
1789
+ const data = await response.json();
1790
+ return z.array(GitLabDraftNoteSchema).parse(data);
1791
+ }
1792
+ /**
1793
+ * Create a draft note for a merge request
1794
+ * @param {string} projectId - The ID or URL-encoded path of the project
1795
+ * @param {number|string} mergeRequestIid - The internal ID of the merge request
1796
+ * @param {string} body - The content of the draft note
1797
+ * @param {MergeRequestThreadPosition} [position] - Position information for diff notes
1798
+ * @param {boolean} [resolveDiscussion] - Whether to resolve the discussion when publishing
1799
+ * @returns {Promise<GitLabDraftNote>} The created draft note
1800
+ */
1801
+ async function createDraftNote(projectId, mergeRequestIid, body, position, resolveDiscussion) {
1802
+ projectId = decodeURIComponent(projectId);
1803
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/draft_notes`);
1804
+ const requestBody = { note: body };
1805
+ if (position) {
1806
+ requestBody.position = position;
1807
+ }
1808
+ if (resolveDiscussion !== undefined) {
1809
+ requestBody.resolve_discussion = resolveDiscussion;
1810
+ }
1811
+ const response = await fetch(url.toString(), {
1812
+ ...DEFAULT_FETCH_CONFIG,
1813
+ method: "POST",
1814
+ body: JSON.stringify(requestBody),
1815
+ });
1816
+ if (!response.ok) {
1817
+ const errorText = await response.text();
1818
+ throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
1819
+ }
1820
+ const data = await response.json();
1821
+ return GitLabDraftNoteSchema.parse(data);
1822
+ }
1823
+ /**
1824
+ * Update an existing draft note
1825
+ * @param {string} projectId - The ID or URL-encoded path of the project
1826
+ * @param {number|string} mergeRequestIid - The internal ID of the merge request
1827
+ * @param {number|string} draftNoteId - The ID of the draft note
1828
+ * @param {string} [body] - The updated content of the draft note
1829
+ * @param {MergeRequestThreadPosition} [position] - Updated position information
1830
+ * @param {boolean} [resolveDiscussion] - Whether to resolve the discussion when publishing
1831
+ * @returns {Promise<GitLabDraftNote>} The updated draft note
1832
+ */
1833
+ async function updateDraftNote(projectId, mergeRequestIid, draftNoteId, body, position, resolveDiscussion) {
1834
+ projectId = decodeURIComponent(projectId);
1835
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/draft_notes/${draftNoteId}`);
1836
+ const requestBody = {};
1837
+ if (body !== undefined) {
1838
+ requestBody.note = body;
1839
+ }
1840
+ if (position) {
1841
+ requestBody.position = position;
1842
+ }
1843
+ if (resolveDiscussion !== undefined) {
1844
+ requestBody.resolve_discussion = resolveDiscussion;
1845
+ }
1846
+ const response = await fetch(url.toString(), {
1847
+ ...DEFAULT_FETCH_CONFIG,
1848
+ method: "PUT",
1849
+ body: JSON.stringify(requestBody),
1850
+ });
1851
+ if (!response.ok) {
1852
+ const errorText = await response.text();
1853
+ throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
1854
+ }
1855
+ const data = await response.json();
1856
+ return GitLabDraftNoteSchema.parse(data);
1857
+ }
1858
+ /**
1859
+ * Delete a draft note
1860
+ * @param {string} projectId - The ID or URL-encoded path of the project
1861
+ * @param {number|string} mergeRequestIid - The internal ID of the merge request
1862
+ * @param {number|string} draftNoteId - The ID of the draft note
1863
+ * @returns {Promise<void>}
1864
+ */
1865
+ async function deleteDraftNote(projectId, mergeRequestIid, draftNoteId) {
1866
+ projectId = decodeURIComponent(projectId);
1867
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/draft_notes/${draftNoteId}`);
1868
+ const response = await fetch(url.toString(), {
1869
+ ...DEFAULT_FETCH_CONFIG,
1870
+ method: "DELETE",
1871
+ });
1872
+ if (!response.ok) {
1873
+ const errorText = await response.text();
1874
+ throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
1875
+ }
1876
+ }
1877
+ /**
1878
+ * Publish a single draft note
1879
+ * @param {string} projectId - The ID or URL-encoded path of the project
1880
+ * @param {number|string} mergeRequestIid - The internal ID of the merge request
1881
+ * @param {number|string} draftNoteId - The ID of the draft note
1882
+ * @returns {Promise<GitLabDiscussionNote>} The published note
1883
+ */
1884
+ async function publishDraftNote(projectId, mergeRequestIid, draftNoteId) {
1885
+ projectId = decodeURIComponent(projectId);
1886
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/draft_notes/${draftNoteId}/publish`);
1887
+ const response = await fetch(url.toString(), {
1888
+ ...DEFAULT_FETCH_CONFIG,
1889
+ method: "PUT",
1890
+ });
1891
+ if (!response.ok) {
1892
+ const errorText = await response.text();
1893
+ throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
1894
+ }
1895
+ // Handle empty response (204 No Content) or successful response
1896
+ const responseText = await response.text();
1897
+ if (!responseText || responseText.trim() === '') {
1898
+ // Return a success indicator for empty responses
1899
+ return {
1900
+ id: draftNoteId.toString(),
1901
+ body: "Draft note published successfully",
1902
+ author: { id: "unknown", username: "unknown" },
1903
+ created_at: new Date().toISOString(),
1904
+ updated_at: new Date().toISOString(),
1905
+ system: false,
1906
+ noteable_id: mergeRequestIid.toString(),
1907
+ noteable_type: "MergeRequest"
1908
+ };
1909
+ }
1910
+ try {
1911
+ const data = JSON.parse(responseText);
1912
+ return GitLabDiscussionNoteSchema.parse(data);
1913
+ }
1914
+ catch (parseError) {
1915
+ // If JSON parsing fails but the operation was successful (2xx status),
1916
+ // return a success indicator
1917
+ console.warn(`JSON parse error for successful publish operation: ${parseError}`);
1918
+ return {
1919
+ id: draftNoteId.toString(),
1920
+ body: "Draft note published successfully (response parse error)",
1921
+ author: { id: "unknown", username: "unknown" },
1922
+ created_at: new Date().toISOString(),
1923
+ updated_at: new Date().toISOString(),
1924
+ system: false,
1925
+ noteable_id: mergeRequestIid.toString(),
1926
+ noteable_type: "MergeRequest"
1927
+ };
1928
+ }
1929
+ }
1930
+ /**
1931
+ * Publish all draft notes for a merge request
1932
+ * @param {string} projectId - The ID or URL-encoded path of the project
1933
+ * @param {number|string} mergeRequestIid - The internal ID of the merge request
1934
+ * @returns {Promise<GitLabDiscussionNote[]>} Array of published notes
1935
+ */
1936
+ async function bulkPublishDraftNotes(projectId, mergeRequestIid) {
1937
+ projectId = decodeURIComponent(projectId);
1938
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/draft_notes/bulk_publish`);
1939
+ const response = await fetch(url.toString(), {
1940
+ ...DEFAULT_FETCH_CONFIG,
1941
+ method: "POST", // Changed from PUT to POST
1942
+ body: JSON.stringify({}), // Send empty body for POST request
1943
+ });
1944
+ if (!response.ok) {
1945
+ const errorText = await response.text();
1946
+ throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
1947
+ }
1948
+ // Handle empty response (204 No Content) or successful response
1949
+ const responseText = await response.text();
1950
+ if (!responseText || responseText.trim() === '') {
1951
+ // Return empty array for successful bulk publish with no content
1952
+ return [];
1953
+ }
1954
+ try {
1955
+ const data = JSON.parse(responseText);
1956
+ return z.array(GitLabDiscussionNoteSchema).parse(data);
1957
+ }
1958
+ catch (parseError) {
1959
+ // If JSON parsing fails but the operation was successful (2xx status),
1960
+ // return empty array indicating successful bulk publish
1961
+ console.warn(`JSON parse error for successful bulk publish operation: ${parseError}`);
1962
+ return [];
1963
+ }
1964
+ }
1965
+ /**
1966
+ * Create a new thread on a merge request
1967
+ * 📦 새로운 함수: createMergeRequestThread - 병합 요청에 새로운 스레드(토론)를 생성하는 함수
1968
+ * (New function: createMergeRequestThread - Function to create a new thread (discussion) on a merge request)
1969
+ *
1970
+ * This function provides more capabilities than createNote, including the ability to:
1971
+ * - Create diff notes (comments on specific lines of code)
1972
+ * - Specify exact positions for comments
1973
+ * - Set creation timestamps
1974
+ *
1975
+ * @param {string} projectId - The ID or URL-encoded path of the project
1976
+ * @param {number} mergeRequestIid - The internal ID of the merge request
1977
+ * @param {string} body - The content of the thread
1978
+ * @param {MergeRequestThreadPosition} [position] - Position information for diff notes
1979
+ * @param {string} [createdAt] - ISO 8601 formatted creation date
1980
+ * @returns {Promise<GitLabDiscussion>} The created discussion thread
1981
+ */
1982
+ async function createMergeRequestThread(projectId, mergeRequestIid, body, position, createdAt) {
1983
+ projectId = decodeURIComponent(projectId); // Decode project ID
1984
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/discussions`);
1985
+ const payload = { body };
1986
+ // Add optional parameters if provided
1987
+ if (position) {
1988
+ payload.position = position;
1989
+ }
1990
+ if (createdAt) {
1991
+ payload.created_at = createdAt;
1992
+ }
1993
+ const response = await fetch(url.toString(), {
1994
+ ...DEFAULT_FETCH_CONFIG,
1995
+ method: "POST",
1996
+ body: JSON.stringify(payload),
1997
+ });
1998
+ await handleGitLabError(response);
1999
+ const data = await response.json();
2000
+ return GitLabDiscussionSchema.parse(data);
2001
+ }
2002
+ /**
2003
+ * List all namespaces
2004
+ * 사용 가능한 모든 네임스페이스 목록 조회
2005
+ *
2006
+ * @param {Object} options - Options for listing namespaces
2007
+ * @param {string} [options.search] - Search query to filter namespaces
2008
+ * @param {boolean} [options.owned_only] - Only return namespaces owned by the authenticated user
2009
+ * @param {boolean} [options.top_level_only] - Only return top-level namespaces
2010
+ * @returns {Promise<GitLabNamespace[]>} List of namespaces
2011
+ */
2012
+ async function listNamespaces(options) {
2013
+ const url = new URL(`${GITLAB_API_URL}/namespaces`);
2014
+ if (options.search) {
2015
+ url.searchParams.append("search", options.search);
2016
+ }
2017
+ if (options.owned_only) {
2018
+ url.searchParams.append("owned_only", "true");
2019
+ }
2020
+ if (options.top_level_only) {
2021
+ url.searchParams.append("top_level_only", "true");
2022
+ }
2023
+ const response = await fetch(url.toString(), {
2024
+ ...DEFAULT_FETCH_CONFIG,
2025
+ });
2026
+ await handleGitLabError(response);
2027
+ const data = await response.json();
2028
+ return z.array(GitLabNamespaceSchema).parse(data);
2029
+ }
2030
+ /**
2031
+ * Get details on a namespace
2032
+ * 네임스페이스 상세 정보 조회
2033
+ *
2034
+ * @param {string} id - The ID or URL-encoded path of the namespace
2035
+ * @returns {Promise<GitLabNamespace>} The namespace details
2036
+ */
2037
+ async function getNamespace(id) {
2038
+ const url = new URL(`${GITLAB_API_URL}/namespaces/${encodeURIComponent(id)}`);
2039
+ const response = await fetch(url.toString(), {
2040
+ ...DEFAULT_FETCH_CONFIG,
2041
+ });
2042
+ await handleGitLabError(response);
2043
+ const data = await response.json();
2044
+ return GitLabNamespaceSchema.parse(data);
2045
+ }
2046
+ /**
2047
+ * Verify if a namespace exists
2048
+ * 네임스페이스 존재 여부 확인
2049
+ *
2050
+ * @param {string} namespacePath - The path of the namespace to check
2051
+ * @param {number} [parentId] - The ID of the parent namespace
2052
+ * @returns {Promise<GitLabNamespaceExistsResponse>} The verification result
2053
+ */
2054
+ async function verifyNamespaceExistence(namespacePath, parentId) {
2055
+ const url = new URL(`${GITLAB_API_URL}/namespaces/${encodeURIComponent(namespacePath)}/exists`);
2056
+ if (parentId) {
2057
+ url.searchParams.append("parent_id", parentId.toString());
2058
+ }
2059
+ const response = await fetch(url.toString(), {
2060
+ ...DEFAULT_FETCH_CONFIG,
2061
+ });
2062
+ await handleGitLabError(response);
2063
+ const data = await response.json();
2064
+ return GitLabNamespaceExistsResponseSchema.parse(data);
2065
+ }
2066
+ /**
2067
+ * Get a single project
2068
+ * 단일 프로젝트 조회
2069
+ *
2070
+ * @param {string} projectId - The ID or URL-encoded path of the project
2071
+ * @param {Object} options - Options for getting project details
2072
+ * @param {boolean} [options.license] - Include project license data
2073
+ * @param {boolean} [options.statistics] - Include project statistics
2074
+ * @param {boolean} [options.with_custom_attributes] - Include custom attributes in response
2075
+ * @returns {Promise<GitLabProject>} Project details
2076
+ */
2077
+ async function getProject(projectId, options = {}) {
2078
+ projectId = decodeURIComponent(projectId); // Decode project ID
2079
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}`);
2080
+ if (options.license) {
2081
+ url.searchParams.append("license", "true");
2082
+ }
2083
+ if (options.statistics) {
2084
+ url.searchParams.append("statistics", "true");
2085
+ }
2086
+ if (options.with_custom_attributes) {
2087
+ url.searchParams.append("with_custom_attributes", "true");
2088
+ }
2089
+ const response = await fetch(url.toString(), {
2090
+ ...DEFAULT_FETCH_CONFIG,
2091
+ });
2092
+ await handleGitLabError(response);
2093
+ const data = await response.json();
2094
+ return GitLabRepositorySchema.parse(data);
2095
+ }
2096
+ /**
2097
+ * List projects
2098
+ * 프로젝트 목록 조회
2099
+ *
2100
+ * @param {Object} options - Options for listing projects
2101
+ * @returns {Promise<GitLabProject[]>} List of projects
2102
+ */
2103
+ async function listProjects(options = {}) {
2104
+ // Construct the query parameters
2105
+ const params = new URLSearchParams();
2106
+ for (const [key, value] of Object.entries(options)) {
2107
+ if (value !== undefined && value !== null) {
2108
+ if (typeof value === "boolean") {
2109
+ params.append(key, value ? "true" : "false");
2110
+ }
2111
+ else {
2112
+ params.append(key, String(value));
2113
+ }
2114
+ }
2115
+ }
2116
+ // Make the API request
2117
+ const response = await fetch(`${GITLAB_API_URL}/projects?${params.toString()}`, {
2118
+ ...DEFAULT_FETCH_CONFIG,
2119
+ });
2120
+ // Handle errors
2121
+ await handleGitLabError(response);
2122
+ // Parse and return the data
2123
+ const data = await response.json();
2124
+ return z.array(GitLabProjectSchema).parse(data);
2125
+ }
2126
+ /**
2127
+ * List labels for a project
2128
+ *
2129
+ * @param projectId The ID or URL-encoded path of the project
2130
+ * @param options Optional parameters for listing labels
2131
+ * @returns Array of GitLab labels
2132
+ */
2133
+ async function listLabels(projectId, options = {}) {
2134
+ projectId = decodeURIComponent(projectId); // Decode project ID
2135
+ // Construct the URL with project path
2136
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/labels`);
2137
+ // Add query parameters
2138
+ Object.entries(options).forEach(([key, value]) => {
2139
+ if (value !== undefined) {
2140
+ if (typeof value === "boolean") {
2141
+ url.searchParams.append(key, value ? "true" : "false");
2142
+ }
2143
+ else {
2144
+ url.searchParams.append(key, String(value));
2145
+ }
2146
+ }
2147
+ });
2148
+ // Make the API request
2149
+ const response = await fetch(url.toString(), {
2150
+ ...DEFAULT_FETCH_CONFIG,
2151
+ });
2152
+ // Handle errors
2153
+ await handleGitLabError(response);
2154
+ // Parse and return the data
2155
+ const data = await response.json();
2156
+ return data;
2157
+ }
2158
+ /**
2159
+ * Get a single label from a project
2160
+ *
2161
+ * @param projectId The ID or URL-encoded path of the project
2162
+ * @param labelId The ID or name of the label
2163
+ * @param includeAncestorGroups Whether to include ancestor groups
2164
+ * @returns GitLab label
2165
+ */
2166
+ async function getLabel(projectId, labelId, includeAncestorGroups) {
2167
+ projectId = decodeURIComponent(projectId); // Decode project ID
2168
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/labels/${encodeURIComponent(String(labelId))}`);
2169
+ // Add query parameters
2170
+ if (includeAncestorGroups !== undefined) {
2171
+ url.searchParams.append("include_ancestor_groups", includeAncestorGroups ? "true" : "false");
2172
+ }
2173
+ // Make the API request
2174
+ const response = await fetch(url.toString(), {
2175
+ ...DEFAULT_FETCH_CONFIG,
2176
+ });
2177
+ // Handle errors
2178
+ await handleGitLabError(response);
2179
+ // Parse and return the data
2180
+ const data = await response.json();
2181
+ return data;
2182
+ }
2183
+ /**
2184
+ * Create a new label in a project
2185
+ *
2186
+ * @param projectId The ID or URL-encoded path of the project
2187
+ * @param options Options for creating the label
2188
+ * @returns Created GitLab label
2189
+ */
2190
+ async function createLabel(projectId, options) {
2191
+ projectId = decodeURIComponent(projectId); // Decode project ID
2192
+ // Make the API request
2193
+ const response = await fetch(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/labels`, {
2194
+ ...DEFAULT_FETCH_CONFIG,
2195
+ method: "POST",
2196
+ body: JSON.stringify(options),
2197
+ });
2198
+ // Handle errors
2199
+ await handleGitLabError(response);
2200
+ // Parse and return the data
2201
+ const data = await response.json();
2202
+ return data;
2203
+ }
2204
+ /**
2205
+ * Update an existing label in a project
2206
+ *
2207
+ * @param projectId The ID or URL-encoded path of the project
2208
+ * @param labelId The ID or name of the label to update
2209
+ * @param options Options for updating the label
2210
+ * @returns Updated GitLab label
2211
+ */
2212
+ async function updateLabel(projectId, labelId, options) {
2213
+ projectId = decodeURIComponent(projectId); // Decode project ID
2214
+ // Make the API request
2215
+ const response = await fetch(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/labels/${encodeURIComponent(String(labelId))}`, {
2216
+ ...DEFAULT_FETCH_CONFIG,
2217
+ method: "PUT",
2218
+ body: JSON.stringify(options),
2219
+ });
2220
+ // Handle errors
2221
+ await handleGitLabError(response);
2222
+ // Parse and return the data
2223
+ const data = await response.json();
2224
+ return data;
2225
+ }
2226
+ /**
2227
+ * Delete a label from a project
2228
+ *
2229
+ * @param projectId The ID or URL-encoded path of the project
2230
+ * @param labelId The ID or name of the label to delete
2231
+ */
2232
+ async function deleteLabel(projectId, labelId) {
2233
+ projectId = decodeURIComponent(projectId); // Decode project ID
2234
+ // Make the API request
2235
+ const response = await fetch(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/labels/${encodeURIComponent(String(labelId))}`, {
2236
+ ...DEFAULT_FETCH_CONFIG,
2237
+ method: "DELETE",
2238
+ });
2239
+ // Handle errors
2240
+ await handleGitLabError(response);
2241
+ }
2242
+ /**
2243
+ * List all projects in a GitLab group
2244
+ *
2245
+ * @param {z.infer<typeof ListGroupProjectsSchema>} options - Options for listing group projects
2246
+ * @returns {Promise<GitLabProject[]>} Array of projects in the group
2247
+ */
2248
+ async function listGroupProjects(options) {
2249
+ const url = new URL(`${GITLAB_API_URL}/groups/${encodeURIComponent(options.group_id)}/projects`);
2250
+ // Add optional parameters to URL
2251
+ if (options.include_subgroups)
2252
+ url.searchParams.append("include_subgroups", "true");
2253
+ if (options.search)
2254
+ url.searchParams.append("search", options.search);
2255
+ if (options.order_by)
2256
+ url.searchParams.append("order_by", options.order_by);
2257
+ if (options.sort)
2258
+ url.searchParams.append("sort", options.sort);
2259
+ if (options.page)
2260
+ url.searchParams.append("page", options.page.toString());
2261
+ if (options.per_page)
2262
+ url.searchParams.append("per_page", options.per_page.toString());
2263
+ if (options.archived !== undefined)
2264
+ url.searchParams.append("archived", options.archived.toString());
2265
+ if (options.visibility)
2266
+ url.searchParams.append("visibility", options.visibility);
2267
+ if (options.with_issues_enabled !== undefined)
2268
+ url.searchParams.append("with_issues_enabled", options.with_issues_enabled.toString());
2269
+ if (options.with_merge_requests_enabled !== undefined)
2270
+ url.searchParams.append("with_merge_requests_enabled", options.with_merge_requests_enabled.toString());
2271
+ if (options.min_access_level !== undefined)
2272
+ url.searchParams.append("min_access_level", options.min_access_level.toString());
2273
+ if (options.with_programming_language)
2274
+ url.searchParams.append("with_programming_language", options.with_programming_language);
2275
+ if (options.starred !== undefined)
2276
+ url.searchParams.append("starred", options.starred.toString());
2277
+ if (options.statistics !== undefined)
2278
+ url.searchParams.append("statistics", options.statistics.toString());
2279
+ if (options.with_custom_attributes !== undefined)
2280
+ url.searchParams.append("with_custom_attributes", options.with_custom_attributes.toString());
2281
+ if (options.with_security_reports !== undefined)
2282
+ url.searchParams.append("with_security_reports", options.with_security_reports.toString());
2283
+ const response = await fetch(url.toString(), {
2284
+ ...DEFAULT_FETCH_CONFIG,
2285
+ });
2286
+ await handleGitLabError(response);
2287
+ const projects = await response.json();
2288
+ return GitLabProjectSchema.array().parse(projects);
2289
+ }
2290
+ // Wiki API helper functions
2291
+ /**
2292
+ * List wiki pages in a project
2293
+ */
2294
+ async function listWikiPages(projectId, options = {}) {
2295
+ projectId = decodeURIComponent(projectId); // Decode project ID
2296
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/wikis`);
2297
+ if (options.page)
2298
+ url.searchParams.append("page", options.page.toString());
2299
+ if (options.per_page)
2300
+ url.searchParams.append("per_page", options.per_page.toString());
2301
+ if (options.with_content)
2302
+ url.searchParams.append("with_content", options.with_content.toString());
2303
+ const response = await fetch(url.toString(), {
2304
+ ...DEFAULT_FETCH_CONFIG,
2305
+ });
2306
+ await handleGitLabError(response);
2307
+ const data = await response.json();
2308
+ return GitLabWikiPageSchema.array().parse(data);
2309
+ }
2310
+ /**
2311
+ * Get a specific wiki page
2312
+ */
2313
+ async function getWikiPage(projectId, slug) {
2314
+ projectId = decodeURIComponent(projectId); // Decode project ID
2315
+ const response = await fetch(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/wikis/${encodeURIComponent(slug)}`, { ...DEFAULT_FETCH_CONFIG });
2316
+ await handleGitLabError(response);
2317
+ const data = await response.json();
2318
+ return GitLabWikiPageSchema.parse(data);
2319
+ }
2320
+ /**
2321
+ * Create a new wiki page
2322
+ */
2323
+ async function createWikiPage(projectId, title, content, format) {
2324
+ projectId = decodeURIComponent(projectId); // Decode project ID
2325
+ const body = { title, content };
2326
+ if (format)
2327
+ body.format = format;
2328
+ const response = await fetch(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/wikis`, {
2329
+ ...DEFAULT_FETCH_CONFIG,
2330
+ method: "POST",
2331
+ body: JSON.stringify(body),
2332
+ });
2333
+ await handleGitLabError(response);
2334
+ const data = await response.json();
2335
+ return GitLabWikiPageSchema.parse(data);
2336
+ }
2337
+ /**
2338
+ * Update an existing wiki page
2339
+ */
2340
+ async function updateWikiPage(projectId, slug, title, content, format) {
2341
+ projectId = decodeURIComponent(projectId); // Decode project ID
2342
+ const body = {};
2343
+ if (title)
2344
+ body.title = title;
2345
+ if (content)
2346
+ body.content = content;
2347
+ if (format)
2348
+ body.format = format;
2349
+ const response = await fetch(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/wikis/${encodeURIComponent(slug)}`, {
2350
+ ...DEFAULT_FETCH_CONFIG,
2351
+ method: "PUT",
2352
+ body: JSON.stringify(body),
2353
+ });
2354
+ await handleGitLabError(response);
2355
+ const data = await response.json();
2356
+ return GitLabWikiPageSchema.parse(data);
2357
+ }
2358
+ /**
2359
+ * Delete a wiki page
2360
+ */
2361
+ async function deleteWikiPage(projectId, slug) {
2362
+ projectId = decodeURIComponent(projectId); // Decode project ID
2363
+ const response = await fetch(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/wikis/${encodeURIComponent(slug)}`, {
2364
+ ...DEFAULT_FETCH_CONFIG,
2365
+ method: "DELETE",
2366
+ });
2367
+ await handleGitLabError(response);
2368
+ }
2369
+ /**
2370
+ * List pipelines in a GitLab project
2371
+ *
2372
+ * @param {string} projectId - The ID or URL-encoded path of the project
2373
+ * @param {ListPipelinesOptions} options - Options for filtering pipelines
2374
+ * @returns {Promise<GitLabPipeline[]>} List of pipelines
2375
+ */
2376
+ async function listPipelines(projectId, options = {}) {
2377
+ projectId = decodeURIComponent(projectId); // Decode project ID
2378
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines`);
2379
+ // Add all query parameters
2380
+ Object.entries(options).forEach(([key, value]) => {
2381
+ if (value !== undefined) {
2382
+ url.searchParams.append(key, value.toString());
2383
+ }
2384
+ });
2385
+ const response = await fetch(url.toString(), {
2386
+ ...DEFAULT_FETCH_CONFIG,
2387
+ });
2388
+ await handleGitLabError(response);
2389
+ const data = await response.json();
2390
+ return z.array(GitLabPipelineSchema).parse(data);
2391
+ }
2392
+ /**
2393
+ * Get details of a specific pipeline
2394
+ *
2395
+ * @param {string} projectId - The ID or URL-encoded path of the project
2396
+ * @param {number} pipelineId - The ID of the pipeline
2397
+ * @returns {Promise<GitLabPipeline>} Pipeline details
2398
+ */
2399
+ async function getPipeline(projectId, pipelineId) {
2400
+ projectId = decodeURIComponent(projectId); // Decode project ID
2401
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${pipelineId}`);
2402
+ const response = await fetch(url.toString(), {
2403
+ ...DEFAULT_FETCH_CONFIG,
2404
+ });
2405
+ if (response.status === 404) {
2406
+ throw new Error(`Pipeline not found`);
2407
+ }
2408
+ await handleGitLabError(response);
2409
+ const data = await response.json();
2410
+ return GitLabPipelineSchema.parse(data);
2411
+ }
2412
+ /**
2413
+ * List all jobs in a specific pipeline
2414
+ *
2415
+ * @param {string} projectId - The ID or URL-encoded path of the project
2416
+ * @param {number} pipelineId - The ID of the pipeline
2417
+ * @param {Object} options - Options for filtering jobs
2418
+ * @returns {Promise<GitLabPipelineJob[]>} List of pipeline jobs
2419
+ */
2420
+ async function listPipelineJobs(projectId, pipelineId, options = {}) {
2421
+ projectId = decodeURIComponent(projectId); // Decode project ID
2422
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${pipelineId}/jobs`);
2423
+ // Add all query parameters
2424
+ Object.entries(options).forEach(([key, value]) => {
2425
+ if (value !== undefined) {
2426
+ if (typeof value === "boolean") {
2427
+ url.searchParams.append(key, value ? "true" : "false");
2428
+ }
2429
+ else {
2430
+ url.searchParams.append(key, value.toString());
2431
+ }
2432
+ }
2433
+ });
2434
+ const response = await fetch(url.toString(), {
2435
+ ...DEFAULT_FETCH_CONFIG,
2436
+ });
2437
+ if (response.status === 404) {
2438
+ throw new Error(`Pipeline not found`);
2439
+ }
2440
+ await handleGitLabError(response);
2441
+ const data = await response.json();
2442
+ return z.array(GitLabPipelineJobSchema).parse(data);
2443
+ }
2444
+ /**
2445
+ * List all trigger jobs (bridges) in a specific pipeline
2446
+ *
2447
+ * @param {string} projectId - The ID or URL-encoded path of the project
2448
+ * @param {number} pipelineId - The ID of the pipeline
2449
+ * @param {Object} options - Options for filtering trigger jobs
2450
+ * @returns {Promise<GitLabPipelineTriggerJob[]>} List of pipeline trigger jobs
2451
+ */
2452
+ async function listPipelineTriggerJobs(projectId, pipelineId, options = {}) {
2453
+ projectId = decodeURIComponent(projectId); // Decode project ID
2454
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${pipelineId}/bridges`);
2455
+ // Add all query parameters
2456
+ Object.entries(options).forEach(([key, value]) => {
2457
+ if (value !== undefined) {
2458
+ if (typeof value === "boolean") {
2459
+ url.searchParams.append(key, value ? "true" : "false");
2460
+ }
2461
+ else {
2462
+ url.searchParams.append(key, value.toString());
2463
+ }
2464
+ }
2465
+ });
2466
+ const response = await fetch(url.toString(), {
2467
+ ...DEFAULT_FETCH_CONFIG,
2468
+ });
2469
+ if (response.status === 404) {
2470
+ throw new Error(`Pipeline not found`);
2471
+ }
2472
+ await handleGitLabError(response);
2473
+ const data = await response.json();
2474
+ return z.array(GitLabPipelineTriggerJobSchema).parse(data);
2475
+ }
2476
+ async function getPipelineJob(projectId, jobId) {
2477
+ projectId = decodeURIComponent(projectId); // Decode project ID
2478
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/jobs/${jobId}`);
2479
+ const response = await fetch(url.toString(), {
2480
+ ...DEFAULT_FETCH_CONFIG,
2481
+ });
2482
+ if (response.status === 404) {
2483
+ throw new Error(`Job not found`);
2484
+ }
2485
+ await handleGitLabError(response);
2486
+ const data = await response.json();
2487
+ return GitLabPipelineJobSchema.parse(data);
2488
+ }
2489
+ /**
2490
+ * Get the output/trace of a pipeline job
2491
+ *
2492
+ * @param {string} projectId - The ID or URL-encoded path of the project
2493
+ * @param {number} jobId - The ID of the job
2494
+ * @param {number} limit - Maximum number of lines to return from the end (default: 1000)
2495
+ * @param {number} offset - Number of lines to skip from the end (default: 0)
2496
+ * @returns {Promise<string>} The job output/trace
2497
+ */
2498
+ async function getPipelineJobOutput(projectId, jobId, limit, offset) {
2499
+ projectId = decodeURIComponent(projectId); // Decode project ID
2500
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/jobs/${jobId}/trace`);
2501
+ const response = await fetch(url.toString(), {
2502
+ ...DEFAULT_FETCH_CONFIG,
2503
+ headers: {
2504
+ ...DEFAULT_HEADERS,
2505
+ Accept: "text/plain", // Override Accept header to get plain text
2506
+ },
2507
+ });
2508
+ if (response.status === 404) {
2509
+ throw new Error(`Job trace not found or job is not finished yet`);
2510
+ }
2511
+ await handleGitLabError(response);
2512
+ const fullTrace = await response.text();
2513
+ // Apply client-side pagination to limit context window usage
2514
+ if (limit !== undefined || offset !== undefined) {
2515
+ const lines = fullTrace.split("\n");
2516
+ const startOffset = offset || 0;
2517
+ const maxLines = limit || 1000;
2518
+ // Return lines from the end, skipping offset lines and limiting to maxLines
2519
+ const startIndex = Math.max(0, lines.length - startOffset - maxLines);
2520
+ const endIndex = lines.length - startOffset;
2521
+ const selectedLines = lines.slice(startIndex, endIndex);
2522
+ const result = selectedLines.join("\n");
2523
+ // Add metadata about truncation
2524
+ if (startIndex > 0 || endIndex < lines.length) {
2525
+ const totalLines = lines.length;
2526
+ const shownLines = selectedLines.length;
2527
+ const skippedFromStart = startIndex;
2528
+ const skippedFromEnd = startOffset;
2529
+ return `[Log truncated: showing ${shownLines} of ${totalLines} lines, skipped ${skippedFromStart} from start, ${skippedFromEnd} from end]\n\n${result}`;
2530
+ }
2531
+ return result;
2532
+ }
2533
+ return fullTrace;
2534
+ }
2535
+ /**
2536
+ * Create a new pipeline
2537
+ *
2538
+ * @param {string} projectId - The ID or URL-encoded path of the project
2539
+ * @param {string} ref - The branch or tag to run the pipeline on
2540
+ * @param {Array} variables - Optional variables for the pipeline
2541
+ * @returns {Promise<GitLabPipeline>} The created pipeline
2542
+ */
2543
+ async function createPipeline(projectId, ref, variables) {
2544
+ projectId = decodeURIComponent(projectId); // Decode project ID
2545
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipeline`);
2546
+ const body = { ref };
2547
+ if (variables && variables.length > 0) {
2548
+ body.variables = variables;
2549
+ }
2550
+ const response = await fetch(url.toString(), {
2551
+ method: "POST",
2552
+ headers: DEFAULT_HEADERS,
2553
+ body: JSON.stringify(body),
2554
+ });
2555
+ await handleGitLabError(response);
2556
+ const data = await response.json();
2557
+ return GitLabPipelineSchema.parse(data);
2558
+ }
2559
+ /**
2560
+ * Retry a pipeline
2561
+ *
2562
+ * @param {string} projectId - The ID or URL-encoded path of the project
2563
+ * @param {number} pipelineId - The ID of the pipeline to retry
2564
+ * @returns {Promise<GitLabPipeline>} The retried pipeline
2565
+ */
2566
+ async function retryPipeline(projectId, pipelineId) {
2567
+ projectId = decodeURIComponent(projectId); // Decode project ID
2568
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${pipelineId}/retry`);
2569
+ const response = await fetch(url.toString(), {
2570
+ method: "POST",
2571
+ headers: DEFAULT_HEADERS,
2572
+ });
2573
+ await handleGitLabError(response);
2574
+ const data = await response.json();
2575
+ return GitLabPipelineSchema.parse(data);
2576
+ }
2577
+ /**
2578
+ * Cancel a pipeline
2579
+ *
2580
+ * @param {string} projectId - The ID or URL-encoded path of the project
2581
+ * @param {number} pipelineId - The ID of the pipeline to cancel
2582
+ * @returns {Promise<GitLabPipeline>} The canceled pipeline
2583
+ */
2584
+ async function cancelPipeline(projectId, pipelineId) {
2585
+ projectId = decodeURIComponent(projectId); // Decode project ID
2586
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${pipelineId}/cancel`);
2587
+ const response = await fetch(url.toString(), {
2588
+ method: "POST",
2589
+ headers: DEFAULT_HEADERS,
2590
+ });
2591
+ await handleGitLabError(response);
2592
+ const data = await response.json();
2593
+ return GitLabPipelineSchema.parse(data);
2594
+ }
2595
+ /**
2596
+ * Get the repository tree for a project
2597
+ * @param {string} projectId - The ID or URL-encoded path of the project
2598
+ * @param {GetRepositoryTreeOptions} options - Options for the tree
2599
+ * @returns {Promise<GitLabTreeItem[]>}
2600
+ */
2601
+ async function getRepositoryTree(options) {
2602
+ options.project_id = decodeURIComponent(options.project_id); // Decode project_id within options
2603
+ const queryParams = new URLSearchParams();
2604
+ if (options.path)
2605
+ queryParams.append("path", options.path);
2606
+ if (options.ref)
2607
+ queryParams.append("ref", options.ref);
2608
+ if (options.recursive)
2609
+ queryParams.append("recursive", "true");
2610
+ if (options.per_page)
2611
+ queryParams.append("per_page", options.per_page.toString());
2612
+ if (options.page_token)
2613
+ queryParams.append("page_token", options.page_token);
2614
+ if (options.pagination)
2615
+ queryParams.append("pagination", options.pagination);
2616
+ const headers = {
2617
+ "Content-Type": "application/json",
2618
+ };
2619
+ if (IS_OLD) {
2620
+ headers["Private-Token"] = `${GITLAB_PERSONAL_ACCESS_TOKEN}`;
2621
+ }
2622
+ else {
2623
+ headers["Authorization"] = `Bearer ${GITLAB_PERSONAL_ACCESS_TOKEN}`;
2624
+ }
2625
+ const response = await fetch(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(options.project_id))}/repository/tree?${queryParams.toString()}`, {
2626
+ headers,
2627
+ });
2628
+ if (response.status === 404) {
2629
+ throw new Error("Repository or path not found");
2630
+ }
2631
+ if (!response.ok) {
2632
+ throw new Error(`Failed to get repository tree: ${response.statusText}`);
2633
+ }
2634
+ const data = await response.json();
2635
+ return z.array(GitLabTreeItemSchema).parse(data);
2636
+ }
2637
+ /**
2638
+ * List project milestones in a GitLab project
2639
+ * @param {string} projectId - The ID or URL-encoded path of the project
2640
+ * @param {Object} options - Options for listing milestones
2641
+ * @returns {Promise<GitLabMilestones[]>} List of milestones
2642
+ */
2643
+ async function listProjectMilestones(projectId, options) {
2644
+ projectId = decodeURIComponent(projectId);
2645
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/milestones`);
2646
+ Object.entries(options).forEach(([key, value]) => {
2647
+ if (value !== undefined) {
2648
+ if (key === "iids" && Array.isArray(value) && value.length > 0) {
2649
+ value.forEach(iid => {
2650
+ url.searchParams.append("iids[]", iid.toString());
2651
+ });
2652
+ }
2653
+ else if (value !== undefined) {
2654
+ url.searchParams.append(key, value.toString());
2655
+ }
2656
+ }
2657
+ });
2658
+ const response = await fetch(url.toString(), {
2659
+ ...DEFAULT_FETCH_CONFIG,
2660
+ });
2661
+ await handleGitLabError(response);
2662
+ const data = await response.json();
2663
+ return z.array(GitLabMilestonesSchema).parse(data);
2664
+ }
13
2665
  /**
14
- * Determine which transport modes are enabled based on environment variables
15
- * If both SSE and STREAMABLE_HTTP are disabled, defaults to STDIO
2666
+ * Get a single milestone in a GitLab project
2667
+ * @param {string} projectId - The ID or URL-encoded path of the project
2668
+ * @param {number} milestoneId - The ID of the milestone
2669
+ * @returns {Promise<GitLabMilestones>} Milestone details
16
2670
  */
17
- function determineTransportModes() {
18
- const sseEnabled = config.SSE;
19
- const streamableHttpEnabled = config.STREAMABLE_HTTP;
20
- // If neither SSE nor STREAMABLE_HTTP are enabled, use STDIO
21
- const stdioEnabled = !sseEnabled && !streamableHttpEnabled;
22
- return {
23
- stdio: stdioEnabled,
24
- sse: sseEnabled,
25
- streamableHttp: streamableHttpEnabled
26
- };
2671
+ async function getProjectMilestone(projectId, milestoneId) {
2672
+ projectId = decodeURIComponent(projectId);
2673
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/milestones/${milestoneId}`);
2674
+ const response = await fetch(url.toString(), {
2675
+ ...DEFAULT_FETCH_CONFIG,
2676
+ });
2677
+ await handleGitLabError(response);
2678
+ const data = await response.json();
2679
+ return GitLabMilestonesSchema.parse(data);
27
2680
  }
28
2681
  /**
29
- * Start server with stdio transport
2682
+ * Create a new milestone in a GitLab project
2683
+ * @param {string} projectId - The ID or URL-encoded path of the project
2684
+ * @param {Object} options - Options for creating a milestone
2685
+ * @returns {Promise<GitLabMilestones>} Created milestone
30
2686
  */
31
- async function startStdioServer() {
32
- const transport = new StdioServerTransport();
33
- await mcpserver.connect(transport);
2687
+ async function createProjectMilestone(projectId, options) {
2688
+ projectId = decodeURIComponent(projectId);
2689
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/milestones`);
2690
+ const response = await fetch(url.toString(), {
2691
+ ...DEFAULT_FETCH_CONFIG,
2692
+ method: "POST",
2693
+ body: JSON.stringify(options),
2694
+ });
2695
+ await handleGitLabError(response);
2696
+ const data = await response.json();
2697
+ return GitLabMilestonesSchema.parse(data);
34
2698
  }
35
- // used for the sse and streamable http transports to share auth
36
- async function startExpressServer(options) {
37
- const { sseEnabled, streamableHttpEnabled } = options;
38
- const app = express();
39
- const authMiddleware = await configureAuthentication(app);
40
- const argon2Salt = new TextEncoder().encode(config.ARGON2_SALT);
41
- if (sseEnabled) {
42
- const transports = {};
43
- app.get("/sse", authMiddleware, async (req, res) => {
44
- const transport = new SSEServerTransport("/messages", res);
45
- transports[transport.sessionId] = {
46
- transport,
47
- };
48
- // if we have a valid auth info here, either obtained from the passthrough token or oauth, we tie it to a session.
49
- if (req.auth) {
50
- transports[transport.sessionId].tokenHash = await argon2.hash(req.auth.token, {
51
- salt: argon2Salt,
52
- });
53
- logger.debug({
54
- tokenHash: transports[transport.sessionId].tokenHash?.slice(-8),
55
- }, "created new auth session");
56
- }
57
- res.on("close", () => {
58
- delete transports[transport.sessionId];
59
- });
60
- try {
61
- await mcpserver.connect(transport);
62
- }
63
- catch (e) {
64
- logger.error({ e }, "Transport error connecting to MCP server:");
65
- res.status(500).send("Internal server error");
66
- }
2699
+ /**
2700
+ * Edit an existing milestone in a GitLab project
2701
+ * @param {string} projectId - The ID or URL-encoded path of the project
2702
+ * @param {number} milestoneId - The ID of the milestone
2703
+ * @param {Object} options - Options for editing a milestone
2704
+ * @returns {Promise<GitLabMilestones>} Updated milestone
2705
+ */
2706
+ async function editProjectMilestone(projectId, milestoneId, options) {
2707
+ projectId = decodeURIComponent(projectId);
2708
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/milestones/${milestoneId}`);
2709
+ const response = await fetch(url.toString(), {
2710
+ ...DEFAULT_FETCH_CONFIG,
2711
+ method: "PUT",
2712
+ body: JSON.stringify(options),
2713
+ });
2714
+ await handleGitLabError(response);
2715
+ const data = await response.json();
2716
+ return GitLabMilestonesSchema.parse(data);
2717
+ }
2718
+ /**
2719
+ * Delete a milestone from a GitLab project
2720
+ * @param {string} projectId - The ID or URL-encoded path of the project
2721
+ * @param {number} milestoneId - The ID of the milestone
2722
+ * @returns {Promise<void>}
2723
+ */
2724
+ async function deleteProjectMilestone(projectId, milestoneId) {
2725
+ projectId = decodeURIComponent(projectId);
2726
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/milestones/${milestoneId}`);
2727
+ const response = await fetch(url.toString(), {
2728
+ ...DEFAULT_FETCH_CONFIG,
2729
+ method: "DELETE",
2730
+ });
2731
+ await handleGitLabError(response);
2732
+ }
2733
+ /**
2734
+ * Get all issues assigned to a single milestone
2735
+ * @param {string} projectId - The ID or URL-encoded path of the project
2736
+ * @param {number} milestoneId - The ID of the milestone
2737
+ * @returns {Promise<GitLabIssue[]>} List of issues
2738
+ */
2739
+ async function getMilestoneIssues(projectId, milestoneId) {
2740
+ projectId = decodeURIComponent(projectId);
2741
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/milestones/${milestoneId}/issues`);
2742
+ const response = await fetch(url.toString(), {
2743
+ ...DEFAULT_FETCH_CONFIG,
2744
+ });
2745
+ await handleGitLabError(response);
2746
+ const data = await response.json();
2747
+ return z.array(GitLabIssueSchema).parse(data);
2748
+ }
2749
+ /**
2750
+ * Get all merge requests assigned to a single milestone
2751
+ * @param {string} projectId - The ID or URL-encoded path of the project
2752
+ * @param {number} milestoneId - The ID of the milestone
2753
+ * @returns {Promise<GitLabMergeRequest[]>} List of merge requests
2754
+ */
2755
+ async function getMilestoneMergeRequests(projectId, milestoneId) {
2756
+ projectId = decodeURIComponent(projectId);
2757
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/milestones/${milestoneId}/merge_requests`);
2758
+ const response = await fetch(url.toString(), {
2759
+ ...DEFAULT_FETCH_CONFIG,
2760
+ });
2761
+ await handleGitLabError(response);
2762
+ const data = await response.json();
2763
+ return z.array(GitLabMergeRequestSchema).parse(data);
2764
+ }
2765
+ /**
2766
+ * Promote a project milestone to a group milestone
2767
+ * @param {string} projectId - The ID or URL-encoded path of the project
2768
+ * @param {number} milestoneId - The ID of the milestone
2769
+ * @returns {Promise<GitLabMilestones>} Promoted milestone
2770
+ */
2771
+ async function promoteProjectMilestone(projectId, milestoneId) {
2772
+ projectId = decodeURIComponent(projectId);
2773
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/milestones/${milestoneId}/promote`);
2774
+ const response = await fetch(url.toString(), {
2775
+ ...DEFAULT_FETCH_CONFIG,
2776
+ method: "POST",
2777
+ });
2778
+ await handleGitLabError(response);
2779
+ const data = await response.json();
2780
+ return GitLabMilestonesSchema.parse(data);
2781
+ }
2782
+ /**
2783
+ * Get all burndown chart events for a single milestone
2784
+ * @param {string} projectId - The ID or URL-encoded path of the project
2785
+ * @param {number} milestoneId - The ID of the milestone
2786
+ * @returns {Promise<any[]>} Burndown chart events
2787
+ */
2788
+ async function getMilestoneBurndownEvents(projectId, milestoneId) {
2789
+ projectId = decodeURIComponent(projectId);
2790
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/milestones/${milestoneId}/burndown_events`);
2791
+ const response = await fetch(url.toString(), {
2792
+ ...DEFAULT_FETCH_CONFIG,
2793
+ });
2794
+ await handleGitLabError(response);
2795
+ const data = await response.json();
2796
+ return data;
2797
+ }
2798
+ /**
2799
+ * Get a single user from GitLab
2800
+ *
2801
+ * @param {string} username - The username to look up
2802
+ * @returns {Promise<GitLabUser | null>} The user data or null if not found
2803
+ */
2804
+ async function getUser(username) {
2805
+ try {
2806
+ const url = new URL(`${GITLAB_API_URL}/users`);
2807
+ url.searchParams.append("username", username);
2808
+ const response = await fetch(url.toString(), {
2809
+ ...DEFAULT_FETCH_CONFIG,
67
2810
  });
68
- app.post("/messages", authMiddleware, async (req, res) => {
69
- const sessionId = req.query.sessionId;
70
- const transportDetails = transports[sessionId];
71
- if (!transportDetails) {
72
- res.status(400).send("No transport found for sessionId");
73
- return;
2811
+ await handleGitLabError(response);
2812
+ const users = await response.json();
2813
+ // GitLab returns an array of users that match the username
2814
+ if (Array.isArray(users) && users.length > 0) {
2815
+ // Find exact match for username (case-sensitive)
2816
+ const exactMatch = users.find(user => user.username === username);
2817
+ if (exactMatch) {
2818
+ return GitLabUserSchema.parse(exactMatch);
2819
+ }
2820
+ }
2821
+ // No matching user found
2822
+ return null;
2823
+ }
2824
+ catch (error) {
2825
+ logger.error(`Error fetching user by username '${username}':`, error);
2826
+ return null;
2827
+ }
2828
+ }
2829
+ /**
2830
+ * Get multiple users from GitLab
2831
+ *
2832
+ * @param {string[]} usernames - Array of usernames to look up
2833
+ * @returns {Promise<GitLabUsersResponse>} Object with usernames as keys and user objects or null as values
2834
+ */
2835
+ async function getUsers(usernames) {
2836
+ const users = {};
2837
+ // Process usernames sequentially to avoid rate limiting
2838
+ for (const username of usernames) {
2839
+ try {
2840
+ const user = await getUser(username);
2841
+ users[username] = user;
2842
+ }
2843
+ catch (error) {
2844
+ logger.error(`Error processing username '${username}':`, error);
2845
+ users[username] = null;
2846
+ }
2847
+ }
2848
+ return GitLabUsersResponseSchema.parse(users);
2849
+ }
2850
+ /**
2851
+ * List repository commits
2852
+ * 저장소 커밋 목록 조회
2853
+ *
2854
+ * @param {string} projectId - Project ID or URL-encoded path
2855
+ * @param {ListCommitsOptions} options - List commits options
2856
+ * @returns {Promise<GitLabCommit[]>} List of commits
2857
+ */
2858
+ async function listCommits(projectId, options = {}) {
2859
+ projectId = decodeURIComponent(projectId);
2860
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/repository/commits`);
2861
+ // Add query parameters
2862
+ if (options.ref_name)
2863
+ url.searchParams.append("ref_name", options.ref_name);
2864
+ if (options.since)
2865
+ url.searchParams.append("since", options.since);
2866
+ if (options.until)
2867
+ url.searchParams.append("until", options.until);
2868
+ if (options.path)
2869
+ url.searchParams.append("path", options.path);
2870
+ if (options.author)
2871
+ url.searchParams.append("author", options.author);
2872
+ if (options.all)
2873
+ url.searchParams.append("all", options.all.toString());
2874
+ if (options.with_stats)
2875
+ url.searchParams.append("with_stats", options.with_stats.toString());
2876
+ if (options.first_parent)
2877
+ url.searchParams.append("first_parent", options.first_parent.toString());
2878
+ if (options.order)
2879
+ url.searchParams.append("order", options.order);
2880
+ if (options.trailers)
2881
+ url.searchParams.append("trailers", options.trailers.toString());
2882
+ if (options.page)
2883
+ url.searchParams.append("page", options.page.toString());
2884
+ if (options.per_page)
2885
+ url.searchParams.append("per_page", options.per_page.toString());
2886
+ const response = await fetch(url.toString(), {
2887
+ ...DEFAULT_FETCH_CONFIG,
2888
+ });
2889
+ await handleGitLabError(response);
2890
+ const data = await response.json();
2891
+ return z.array(GitLabCommitSchema).parse(data);
2892
+ }
2893
+ /**
2894
+ * Get a single commit
2895
+ * 단일 커밋 정보 조회
2896
+ *
2897
+ * @param {string} projectId - Project ID or URL-encoded path
2898
+ * @param {string} sha - The commit hash or name of a repository branch or tag
2899
+ * @param {boolean} [stats] - Include commit stats
2900
+ * @returns {Promise<GitLabCommit>} The commit details
2901
+ */
2902
+ async function getCommit(projectId, sha, stats) {
2903
+ projectId = decodeURIComponent(projectId);
2904
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/repository/commits/${encodeURIComponent(sha)}`);
2905
+ if (stats) {
2906
+ url.searchParams.append("stats", "true");
2907
+ }
2908
+ const response = await fetch(url.toString(), {
2909
+ ...DEFAULT_FETCH_CONFIG,
2910
+ });
2911
+ await handleGitLabError(response);
2912
+ const data = await response.json();
2913
+ return GitLabCommitSchema.parse(data);
2914
+ }
2915
+ /**
2916
+ * Get commit diff
2917
+ * 커밋 변경사항 조회
2918
+ *
2919
+ * @param {string} projectId - Project ID or URL-encoded path
2920
+ * @param {string} sha - The commit hash or name of a repository branch or tag
2921
+ * @returns {Promise<GitLabMergeRequestDiff[]>} The commit diffs
2922
+ */
2923
+ async function getCommitDiff(projectId, sha) {
2924
+ projectId = decodeURIComponent(projectId);
2925
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/repository/commits/${encodeURIComponent(sha)}/diff`);
2926
+ const response = await fetch(url.toString(), {
2927
+ ...DEFAULT_FETCH_CONFIG,
2928
+ });
2929
+ await handleGitLabError(response);
2930
+ const data = await response.json();
2931
+ return z.array(GitLabDiffSchema).parse(data);
2932
+ }
2933
+ /**
2934
+ * Get the current authenticated user
2935
+ * 현재 인증된 사용자 가져오기
2936
+ *
2937
+ * @returns {Promise<GitLabUser>} The current user
2938
+ */
2939
+ async function getCurrentUser() {
2940
+ const response = await fetch(`${GITLAB_API_URL}/user`, DEFAULT_FETCH_CONFIG);
2941
+ await handleGitLabError(response);
2942
+ const data = await response.json();
2943
+ return GitLabUserSchema.parse(data);
2944
+ }
2945
+ /**
2946
+ * List issues assigned to the current authenticated user
2947
+ * 현재 인증된 사용자에게 할당된 이슈 목록 조회
2948
+ *
2949
+ * @param {MyIssuesOptions} options - Options for filtering issues
2950
+ * @returns {Promise<GitLabIssue[]>} List of issues assigned to the current user
2951
+ */
2952
+ async function myIssues(options = {}) {
2953
+ // Get current user to find their username
2954
+ const currentUser = await getCurrentUser();
2955
+ // Use getEffectiveProjectId to handle project ID resolution
2956
+ const effectiveProjectId = getEffectiveProjectId(options.project_id || "");
2957
+ // Use listIssues with assignee_username filter
2958
+ let listIssuesOptions = {
2959
+ state: options.state || "opened", // Default to "opened" if not specified
2960
+ labels: options.labels,
2961
+ milestone: options.milestone,
2962
+ search: options.search,
2963
+ created_after: options.created_after,
2964
+ created_before: options.created_before,
2965
+ updated_after: options.updated_after,
2966
+ updated_before: options.updated_before,
2967
+ per_page: options.per_page,
2968
+ page: options.page,
2969
+ };
2970
+ if (currentUser.username) {
2971
+ listIssuesOptions.assignee_username = [currentUser.username];
2972
+ }
2973
+ else {
2974
+ listIssuesOptions.assignee_id = currentUser.id;
2975
+ }
2976
+ return listIssues(effectiveProjectId, listIssuesOptions);
2977
+ }
2978
+ /**
2979
+ * List members of a GitLab project
2980
+ * GitLab 프로젝트 멤버 목록 조회
2981
+ *
2982
+ * @param {string} projectId - Project ID or URL-encoded path
2983
+ * @param {Omit<ListProjectMembersOptions, "project_id">} options - Options for filtering members
2984
+ * @returns {Promise<GitLabProjectMember[]>} List of project members
2985
+ */
2986
+ async function listProjectMembers(projectId, options = {}) {
2987
+ projectId = decodeURIComponent(projectId);
2988
+ const effectiveProjectId = getEffectiveProjectId(projectId);
2989
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/members`);
2990
+ // Add query parameters
2991
+ if (options.query)
2992
+ url.searchParams.append("query", options.query);
2993
+ if (options.user_ids) {
2994
+ options.user_ids.forEach(id => url.searchParams.append("user_ids[]", id.toString()));
2995
+ }
2996
+ if (options.skip_users) {
2997
+ options.skip_users.forEach(id => url.searchParams.append("skip_users[]", id.toString()));
2998
+ }
2999
+ if (options.per_page)
3000
+ url.searchParams.append("per_page", options.per_page.toString());
3001
+ if (options.page)
3002
+ url.searchParams.append("page", options.page.toString());
3003
+ const response = await fetch(url.toString(), DEFAULT_FETCH_CONFIG);
3004
+ await handleGitLabError(response);
3005
+ const data = await response.json();
3006
+ return z.array(GitLabProjectMemberSchema).parse(data);
3007
+ }
3008
+ /**
3009
+ * list group iterations
3010
+ *
3011
+ * @param {string} groupId
3012
+ * @param {Omit<ListGroupIterationsOptions, "group_id">} options
3013
+ * @returns {Promise<GetIt[]>}
3014
+ */
3015
+ async function listGroupIterations(groupId, options = {}) {
3016
+ groupId = decodeURIComponent(groupId);
3017
+ const url = new URL(`${GITLAB_API_URL}/groups/${encodeURIComponent(groupId)}/iterations`);
3018
+ // クエリパラメータの追加
3019
+ if (options.state)
3020
+ url.searchParams.append("state", options.state);
3021
+ if (options.search)
3022
+ url.searchParams.append("search", options.search);
3023
+ if (options.in)
3024
+ url.searchParams.append("in", options.in.join(","));
3025
+ if (options.include_ancestors !== undefined)
3026
+ url.searchParams.append("include_ancestors", options.include_ancestors.toString());
3027
+ if (options.include_descendants !== undefined)
3028
+ url.searchParams.append("include_descendants", options.include_descendants.toString());
3029
+ if (options.updated_before)
3030
+ url.searchParams.append("updated_before", options.updated_before);
3031
+ if (options.updated_after)
3032
+ url.searchParams.append("updated_after", options.updated_after);
3033
+ if (options.page)
3034
+ url.searchParams.append("page", options.page.toString());
3035
+ if (options.per_page)
3036
+ url.searchParams.append("per_page", options.per_page.toString());
3037
+ const response = await fetch(url.toString(), DEFAULT_FETCH_CONFIG);
3038
+ if (!response.ok) {
3039
+ await handleGitLabError(response);
3040
+ }
3041
+ const data = await response.json();
3042
+ return z.array(GroupIteration).parse(data);
3043
+ }
3044
+ /**
3045
+ * Upload a file to a GitLab project for use in markdown content
3046
+ *
3047
+ * @param {string} projectId - The ID or URL-encoded path of the project
3048
+ * @param {string} filePath - Path to the local file to upload
3049
+ * @returns {Promise<GitLabMarkdownUpload>} The upload response
3050
+ */
3051
+ async function markdownUpload(projectId, filePath) {
3052
+ projectId = decodeURIComponent(projectId);
3053
+ const effectiveProjectId = getEffectiveProjectId(projectId);
3054
+ // Check if file exists
3055
+ if (!fs.existsSync(filePath)) {
3056
+ throw new Error(`File not found: ${filePath}`);
3057
+ }
3058
+ // Read the file
3059
+ const fileBuffer = fs.readFileSync(filePath);
3060
+ const fileName = path.basename(filePath);
3061
+ // Create form data
3062
+ const FormData = (await import("form-data")).default;
3063
+ const form = new FormData();
3064
+ form.append("file", fileBuffer, {
3065
+ filename: fileName,
3066
+ contentType: "application/octet-stream",
3067
+ });
3068
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/uploads`);
3069
+ const response = await fetch(url.toString(), {
3070
+ method: "POST",
3071
+ headers: {
3072
+ ...DEFAULT_HEADERS,
3073
+ // Remove Content-Type header to let form-data set it with boundary
3074
+ "Content-Type": undefined,
3075
+ },
3076
+ body: form,
3077
+ });
3078
+ if (!response.ok) {
3079
+ await handleGitLabError(response);
3080
+ }
3081
+ const data = await response.json();
3082
+ return GitLabMarkdownUploadSchema.parse(data);
3083
+ }
3084
+ async function downloadAttachment(projectId, secret, filename, localPath) {
3085
+ const effectiveProjectId = getEffectiveProjectId(projectId);
3086
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/uploads/${secret}/${filename}`);
3087
+ const response = await fetch(url.toString(), {
3088
+ method: "GET",
3089
+ headers: DEFAULT_HEADERS,
3090
+ });
3091
+ if (!response.ok) {
3092
+ await handleGitLabError(response);
3093
+ }
3094
+ // Get the file content as buffer
3095
+ const buffer = await response.arrayBuffer();
3096
+ // Determine the save path
3097
+ const savePath = localPath ? path.join(localPath, filename) : filename;
3098
+ // Write the file to disk
3099
+ fs.writeFileSync(savePath, Buffer.from(buffer));
3100
+ return savePath;
3101
+ }
3102
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
3103
+ // Apply read-only filter first
3104
+ const tools0 = GITLAB_READ_ONLY_MODE
3105
+ ? allTools.filter(tool => readOnlyTools.includes(tool.name))
3106
+ : allTools;
3107
+ // Toggle wiki tools by USE_GITLAB_WIKI flag
3108
+ const tools1 = USE_GITLAB_WIKI
3109
+ ? tools0
3110
+ : tools0.filter(tool => !wikiToolNames.includes(tool.name));
3111
+ // Toggle milestone tools by USE_MILESTONE flag
3112
+ const tools2 = USE_MILESTONE
3113
+ ? tools1
3114
+ : tools1.filter(tool => !milestoneToolNames.includes(tool.name));
3115
+ // Toggle pipeline tools by USE_PIPELINE flag
3116
+ let tools = USE_PIPELINE ? tools2 : tools2.filter(tool => !pipelineToolNames.includes(tool.name));
3117
+ tools = GITLAB_DENIED_TOOLS_REGEX ? tools.filter(tool => !GITLAB_DENIED_TOOLS_REGEX.test(tool.name)) : tools;
3118
+ // <<< START: Gemini 호환성을 위해 $schema 제거 >>>
3119
+ tools = tools.map(tool => {
3120
+ // inputSchema가 존재하고 객체인지 확인
3121
+ if (tool.inputSchema && typeof tool.inputSchema === "object" && tool.inputSchema !== null) {
3122
+ // $schema 키가 존재하면 삭제
3123
+ if ("$schema" in tool.inputSchema) {
3124
+ // 불변성을 위해 새로운 객체 생성 (선택적이지만 권장)
3125
+ const modifiedSchema = { ...tool.inputSchema };
3126
+ delete modifiedSchema.$schema;
3127
+ return { ...tool, inputSchema: modifiedSchema };
74
3128
  }
75
- const { transport, tokenHash } = transportDetails;
76
- // means we have a token hash to verify.
77
- if (tokenHash) {
78
- // NOTE: at this point, we assume that this req.auth is a "valid" AuthInfo
79
- // TODO: consider the security implications of this when verifying dcr clients.
80
- if (!req.auth) {
81
- res.status(401).send("No authorization information sent");
82
- return;
3129
+ }
3130
+ // 변경이 필요 없으면 그대로 반환
3131
+ return tool;
3132
+ });
3133
+ // <<< END: Gemini 호환성을 위해 $schema 제거 >>>
3134
+ return {
3135
+ tools, // $schema가 제거된 도구 목록 반환
3136
+ };
3137
+ });
3138
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3139
+ try {
3140
+ if (!request.params.arguments) {
3141
+ throw new Error("Arguments are required");
3142
+ }
3143
+ // Ensure session is established for every request if cookie authentication is enabled
3144
+ if (GITLAB_AUTH_COOKIE_PATH) {
3145
+ await ensureSessionForRequest();
3146
+ }
3147
+ logger.info(request.params.name);
3148
+ switch (request.params.name) {
3149
+ case "fork_repository": {
3150
+ if (GITLAB_PROJECT_ID) {
3151
+ throw new Error("Direct project ID is set. So fork_repository is not allowed");
83
3152
  }
84
- const gitlabToken = req.auth.token;
85
- if (!gitlabToken) {
86
- res.status(401).send("No valid token info found in request");
87
- return;
3153
+ const forkArgs = ForkRepositorySchema.parse(request.params.arguments);
3154
+ try {
3155
+ const forkedProject = await forkProject(forkArgs.project_id, forkArgs.namespace);
3156
+ return {
3157
+ content: [{ type: "text", text: JSON.stringify(forkedProject, null, 2) }],
3158
+ };
88
3159
  }
89
- const verified = await argon2.verify(tokenHash, gitlabToken, {
90
- salt: argon2Salt,
91
- });
92
- if (!verified) {
93
- res.status(401).send("Token does not match session");
94
- return;
3160
+ catch (forkError) {
3161
+ logger.error("Error forking repository:", forkError);
3162
+ let forkErrorMessage = "Failed to fork repository";
3163
+ if (forkError instanceof Error) {
3164
+ forkErrorMessage = `${forkErrorMessage}: ${forkError.message}`;
3165
+ }
3166
+ return {
3167
+ content: [
3168
+ {
3169
+ type: "text",
3170
+ text: JSON.stringify({ error: forkErrorMessage }, null, 2),
3171
+ },
3172
+ ],
3173
+ };
95
3174
  }
96
- logger.debug({
97
- tokenHash: tokenHash.slice(-8),
98
- }, "auth token verified");
99
3175
  }
100
- if (transport) {
101
- try {
102
- await transport.handlePostMessage(req, res);
3176
+ case "create_branch": {
3177
+ const args = CreateBranchSchema.parse(request.params.arguments);
3178
+ let ref = args.ref;
3179
+ if (!ref) {
3180
+ ref = await getDefaultBranchRef(args.project_id);
103
3181
  }
104
- catch (e) {
105
- logger.error({ e }, "Transport error handling message");
106
- res.status(500).send("Internal server error");
3182
+ const branch = await createBranch(args.project_id, {
3183
+ name: args.branch,
3184
+ ref,
3185
+ });
3186
+ return {
3187
+ content: [{ type: "text", text: JSON.stringify(branch, null, 2) }],
3188
+ };
3189
+ }
3190
+ case "get_branch_diffs": {
3191
+ const args = GetBranchDiffsSchema.parse(request.params.arguments);
3192
+ const diffResp = await getBranchDiffs(args.project_id, args.from, args.to, args.straight);
3193
+ if (args.excluded_file_patterns?.length) {
3194
+ const regexPatterns = args.excluded_file_patterns.map(pattern => new RegExp(pattern));
3195
+ // Helper function to check if a path matches any regex pattern
3196
+ const matchesAnyPattern = (path) => {
3197
+ if (!path)
3198
+ return false;
3199
+ return regexPatterns.some(regex => regex.test(path));
3200
+ };
3201
+ // Filter out files that match any of the regex patterns on new files
3202
+ diffResp.diffs = diffResp.diffs.filter(diff => !matchesAnyPattern(diff.new_path));
107
3203
  }
3204
+ return {
3205
+ content: [{ type: "text", text: JSON.stringify(diffResp, null, 2) }],
3206
+ };
108
3207
  }
109
- });
110
- }
111
- if (streamableHttpEnabled) {
112
- const transports = {};
113
- // Streamable HTTP endpoint - handles both session creation and message handling
114
- app.post('/mcp', authMiddleware, async (req, res) => {
115
- const sessionId = req.headers['mcp-session-id'];
116
- try {
117
- let transport;
118
- if (sessionId && transports[sessionId]) {
119
- // Reuse existing transport for ongoing session
120
- const session = transports[sessionId];
121
- if (session.tokenHash) {
122
- if (!req.auth) {
123
- res.status(401).send("No authorization information sent");
124
- return;
125
- }
126
- const gitlabToken = req.auth.token;
127
- if (!gitlabToken) {
128
- res.status(401).send("No valid token info found in request");
129
- return;
130
- }
131
- const verified = await argon2.verify(session.tokenHash, gitlabToken, {
132
- salt: argon2Salt,
133
- });
134
- if (!verified) {
135
- res.status(401).send("Token does not match session");
136
- return;
137
- }
138
- logger.debug({
139
- tokenHash: session.tokenHash.slice(-8),
140
- }, "auth token verified");
141
- }
142
- transport = session.transport;
143
- try {
144
- await transport.handleRequest(req, res, req.body);
145
- }
146
- catch (e) {
147
- logger.error("Transport error handling request:", e);
148
- res.status(500).send("Internal server error");
149
- return;
150
- }
3208
+ case "search_repositories": {
3209
+ const args = SearchRepositoriesSchema.parse(request.params.arguments);
3210
+ const results = await searchProjects(args.search, args.page, args.per_page);
3211
+ return {
3212
+ content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
3213
+ };
3214
+ }
3215
+ case "create_repository": {
3216
+ if (GITLAB_PROJECT_ID) {
3217
+ throw new Error("Direct project ID is set. So fork_repository is not allowed");
151
3218
  }
152
- else {
153
- // Create new transport for new session
154
- transport = new StreamableHTTPServerTransport({
155
- sessionIdGenerator: () => randomUUID(),
156
- onsessioninitialized: (newSessionId) => {
157
- transports[newSessionId] = {
158
- transport,
159
- };
160
- // if we have a valid auth info here, either obtained from the passthrough token or oauth, we tie it to a session.
161
- if (req.auth) {
162
- transports[newSessionId].tokenHash = argon2.hashSync(req.auth.token, {
163
- salt: argon2Salt,
164
- });
165
- logger.debug({
166
- tokenHash: transports[newSessionId].tokenHash.slice(-8),
167
- }, "auth session created for token");
168
- }
169
- logger.warn(`Streamable HTTP session initialized: ${newSessionId}`);
170
- }
171
- });
172
- // Set up cleanup handler when transport closes
173
- transport.onclose = () => {
174
- const sid = transport.sessionId;
175
- if (sid && transports[sid]) {
176
- logger.warn(`Streamable HTTP transport closed for session ${sid}, cleaning up`);
177
- delete transports[sid];
178
- }
179
- };
180
- // Connect transport to MCP server before handling the request
181
- try {
182
- await mcpserver.connect(transport);
183
- }
184
- catch (e) {
185
- logger.error({ e }, "Transport error connecting to MCP server:");
186
- res.status(500).send("Internal server error");
187
- return;
188
- }
189
- try {
190
- await transport.handleRequest(req, res, req.body);
191
- }
192
- catch (e) {
193
- logger.error({ e }, "Transport error handling request:");
194
- res.status(500).send("Internal server error");
195
- return;
196
- }
3219
+ const args = CreateRepositorySchema.parse(request.params.arguments);
3220
+ const repository = await createRepository(args);
3221
+ return {
3222
+ content: [{ type: "text", text: JSON.stringify(repository, null, 2) }],
3223
+ };
3224
+ }
3225
+ case "get_file_contents": {
3226
+ const args = GetFileContentsSchema.parse(request.params.arguments);
3227
+ const contents = await getFileContents(args.project_id, args.file_path, args.ref);
3228
+ return {
3229
+ content: [{ type: "text", text: JSON.stringify(contents, null, 2) }],
3230
+ };
3231
+ }
3232
+ case "create_or_update_file": {
3233
+ const args = CreateOrUpdateFileSchema.parse(request.params.arguments);
3234
+ const result = await createOrUpdateFile(args.project_id, args.file_path, args.content, args.commit_message, args.branch, args.previous_path, args.last_commit_id, args.commit_id);
3235
+ return {
3236
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
3237
+ };
3238
+ }
3239
+ case "push_files": {
3240
+ const args = PushFilesSchema.parse(request.params.arguments);
3241
+ const result = await createCommit(args.project_id, args.commit_message, args.branch, args.files.map(f => ({ path: f.file_path, content: f.content })));
3242
+ return {
3243
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
3244
+ };
3245
+ }
3246
+ case "create_issue": {
3247
+ const args = CreateIssueSchema.parse(request.params.arguments);
3248
+ const { project_id, ...options } = args;
3249
+ const issue = await createIssue(project_id, options);
3250
+ return {
3251
+ content: [{ type: "text", text: JSON.stringify(issue, null, 2) }],
3252
+ };
3253
+ }
3254
+ case "create_merge_request": {
3255
+ const args = CreateMergeRequestSchema.parse(request.params.arguments);
3256
+ const { project_id, ...options } = args;
3257
+ const mergeRequest = await createMergeRequest(project_id, options);
3258
+ return {
3259
+ content: [{ type: "text", text: JSON.stringify(mergeRequest, null, 2) }],
3260
+ };
3261
+ }
3262
+ case "update_merge_request_note": {
3263
+ const args = UpdateMergeRequestNoteSchema.parse(request.params.arguments);
3264
+ const note = await updateMergeRequestNote(args.project_id, args.merge_request_iid, args.discussion_id, args.note_id, args.body, // Now optional
3265
+ args.resolved // Now one of body or resolved must be provided, not both
3266
+ );
3267
+ return {
3268
+ content: [{ type: "text", text: JSON.stringify(note, null, 2) }],
3269
+ };
3270
+ }
3271
+ case "create_merge_request_note": {
3272
+ const args = CreateMergeRequestNoteSchema.parse(request.params.arguments);
3273
+ const note = await createMergeRequestNote(args.project_id, args.merge_request_iid, args.discussion_id, args.body, args.created_at);
3274
+ return {
3275
+ content: [{ type: "text", text: JSON.stringify(note, null, 2) }],
3276
+ };
3277
+ }
3278
+ case "update_issue_note": {
3279
+ const args = UpdateIssueNoteSchema.parse(request.params.arguments);
3280
+ const note = await updateIssueNote(args.project_id, args.issue_iid, args.discussion_id, args.note_id, args.body);
3281
+ return {
3282
+ content: [{ type: "text", text: JSON.stringify(note, null, 2) }],
3283
+ };
3284
+ }
3285
+ case "create_issue_note": {
3286
+ const args = CreateIssueNoteSchema.parse(request.params.arguments);
3287
+ const note = await createIssueNote(args.project_id, args.issue_iid, args.discussion_id, args.body, args.created_at);
3288
+ return {
3289
+ content: [{ type: "text", text: JSON.stringify(note, null, 2) }],
3290
+ };
3291
+ }
3292
+ case "get_merge_request": {
3293
+ const args = GetMergeRequestSchema.parse(request.params.arguments);
3294
+ const mergeRequest = await getMergeRequest(args.project_id, args.merge_request_iid, args.source_branch);
3295
+ return {
3296
+ content: [{ type: "text", text: JSON.stringify(mergeRequest, null, 2) }],
3297
+ };
3298
+ }
3299
+ case "get_merge_request_diffs": {
3300
+ const args = GetMergeRequestDiffsSchema.parse(request.params.arguments);
3301
+ const diffs = await getMergeRequestDiffs(args.project_id, args.merge_request_iid, args.source_branch, args.view);
3302
+ return {
3303
+ content: [{ type: "text", text: JSON.stringify(diffs, null, 2) }],
3304
+ };
3305
+ }
3306
+ case "list_merge_request_diffs": {
3307
+ const args = ListMergeRequestDiffsSchema.parse(request.params.arguments);
3308
+ const changes = await listMergeRequestDiffs(args.project_id, args.merge_request_iid, args.source_branch, args.page, args.per_page, args.unidiff);
3309
+ return {
3310
+ content: [{ type: "text", text: JSON.stringify(changes, null, 2) }],
3311
+ };
3312
+ }
3313
+ case "update_merge_request": {
3314
+ const args = UpdateMergeRequestSchema.parse(request.params.arguments);
3315
+ const { project_id, merge_request_iid, source_branch, ...options } = args;
3316
+ const mergeRequest = await updateMergeRequest(project_id, options, merge_request_iid, source_branch);
3317
+ return {
3318
+ content: [{ type: "text", text: JSON.stringify(mergeRequest, null, 2) }],
3319
+ };
3320
+ }
3321
+ case "merge_merge_request": {
3322
+ const args = MergeMergeRequestSchema.parse(request.params.arguments);
3323
+ const { project_id, merge_request_iid, ...options } = args;
3324
+ const mergeRequest = await mergeMergeRequest(project_id, options, merge_request_iid);
3325
+ return {
3326
+ content: [{ type: "text", text: JSON.stringify(mergeRequest, null, 2) }],
3327
+ };
3328
+ }
3329
+ case "mr_discussions": {
3330
+ const args = ListMergeRequestDiscussionsSchema.parse(request.params.arguments);
3331
+ const { project_id, merge_request_iid, ...options } = args;
3332
+ const discussions = await listMergeRequestDiscussions(project_id, merge_request_iid, options);
3333
+ return {
3334
+ content: [{ type: "text", text: JSON.stringify(discussions, null, 2) }],
3335
+ };
3336
+ }
3337
+ case "list_namespaces": {
3338
+ const args = ListNamespacesSchema.parse(request.params.arguments);
3339
+ const url = new URL(`${GITLAB_API_URL}/namespaces`);
3340
+ if (args.search) {
3341
+ url.searchParams.append("search", args.search);
3342
+ }
3343
+ if (args.page) {
3344
+ url.searchParams.append("page", args.page.toString());
3345
+ }
3346
+ if (args.per_page) {
3347
+ url.searchParams.append("per_page", args.per_page.toString());
3348
+ }
3349
+ if (args.owned) {
3350
+ url.searchParams.append("owned", args.owned.toString());
197
3351
  }
3352
+ const response = await fetch(url.toString(), {
3353
+ ...DEFAULT_FETCH_CONFIG,
3354
+ });
3355
+ await handleGitLabError(response);
3356
+ const data = await response.json();
3357
+ const namespaces = z.array(GitLabNamespaceSchema).parse(data);
3358
+ return {
3359
+ content: [{ type: "text", text: JSON.stringify(namespaces, null, 2) }],
3360
+ };
198
3361
  }
199
- catch (error) {
200
- logger.error('Streamable HTTP error:', error);
201
- res.status(500).json({
202
- error: 'Internal server error',
203
- message: error instanceof Error ? error.message : 'Unknown error'
3362
+ case "get_namespace": {
3363
+ const args = GetNamespaceSchema.parse(request.params.arguments);
3364
+ const url = new URL(`${GITLAB_API_URL}/namespaces/${encodeURIComponent(args.namespace_id)}`);
3365
+ const response = await fetch(url.toString(), {
3366
+ ...DEFAULT_FETCH_CONFIG,
204
3367
  });
3368
+ await handleGitLabError(response);
3369
+ const data = await response.json();
3370
+ const namespace = GitLabNamespaceSchema.parse(data);
3371
+ return {
3372
+ content: [{ type: "text", text: JSON.stringify(namespace, null, 2) }],
3373
+ };
205
3374
  }
206
- });
3375
+ case "verify_namespace": {
3376
+ const args = VerifyNamespaceSchema.parse(request.params.arguments);
3377
+ const url = new URL(`${GITLAB_API_URL}/namespaces/${encodeURIComponent(args.path)}/exists`);
3378
+ const response = await fetch(url.toString(), {
3379
+ ...DEFAULT_FETCH_CONFIG,
3380
+ });
3381
+ await handleGitLabError(response);
3382
+ const data = await response.json();
3383
+ const namespaceExists = GitLabNamespaceExistsResponseSchema.parse(data);
3384
+ return {
3385
+ content: [{ type: "text", text: JSON.stringify(namespaceExists, null, 2) }],
3386
+ };
3387
+ }
3388
+ case "get_project": {
3389
+ const args = GetProjectSchema.parse(request.params.arguments);
3390
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(args.project_id))}`);
3391
+ const response = await fetch(url.toString(), {
3392
+ ...DEFAULT_FETCH_CONFIG,
3393
+ });
3394
+ await handleGitLabError(response);
3395
+ const data = await response.json();
3396
+ const project = GitLabProjectSchema.parse(data);
3397
+ return {
3398
+ content: [{ type: "text", text: JSON.stringify(project, null, 2) }],
3399
+ };
3400
+ }
3401
+ case "list_projects": {
3402
+ const args = ListProjectsSchema.parse(request.params.arguments);
3403
+ const projects = await listProjects(args);
3404
+ return {
3405
+ content: [{ type: "text", text: JSON.stringify(projects, null, 2) }],
3406
+ };
3407
+ }
3408
+ case "list_project_members": {
3409
+ const args = ListProjectMembersSchema.parse(request.params.arguments);
3410
+ const { project_id, ...options } = args;
3411
+ const members = await listProjectMembers(project_id, options);
3412
+ return {
3413
+ content: [{ type: "text", text: JSON.stringify(members, null, 2) }],
3414
+ };
3415
+ }
3416
+ case "get_users": {
3417
+ const args = GetUsersSchema.parse(request.params.arguments);
3418
+ const usersMap = await getUsers(args.usernames);
3419
+ return {
3420
+ content: [{ type: "text", text: JSON.stringify(usersMap, null, 2) }],
3421
+ };
3422
+ }
3423
+ case "create_note": {
3424
+ const args = CreateNoteSchema.parse(request.params.arguments);
3425
+ const { project_id, noteable_type, noteable_iid, body } = args;
3426
+ const note = await createNote(project_id, noteable_type, noteable_iid, body);
3427
+ return {
3428
+ content: [{ type: "text", text: JSON.stringify(note, null, 2) }],
3429
+ };
3430
+ }
3431
+ case "get_draft_note": {
3432
+ const args = GetDraftNoteSchema.parse(request.params.arguments);
3433
+ const { project_id, merge_request_iid, draft_note_id } = args;
3434
+ const draftNote = await getDraftNote(project_id, merge_request_iid, draft_note_id);
3435
+ return {
3436
+ content: [{ type: "text", text: JSON.stringify(draftNote, null, 2) }],
3437
+ };
3438
+ }
3439
+ case "list_draft_notes": {
3440
+ const args = ListDraftNotesSchema.parse(request.params.arguments);
3441
+ const { project_id, merge_request_iid } = args;
3442
+ const draftNotes = await listDraftNotes(project_id, merge_request_iid);
3443
+ return {
3444
+ content: [{ type: "text", text: JSON.stringify(draftNotes, null, 2) }],
3445
+ };
3446
+ }
3447
+ case "create_draft_note": {
3448
+ const args = CreateDraftNoteSchema.parse(request.params.arguments);
3449
+ const { project_id, merge_request_iid, body, position, resolve_discussion } = args;
3450
+ const draftNote = await createDraftNote(project_id, merge_request_iid, body, position, resolve_discussion);
3451
+ return {
3452
+ content: [{ type: "text", text: JSON.stringify(draftNote, null, 2) }],
3453
+ };
3454
+ }
3455
+ case "update_draft_note": {
3456
+ const args = UpdateDraftNoteSchema.parse(request.params.arguments);
3457
+ const { project_id, merge_request_iid, draft_note_id, body, position, resolve_discussion } = args;
3458
+ const draftNote = await updateDraftNote(project_id, merge_request_iid, draft_note_id, body, position, resolve_discussion);
3459
+ return {
3460
+ content: [{ type: "text", text: JSON.stringify(draftNote, null, 2) }],
3461
+ };
3462
+ }
3463
+ case "delete_draft_note": {
3464
+ const args = DeleteDraftNoteSchema.parse(request.params.arguments);
3465
+ const { project_id, merge_request_iid, draft_note_id } = args;
3466
+ await deleteDraftNote(project_id, merge_request_iid, draft_note_id);
3467
+ return {
3468
+ content: [{ type: "text", text: "Draft note deleted successfully" }],
3469
+ };
3470
+ }
3471
+ case "publish_draft_note": {
3472
+ const args = PublishDraftNoteSchema.parse(request.params.arguments);
3473
+ const { project_id, merge_request_iid, draft_note_id } = args;
3474
+ const publishedNote = await publishDraftNote(project_id, merge_request_iid, draft_note_id);
3475
+ return {
3476
+ content: [{ type: "text", text: JSON.stringify(publishedNote, null, 2) }],
3477
+ };
3478
+ }
3479
+ case "bulk_publish_draft_notes": {
3480
+ const args = BulkPublishDraftNotesSchema.parse(request.params.arguments);
3481
+ const { project_id, merge_request_iid } = args;
3482
+ const publishedNotes = await bulkPublishDraftNotes(project_id, merge_request_iid);
3483
+ return {
3484
+ content: [{ type: "text", text: JSON.stringify(publishedNotes, null, 2) }],
3485
+ };
3486
+ }
3487
+ case "create_merge_request_thread": {
3488
+ const args = CreateMergeRequestThreadSchema.parse(request.params.arguments);
3489
+ const { project_id, merge_request_iid, body, position, created_at } = args;
3490
+ const thread = await createMergeRequestThread(project_id, merge_request_iid, body, position, created_at);
3491
+ return {
3492
+ content: [{ type: "text", text: JSON.stringify(thread, null, 2) }],
3493
+ };
3494
+ }
3495
+ case "list_issues": {
3496
+ const args = ListIssuesSchema.parse(request.params.arguments);
3497
+ const { project_id, ...options } = args;
3498
+ const issues = await listIssues(project_id, options);
3499
+ return {
3500
+ content: [{ type: "text", text: JSON.stringify(issues, null, 2) }],
3501
+ };
3502
+ }
3503
+ case "my_issues": {
3504
+ const args = MyIssuesSchema.parse(request.params.arguments);
3505
+ const issues = await myIssues(args);
3506
+ return {
3507
+ content: [{ type: "text", text: JSON.stringify(issues, null, 2) }],
3508
+ };
3509
+ }
3510
+ case "get_issue": {
3511
+ const args = GetIssueSchema.parse(request.params.arguments);
3512
+ const issue = await getIssue(args.project_id, args.issue_iid);
3513
+ return {
3514
+ content: [{ type: "text", text: JSON.stringify(issue, null, 2) }],
3515
+ };
3516
+ }
3517
+ case "update_issue": {
3518
+ const args = UpdateIssueSchema.parse(request.params.arguments);
3519
+ const { project_id, issue_iid, ...options } = args;
3520
+ const issue = await updateIssue(project_id, issue_iid, options);
3521
+ return {
3522
+ content: [{ type: "text", text: JSON.stringify(issue, null, 2) }],
3523
+ };
3524
+ }
3525
+ case "delete_issue": {
3526
+ const args = DeleteIssueSchema.parse(request.params.arguments);
3527
+ await deleteIssue(args.project_id, args.issue_iid);
3528
+ return {
3529
+ content: [
3530
+ {
3531
+ type: "text",
3532
+ text: JSON.stringify({ status: "success", message: "Issue deleted successfully" }, null, 2),
3533
+ },
3534
+ ],
3535
+ };
3536
+ }
3537
+ case "list_issue_links": {
3538
+ const args = ListIssueLinksSchema.parse(request.params.arguments);
3539
+ const links = await listIssueLinks(args.project_id, args.issue_iid);
3540
+ return {
3541
+ content: [{ type: "text", text: JSON.stringify(links, null, 2) }],
3542
+ };
3543
+ }
3544
+ case "list_issue_discussions": {
3545
+ const args = ListIssueDiscussionsSchema.parse(request.params.arguments);
3546
+ const { project_id, issue_iid, ...options } = args;
3547
+ const discussions = await listIssueDiscussions(project_id, issue_iid, options);
3548
+ return {
3549
+ content: [{ type: "text", text: JSON.stringify(discussions, null, 2) }],
3550
+ };
3551
+ }
3552
+ case "get_issue_link": {
3553
+ const args = GetIssueLinkSchema.parse(request.params.arguments);
3554
+ const link = await getIssueLink(args.project_id, args.issue_iid, args.issue_link_id);
3555
+ return {
3556
+ content: [{ type: "text", text: JSON.stringify(link, null, 2) }],
3557
+ };
3558
+ }
3559
+ case "create_issue_link": {
3560
+ const args = CreateIssueLinkSchema.parse(request.params.arguments);
3561
+ const link = await createIssueLink(args.project_id, args.issue_iid, args.target_project_id, args.target_issue_iid, args.link_type);
3562
+ return {
3563
+ content: [{ type: "text", text: JSON.stringify(link, null, 2) }],
3564
+ };
3565
+ }
3566
+ case "delete_issue_link": {
3567
+ const args = DeleteIssueLinkSchema.parse(request.params.arguments);
3568
+ await deleteIssueLink(args.project_id, args.issue_iid, args.issue_link_id);
3569
+ return {
3570
+ content: [
3571
+ {
3572
+ type: "text",
3573
+ text: JSON.stringify({
3574
+ status: "success",
3575
+ message: "Issue link deleted successfully",
3576
+ }, null, 2),
3577
+ },
3578
+ ],
3579
+ };
3580
+ }
3581
+ case "list_labels": {
3582
+ const args = ListLabelsSchema.parse(request.params.arguments);
3583
+ const labels = await listLabels(args.project_id, args);
3584
+ return {
3585
+ content: [{ type: "text", text: JSON.stringify(labels, null, 2) }],
3586
+ };
3587
+ }
3588
+ case "get_label": {
3589
+ const args = GetLabelSchema.parse(request.params.arguments);
3590
+ const label = await getLabel(args.project_id, args.label_id, args.include_ancestor_groups);
3591
+ return {
3592
+ content: [{ type: "text", text: JSON.stringify(label, null, 2) }],
3593
+ };
3594
+ }
3595
+ case "create_label": {
3596
+ const args = CreateLabelSchema.parse(request.params.arguments);
3597
+ const label = await createLabel(args.project_id, args);
3598
+ return {
3599
+ content: [{ type: "text", text: JSON.stringify(label, null, 2) }],
3600
+ };
3601
+ }
3602
+ case "update_label": {
3603
+ const args = UpdateLabelSchema.parse(request.params.arguments);
3604
+ const { project_id, label_id, ...options } = args;
3605
+ const label = await updateLabel(project_id, label_id, options);
3606
+ return {
3607
+ content: [{ type: "text", text: JSON.stringify(label, null, 2) }],
3608
+ };
3609
+ }
3610
+ case "delete_label": {
3611
+ const args = DeleteLabelSchema.parse(request.params.arguments);
3612
+ await deleteLabel(args.project_id, args.label_id);
3613
+ return {
3614
+ content: [
3615
+ {
3616
+ type: "text",
3617
+ text: JSON.stringify({ status: "success", message: "Label deleted successfully" }, null, 2),
3618
+ },
3619
+ ],
3620
+ };
3621
+ }
3622
+ case "list_group_projects": {
3623
+ const args = ListGroupProjectsSchema.parse(request.params.arguments);
3624
+ const projects = await listGroupProjects(args);
3625
+ return {
3626
+ content: [{ type: "text", text: JSON.stringify(projects, null, 2) }],
3627
+ };
3628
+ }
3629
+ case "list_wiki_pages": {
3630
+ const { project_id, page, per_page, with_content } = ListWikiPagesSchema.parse(request.params.arguments);
3631
+ const wikiPages = await listWikiPages(project_id, {
3632
+ page,
3633
+ per_page,
3634
+ with_content,
3635
+ });
3636
+ return {
3637
+ content: [{ type: "text", text: JSON.stringify(wikiPages, null, 2) }],
3638
+ };
3639
+ }
3640
+ case "get_wiki_page": {
3641
+ const { project_id, slug } = GetWikiPageSchema.parse(request.params.arguments);
3642
+ const wikiPage = await getWikiPage(project_id, slug);
3643
+ return {
3644
+ content: [{ type: "text", text: JSON.stringify(wikiPage, null, 2) }],
3645
+ };
3646
+ }
3647
+ case "create_wiki_page": {
3648
+ const { project_id, title, content, format } = CreateWikiPageSchema.parse(request.params.arguments);
3649
+ const wikiPage = await createWikiPage(project_id, title, content, format);
3650
+ return {
3651
+ content: [{ type: "text", text: JSON.stringify(wikiPage, null, 2) }],
3652
+ };
3653
+ }
3654
+ case "update_wiki_page": {
3655
+ const { project_id, slug, title, content, format } = UpdateWikiPageSchema.parse(request.params.arguments);
3656
+ const wikiPage = await updateWikiPage(project_id, slug, title, content, format);
3657
+ return {
3658
+ content: [{ type: "text", text: JSON.stringify(wikiPage, null, 2) }],
3659
+ };
3660
+ }
3661
+ case "delete_wiki_page": {
3662
+ const { project_id, slug } = DeleteWikiPageSchema.parse(request.params.arguments);
3663
+ await deleteWikiPage(project_id, slug);
3664
+ return {
3665
+ content: [
3666
+ {
3667
+ type: "text",
3668
+ text: JSON.stringify({
3669
+ status: "success",
3670
+ message: "Wiki page deleted successfully",
3671
+ }, null, 2),
3672
+ },
3673
+ ],
3674
+ };
3675
+ }
3676
+ case "get_repository_tree": {
3677
+ const args = GetRepositoryTreeSchema.parse(request.params.arguments);
3678
+ const tree = await getRepositoryTree(args);
3679
+ return {
3680
+ content: [{ type: "text", text: JSON.stringify(tree, null, 2) }],
3681
+ };
3682
+ }
3683
+ case "list_pipelines": {
3684
+ const args = ListPipelinesSchema.parse(request.params.arguments);
3685
+ const { project_id, ...options } = args;
3686
+ const pipelines = await listPipelines(project_id, options);
3687
+ return {
3688
+ content: [{ type: "text", text: JSON.stringify(pipelines, null, 2) }],
3689
+ };
3690
+ }
3691
+ case "get_pipeline": {
3692
+ const { project_id, pipeline_id } = GetPipelineSchema.parse(request.params.arguments);
3693
+ const pipeline = await getPipeline(project_id, pipeline_id);
3694
+ return {
3695
+ content: [
3696
+ {
3697
+ type: "text",
3698
+ text: JSON.stringify(pipeline, null, 2),
3699
+ },
3700
+ ],
3701
+ };
3702
+ }
3703
+ case "list_pipeline_jobs": {
3704
+ const { project_id, pipeline_id, ...options } = ListPipelineJobsSchema.parse(request.params.arguments);
3705
+ const jobs = await listPipelineJobs(project_id, pipeline_id, options);
3706
+ return {
3707
+ content: [
3708
+ {
3709
+ type: "text",
3710
+ text: JSON.stringify(jobs, null, 2),
3711
+ },
3712
+ ],
3713
+ };
3714
+ }
3715
+ case "list_pipeline_trigger_jobs": {
3716
+ const { project_id, pipeline_id, ...options } = ListPipelineTriggerJobsSchema.parse(request.params.arguments);
3717
+ const triggerJobs = await listPipelineTriggerJobs(project_id, pipeline_id, options);
3718
+ return {
3719
+ content: [
3720
+ {
3721
+ type: "text",
3722
+ text: JSON.stringify(triggerJobs, null, 2),
3723
+ },
3724
+ ],
3725
+ };
3726
+ }
3727
+ case "get_pipeline_job": {
3728
+ const { project_id, job_id } = GetPipelineJobOutputSchema.parse(request.params.arguments);
3729
+ const jobDetails = await getPipelineJob(project_id, job_id);
3730
+ return {
3731
+ content: [
3732
+ {
3733
+ type: "text",
3734
+ text: JSON.stringify(jobDetails, null, 2),
3735
+ },
3736
+ ],
3737
+ };
3738
+ }
3739
+ case "get_pipeline_job_output": {
3740
+ const { project_id, job_id, limit, offset } = GetPipelineJobOutputSchema.parse(request.params.arguments);
3741
+ const jobOutput = await getPipelineJobOutput(project_id, job_id, limit, offset);
3742
+ return {
3743
+ content: [
3744
+ {
3745
+ type: "text",
3746
+ text: jobOutput,
3747
+ },
3748
+ ],
3749
+ };
3750
+ }
3751
+ case "create_pipeline": {
3752
+ const { project_id, ref, variables } = CreatePipelineSchema.parse(request.params.arguments);
3753
+ const pipeline = await createPipeline(project_id, ref, variables);
3754
+ return {
3755
+ content: [
3756
+ {
3757
+ type: "text",
3758
+ text: `Created pipeline #${pipeline.id} for ${ref}. Status: ${pipeline.status}\nWeb URL: ${pipeline.web_url}`,
3759
+ },
3760
+ ],
3761
+ };
3762
+ }
3763
+ case "retry_pipeline": {
3764
+ const { project_id, pipeline_id } = RetryPipelineSchema.parse(request.params.arguments);
3765
+ const pipeline = await retryPipeline(project_id, pipeline_id);
3766
+ return {
3767
+ content: [
3768
+ {
3769
+ type: "text",
3770
+ text: `Retried pipeline #${pipeline.id}. Status: ${pipeline.status}\nWeb URL: ${pipeline.web_url}`,
3771
+ },
3772
+ ],
3773
+ };
3774
+ }
3775
+ case "cancel_pipeline": {
3776
+ const { project_id, pipeline_id } = CancelPipelineSchema.parse(request.params.arguments);
3777
+ const pipeline = await cancelPipeline(project_id, pipeline_id);
3778
+ return {
3779
+ content: [
3780
+ {
3781
+ type: "text",
3782
+ text: `Canceled pipeline #${pipeline.id}. Status: ${pipeline.status}\nWeb URL: ${pipeline.web_url}`,
3783
+ },
3784
+ ],
3785
+ };
3786
+ }
3787
+ case "list_merge_requests": {
3788
+ const args = ListMergeRequestsSchema.parse(request.params.arguments);
3789
+ const mergeRequests = await listMergeRequests(args.project_id, args);
3790
+ return {
3791
+ content: [{ type: "text", text: JSON.stringify(mergeRequests, null, 2) }],
3792
+ };
3793
+ }
3794
+ case "list_milestones": {
3795
+ const { project_id, ...options } = ListProjectMilestonesSchema.parse(request.params.arguments);
3796
+ const milestones = await listProjectMilestones(project_id, options);
3797
+ return {
3798
+ content: [
3799
+ {
3800
+ type: "text",
3801
+ text: JSON.stringify(milestones, null, 2),
3802
+ },
3803
+ ],
3804
+ };
3805
+ }
3806
+ case "get_milestone": {
3807
+ const { project_id, milestone_id } = GetProjectMilestoneSchema.parse(request.params.arguments);
3808
+ const milestone = await getProjectMilestone(project_id, milestone_id);
3809
+ return {
3810
+ content: [
3811
+ {
3812
+ type: "text",
3813
+ text: JSON.stringify(milestone, null, 2),
3814
+ },
3815
+ ],
3816
+ };
3817
+ }
3818
+ case "create_milestone": {
3819
+ const { project_id, ...options } = CreateProjectMilestoneSchema.parse(request.params.arguments);
3820
+ const milestone = await createProjectMilestone(project_id, options);
3821
+ return {
3822
+ content: [
3823
+ {
3824
+ type: "text",
3825
+ text: JSON.stringify(milestone, null, 2),
3826
+ },
3827
+ ],
3828
+ };
3829
+ }
3830
+ case "edit_milestone": {
3831
+ const { project_id, milestone_id, ...options } = EditProjectMilestoneSchema.parse(request.params.arguments);
3832
+ const milestone = await editProjectMilestone(project_id, milestone_id, options);
3833
+ return {
3834
+ content: [
3835
+ {
3836
+ type: "text",
3837
+ text: JSON.stringify(milestone, null, 2),
3838
+ },
3839
+ ],
3840
+ };
3841
+ }
3842
+ case "delete_milestone": {
3843
+ const { project_id, milestone_id } = DeleteProjectMilestoneSchema.parse(request.params.arguments);
3844
+ await deleteProjectMilestone(project_id, milestone_id);
3845
+ return {
3846
+ content: [
3847
+ {
3848
+ type: "text",
3849
+ text: JSON.stringify({
3850
+ status: "success",
3851
+ message: "Milestone deleted successfully",
3852
+ }, null, 2),
3853
+ },
3854
+ ],
3855
+ };
3856
+ }
3857
+ case "get_milestone_issue": {
3858
+ const { project_id, milestone_id } = GetMilestoneIssuesSchema.parse(request.params.arguments);
3859
+ const issues = await getMilestoneIssues(project_id, milestone_id);
3860
+ return {
3861
+ content: [
3862
+ {
3863
+ type: "text",
3864
+ text: JSON.stringify(issues, null, 2),
3865
+ },
3866
+ ],
3867
+ };
3868
+ }
3869
+ case "get_milestone_merge_requests": {
3870
+ const { project_id, milestone_id } = GetMilestoneMergeRequestsSchema.parse(request.params.arguments);
3871
+ const mergeRequests = await getMilestoneMergeRequests(project_id, milestone_id);
3872
+ return {
3873
+ content: [
3874
+ {
3875
+ type: "text",
3876
+ text: JSON.stringify(mergeRequests, null, 2),
3877
+ },
3878
+ ],
3879
+ };
3880
+ }
3881
+ case "promote_milestone": {
3882
+ const { project_id, milestone_id } = PromoteProjectMilestoneSchema.parse(request.params.arguments);
3883
+ const milestone = await promoteProjectMilestone(project_id, milestone_id);
3884
+ return {
3885
+ content: [
3886
+ {
3887
+ type: "text",
3888
+ text: JSON.stringify(milestone, null, 2),
3889
+ },
3890
+ ],
3891
+ };
3892
+ }
3893
+ case "get_milestone_burndown_events": {
3894
+ const { project_id, milestone_id } = GetMilestoneBurndownEventsSchema.parse(request.params.arguments);
3895
+ const events = await getMilestoneBurndownEvents(project_id, milestone_id);
3896
+ return {
3897
+ content: [
3898
+ {
3899
+ type: "text",
3900
+ text: JSON.stringify(events, null, 2),
3901
+ },
3902
+ ],
3903
+ };
3904
+ }
3905
+ case "list_commits": {
3906
+ const args = ListCommitsSchema.parse(request.params.arguments);
3907
+ const commits = await listCommits(args.project_id, args);
3908
+ return {
3909
+ content: [{ type: "text", text: JSON.stringify(commits, null, 2) }],
3910
+ };
3911
+ }
3912
+ case "get_commit": {
3913
+ const args = GetCommitSchema.parse(request.params.arguments);
3914
+ const commit = await getCommit(args.project_id, args.sha, args.stats);
3915
+ return {
3916
+ content: [{ type: "text", text: JSON.stringify(commit, null, 2) }],
3917
+ };
3918
+ }
3919
+ case "get_commit_diff": {
3920
+ const args = GetCommitDiffSchema.parse(request.params.arguments);
3921
+ const diff = await getCommitDiff(args.project_id, args.sha);
3922
+ return {
3923
+ content: [{ type: "text", text: JSON.stringify(diff, null, 2) }],
3924
+ };
3925
+ }
3926
+ case "list_group_iterations": {
3927
+ const args = ListGroupIterationsSchema.parse(request.params.arguments);
3928
+ const iterations = await listGroupIterations(args.group_id, args);
3929
+ return {
3930
+ content: [{ type: "text", text: JSON.stringify(iterations, null, 2) }],
3931
+ };
3932
+ }
3933
+ case "upload_markdown": {
3934
+ const args = MarkdownUploadSchema.parse(request.params.arguments);
3935
+ const upload = await markdownUpload(args.project_id, args.file_path);
3936
+ return {
3937
+ content: [{ type: "text", text: JSON.stringify(upload, null, 2) }],
3938
+ };
3939
+ }
3940
+ case "download_attachment": {
3941
+ const args = DownloadAttachmentSchema.parse(request.params.arguments);
3942
+ const filePath = await downloadAttachment(args.project_id, args.secret, args.filename, args.local_path);
3943
+ return {
3944
+ content: [{ type: "text", text: JSON.stringify({ success: true, file_path: filePath }, null, 2) }],
3945
+ };
3946
+ }
3947
+ default:
3948
+ throw new Error(`Unknown tool: ${request.params.name}`);
3949
+ }
3950
+ }
3951
+ catch (error) {
3952
+ logger.debug(request.params);
3953
+ if (error instanceof z.ZodError) {
3954
+ throw new Error(`Invalid arguments: ${error.errors
3955
+ .map(e => `${e.path.join(".")}: ${e.message}`)
3956
+ .join(", ")}`);
3957
+ }
3958
+ throw error;
3959
+ }
3960
+ });
3961
+ /**
3962
+ * Color constants for terminal output
3963
+ */
3964
+ const colorGreen = "\x1b[32m";
3965
+ const colorReset = "\x1b[0m";
3966
+ /**
3967
+ * Determine the transport mode based on environment variables and availability
3968
+ *
3969
+ * Transport mode priority (highest to lowest):
3970
+ * 1. STREAMABLE_HTTP
3971
+ * 2. SSE
3972
+ * 3. STDIO
3973
+ */
3974
+ function determineTransportMode() {
3975
+ // Check for streamable-http support (highest priority)
3976
+ if (STREAMABLE_HTTP) {
3977
+ return TransportMode.STREAMABLE_HTTP;
207
3978
  }
3979
+ // Check for SSE support (medium priority)
3980
+ if (SSE) {
3981
+ return TransportMode.SSE;
3982
+ }
3983
+ // Default to stdio (lowest priority)
3984
+ return TransportMode.STDIO;
3985
+ }
3986
+ /**
3987
+ * Start server with stdio transport
3988
+ */
3989
+ async function startStdioServer() {
3990
+ const transport = new StdioServerTransport();
3991
+ await server.connect(transport);
3992
+ }
3993
+ /**
3994
+ * Start server with traditional SSE transport
3995
+ */
3996
+ async function startSSEServer() {
3997
+ const app = express();
3998
+ const transports = {};
3999
+ app.get("/sse", async (_, res) => {
4000
+ const transport = new SSEServerTransport("/messages", res);
4001
+ transports[transport.sessionId] = transport;
4002
+ res.on("close", () => {
4003
+ delete transports[transport.sessionId];
4004
+ });
4005
+ await server.connect(transport);
4006
+ });
4007
+ app.post("/messages", async (req, res) => {
4008
+ const sessionId = req.query.sessionId;
4009
+ const transport = transports[sessionId];
4010
+ if (transport) {
4011
+ await transport.handlePostMessage(req, res);
4012
+ }
4013
+ else {
4014
+ res.status(400).send("No transport found for sessionId");
4015
+ }
4016
+ });
208
4017
  app.get("/health", (_, res) => {
209
4018
  res.status(200).json({
210
4019
  status: "healthy",
211
- version: process.env.npm_package_version || "unknown",
4020
+ version: SERVER_VERSION,
4021
+ transport: TransportMode.SSE,
212
4022
  });
213
4023
  });
214
- app.listen(Number(config.PORT), config.HOST, () => {
215
- const enabledModes = [];
216
- if (sseEnabled)
217
- enabledModes.push('SSE');
218
- if (streamableHttpEnabled)
219
- enabledModes.push('Streamable HTTP');
220
- logger.info(`GitLab MCP Server running with ${enabledModes.join(' and ')} transport(s)`);
4024
+ app.listen(Number(PORT), HOST, () => {
4025
+ logger.info(`GitLab MCP Server running with SSE transport`);
221
4026
  const colorGreen = "\x1b[32m";
222
4027
  const colorReset = "\x1b[0m";
223
- if (sseEnabled) {
224
- logger.info(`${colorGreen}SSE Endpoint: http://${config.HOST}:${config.PORT}/sse${colorReset}`);
4028
+ logger.info(`${colorGreen}Endpoint: http://${HOST}:${PORT}/sse${colorReset}`);
4029
+ });
4030
+ }
4031
+ /**
4032
+ * Start server with Streamable HTTP transport
4033
+ */
4034
+ async function startStreamableHTTPServer() {
4035
+ const app = express();
4036
+ const streamableTransports = {};
4037
+ // Configure Express middleware
4038
+ app.use(express.json());
4039
+ // Streamable HTTP endpoint - handles both session creation and message handling
4040
+ app.post("/mcp", async (req, res) => {
4041
+ const sessionId = req.headers["mcp-session-id"];
4042
+ try {
4043
+ let transport;
4044
+ if (sessionId && streamableTransports[sessionId]) {
4045
+ // Reuse existing transport for ongoing session
4046
+ transport = streamableTransports[sessionId];
4047
+ await transport.handleRequest(req, res, req.body);
4048
+ }
4049
+ else {
4050
+ // Create new transport for new session
4051
+ transport = new StreamableHTTPServerTransport({
4052
+ sessionIdGenerator: () => randomUUID(),
4053
+ onsessioninitialized: (newSessionId) => {
4054
+ streamableTransports[newSessionId] = transport;
4055
+ logger.warn(`Streamable HTTP session initialized: ${newSessionId}`);
4056
+ },
4057
+ });
4058
+ // Set up cleanup handler when transport closes
4059
+ transport.onclose = () => {
4060
+ const sid = transport.sessionId;
4061
+ if (sid && streamableTransports[sid]) {
4062
+ logger.warn(`Streamable HTTP transport closed for session ${sid}, cleaning up`);
4063
+ delete streamableTransports[sid];
4064
+ }
4065
+ };
4066
+ // Connect transport to MCP server before handling the request
4067
+ await server.connect(transport);
4068
+ await transport.handleRequest(req, res, req.body);
4069
+ }
225
4070
  }
226
- if (streamableHttpEnabled) {
227
- logger.info(`${colorGreen}Streamable HTTP Endpoint: http://${config.HOST}:${config.PORT}/mcp${colorReset}`);
4071
+ catch (error) {
4072
+ logger.error("Streamable HTTP error:", error);
4073
+ res.status(500).json({
4074
+ error: "Internal server error",
4075
+ message: error instanceof Error ? error.message : "Unknown error",
4076
+ });
228
4077
  }
229
4078
  });
4079
+ // Health check endpoint
4080
+ app.get("/health", (_, res) => {
4081
+ res.status(200).json({
4082
+ status: "healthy",
4083
+ version: SERVER_VERSION,
4084
+ transport: TransportMode.STREAMABLE_HTTP,
4085
+ activeSessions: Object.keys(streamableTransports).length,
4086
+ });
4087
+ });
4088
+ // Start server
4089
+ app.listen(Number(PORT), HOST, () => {
4090
+ logger.info(`GitLab MCP Server running with Streamable HTTP transport`);
4091
+ logger.info(`${colorGreen}Endpoint: http://${HOST}:${PORT}/mcp${colorReset}`);
4092
+ });
230
4093
  }
231
4094
  /**
232
- * Initialize server based on enabled transport modes
4095
+ * Initialize server with specific transport mode
4096
+ * Handle transport-specific initialization logic
233
4097
  */
234
- async function initializeServer(modes) {
235
- if (modes.stdio) {
236
- logger.warn('Starting GitLab MCP Server with stdio transport');
237
- await startStdioServer();
238
- }
239
- else if (modes.sse || modes.streamableHttp) {
240
- logger.warn('Starting GitLab MCP Server with HTTP transport(s)');
241
- await startExpressServer({
242
- sseEnabled: modes.sse,
243
- streamableHttpEnabled: modes.streamableHttp
244
- });
245
- }
246
- else {
247
- throw new Error('No transport mode enabled');
4098
+ async function initializeServerByTransportMode(mode) {
4099
+ logger.info("Initializing server with transport mode:", mode);
4100
+ switch (mode) {
4101
+ case TransportMode.STDIO:
4102
+ logger.warn("Starting GitLab MCP Server with stdio transport");
4103
+ await startStdioServer();
4104
+ break;
4105
+ case TransportMode.SSE:
4106
+ logger.warn("Starting GitLab MCP Server with SSE transport");
4107
+ await startSSEServer();
4108
+ break;
4109
+ case TransportMode.STREAMABLE_HTTP:
4110
+ logger.warn("Starting GitLab MCP Server with Streamable HTTP transport");
4111
+ await startStreamableHTTPServer();
4112
+ break;
4113
+ default:
4114
+ // This should never happen with proper enum usage, but TypeScript requires it
4115
+ const exhaustiveCheck = mode;
4116
+ throw new Error(`Unknown transport mode: ${exhaustiveCheck}`);
248
4117
  }
249
4118
  }
250
4119
  /**
@@ -253,14 +4122,15 @@ async function initializeServer(modes) {
253
4122
  */
254
4123
  async function runServer() {
255
4124
  try {
256
- const transportModes = determineTransportModes();
257
- await initializeServer(transportModes);
4125
+ const transportMode = determineTransportMode();
4126
+ await initializeServerByTransportMode(transportMode);
258
4127
  }
259
4128
  catch (error) {
260
4129
  logger.error("Error initializing server:", error);
261
4130
  process.exit(1);
262
4131
  }
263
4132
  }
4133
+ // 下記の2行を追記
264
4134
  runServer().catch(error => {
265
4135
  logger.error("Fatal error in main():", error);
266
4136
  process.exit(1);