gitlab-mcp 0.0.1 → 0.1.0

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 ADDED
@@ -0,0 +1,1653 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
5
+ import fetch from "node-fetch";
6
+ import { z } from "zod";
7
+ import { zodToJsonSchema } from "zod-to-json-schema";
8
+ import { fileURLToPath } from "url";
9
+ import { dirname } from "path";
10
+ import fs from "fs";
11
+ import path from "path";
12
+ import { config } from "dotenv";
13
+ import { GitLabForkSchema, GitLabReferenceSchema, GitLabRepositorySchema, GitLabIssueSchema, GitLabMergeRequestSchema, GitLabContentSchema, GitLabCreateUpdateFileResponseSchema, GitLabSearchResponseSchema, GitLabTreeSchema, GitLabCommitSchema, GitLabNamespaceSchema, GitLabNamespaceExistsResponseSchema, GitLabProjectSchema, CreateOrUpdateFileSchema, SearchRepositoriesSchema, CreateRepositorySchema, GetFileContentsSchema, PushFilesSchema, CreateIssueSchema, CreateMergeRequestSchema, ForkRepositorySchema, CreateBranchSchema, GitLabMergeRequestDiffSchema, GetMergeRequestSchema, GetMergeRequestDiffsSchema, UpdateMergeRequestSchema, ListIssuesSchema, GetIssueSchema, UpdateIssueSchema, DeleteIssueSchema, GitLabIssueLinkSchema, GitLabIssueWithLinkDetailsSchema, ListIssueLinksSchema, GetIssueLinkSchema, CreateIssueLinkSchema, DeleteIssueLinkSchema, ListNamespacesSchema, GetNamespaceSchema, VerifyNamespaceSchema, GetProjectSchema, ListProjectsSchema, ListLabelsSchema, GetLabelSchema, CreateLabelSchema, UpdateLabelSchema, DeleteLabelSchema, CreateNoteSchema, ListGroupProjectsSchema,
14
+ // Discussion Schemas
15
+ GitLabDiscussionNoteSchema, // Added
16
+ GitLabDiscussionSchema, UpdateMergeRequestNoteSchema, // Added
17
+ ListMergeRequestDiscussionsSchema, } from "./schemas.js";
18
+ // Load .env from the current working directory
19
+ config({ path: path.resolve(process.cwd(), ".env") });
20
+ /**
21
+ * Read version from package.json
22
+ */
23
+ const __filename = fileURLToPath(import.meta.url);
24
+ const __dirname = dirname(__filename);
25
+ const packageJsonPath = path.resolve(__dirname, "../package.json");
26
+ let SERVER_VERSION = "unknown";
27
+ try {
28
+ if (fs.existsSync(packageJsonPath)) {
29
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
30
+ SERVER_VERSION = packageJson.version || SERVER_VERSION;
31
+ }
32
+ }
33
+ catch (error) {
34
+ console.error("Warning: Could not read version from package.json:", error);
35
+ }
36
+ const server = new Server({
37
+ name: "better-gitlab-mcp-server",
38
+ version: SERVER_VERSION,
39
+ }, {
40
+ capabilities: {
41
+ tools: {},
42
+ },
43
+ });
44
+ const GITLAB_PERSONAL_ACCESS_TOKEN = process.env.GITLAB_PERSONAL_ACCESS_TOKEN;
45
+ const GITLAB_READ_ONLY_MODE = process.env.GITLAB_READ_ONLY_MODE === "true";
46
+ // Define all available tools
47
+ const allTools = [
48
+ {
49
+ name: "create_or_update_file",
50
+ description: "Create or update a single file in a GitLab project",
51
+ inputSchema: zodToJsonSchema(CreateOrUpdateFileSchema),
52
+ },
53
+ {
54
+ name: "search_repositories",
55
+ description: "Search for GitLab projects",
56
+ inputSchema: zodToJsonSchema(SearchRepositoriesSchema),
57
+ },
58
+ {
59
+ name: "create_repository",
60
+ description: "Create a new GitLab project",
61
+ inputSchema: zodToJsonSchema(CreateRepositorySchema),
62
+ },
63
+ {
64
+ name: "get_file_contents",
65
+ description: "Get the contents of a file or directory from a GitLab project",
66
+ inputSchema: zodToJsonSchema(GetFileContentsSchema),
67
+ },
68
+ {
69
+ name: "push_files",
70
+ description: "Push multiple files to a GitLab project in a single commit",
71
+ inputSchema: zodToJsonSchema(PushFilesSchema),
72
+ },
73
+ {
74
+ name: "create_issue",
75
+ description: "Create a new issue in a GitLab project",
76
+ inputSchema: zodToJsonSchema(CreateIssueSchema),
77
+ },
78
+ {
79
+ name: "create_merge_request",
80
+ description: "Create a new merge request in a GitLab project",
81
+ inputSchema: zodToJsonSchema(CreateMergeRequestSchema),
82
+ },
83
+ {
84
+ name: "fork_repository",
85
+ description: "Fork a GitLab project to your account or specified namespace",
86
+ inputSchema: zodToJsonSchema(ForkRepositorySchema),
87
+ },
88
+ {
89
+ name: "create_branch",
90
+ description: "Create a new branch in a GitLab project",
91
+ inputSchema: zodToJsonSchema(CreateBranchSchema),
92
+ },
93
+ {
94
+ name: "get_merge_request",
95
+ description: "Get details of a merge request",
96
+ inputSchema: zodToJsonSchema(GetMergeRequestSchema),
97
+ },
98
+ {
99
+ name: "get_merge_request_diffs",
100
+ description: "Get the changes/diffs of a merge request",
101
+ inputSchema: zodToJsonSchema(GetMergeRequestDiffsSchema),
102
+ },
103
+ {
104
+ name: "update_merge_request",
105
+ description: "Update a merge request",
106
+ inputSchema: zodToJsonSchema(UpdateMergeRequestSchema),
107
+ },
108
+ {
109
+ name: "create_note",
110
+ description: "Create a new note (comment) to an issue or merge request",
111
+ inputSchema: zodToJsonSchema(CreateNoteSchema),
112
+ },
113
+ {
114
+ name: "list_merge_request_discussions",
115
+ description: "List discussion items for a merge request",
116
+ inputSchema: zodToJsonSchema(ListMergeRequestDiscussionsSchema),
117
+ },
118
+ {
119
+ name: "update_merge_request_note",
120
+ description: "Modify an existing merge request thread note",
121
+ inputSchema: zodToJsonSchema(UpdateMergeRequestNoteSchema),
122
+ },
123
+ {
124
+ name: "list_issues",
125
+ description: "List issues in a GitLab project with filtering options",
126
+ inputSchema: zodToJsonSchema(ListIssuesSchema),
127
+ },
128
+ {
129
+ name: "get_issue",
130
+ description: "Get details of a specific issue in a GitLab project",
131
+ inputSchema: zodToJsonSchema(GetIssueSchema),
132
+ },
133
+ {
134
+ name: "update_issue",
135
+ description: "Update an issue in a GitLab project",
136
+ inputSchema: zodToJsonSchema(UpdateIssueSchema),
137
+ },
138
+ {
139
+ name: "delete_issue",
140
+ description: "Delete an issue from a GitLab project",
141
+ inputSchema: zodToJsonSchema(DeleteIssueSchema),
142
+ },
143
+ {
144
+ name: "list_issue_links",
145
+ description: "List all issue links for a specific issue",
146
+ inputSchema: zodToJsonSchema(ListIssueLinksSchema),
147
+ },
148
+ {
149
+ name: "get_issue_link",
150
+ description: "Get a specific issue link",
151
+ inputSchema: zodToJsonSchema(GetIssueLinkSchema),
152
+ },
153
+ {
154
+ name: "create_issue_link",
155
+ description: "Create an issue link between two issues",
156
+ inputSchema: zodToJsonSchema(CreateIssueLinkSchema),
157
+ },
158
+ {
159
+ name: "delete_issue_link",
160
+ description: "Delete an issue link",
161
+ inputSchema: zodToJsonSchema(DeleteIssueLinkSchema),
162
+ },
163
+ {
164
+ name: "list_namespaces",
165
+ description: "List all namespaces available to the current user",
166
+ inputSchema: zodToJsonSchema(ListNamespacesSchema),
167
+ },
168
+ {
169
+ name: "get_namespace",
170
+ description: "Get details of a namespace by ID or path",
171
+ inputSchema: zodToJsonSchema(GetNamespaceSchema),
172
+ },
173
+ {
174
+ name: "verify_namespace",
175
+ description: "Verify if a namespace path exists",
176
+ inputSchema: zodToJsonSchema(VerifyNamespaceSchema),
177
+ },
178
+ {
179
+ name: "get_project",
180
+ description: "Get details of a specific project",
181
+ inputSchema: zodToJsonSchema(GetProjectSchema),
182
+ },
183
+ {
184
+ name: "list_projects",
185
+ description: "List projects accessible by the current user",
186
+ inputSchema: zodToJsonSchema(ListProjectsSchema),
187
+ },
188
+ {
189
+ name: "list_labels",
190
+ description: "List labels for a project",
191
+ inputSchema: zodToJsonSchema(ListLabelsSchema),
192
+ },
193
+ {
194
+ name: "get_label",
195
+ description: "Get a single label from a project",
196
+ inputSchema: zodToJsonSchema(GetLabelSchema),
197
+ },
198
+ {
199
+ name: "create_label",
200
+ description: "Create a new label in a project",
201
+ inputSchema: zodToJsonSchema(CreateLabelSchema),
202
+ },
203
+ {
204
+ name: "update_label",
205
+ description: "Update an existing label in a project",
206
+ inputSchema: zodToJsonSchema(UpdateLabelSchema),
207
+ },
208
+ {
209
+ name: "delete_label",
210
+ description: "Delete a label from a project",
211
+ inputSchema: zodToJsonSchema(DeleteLabelSchema),
212
+ },
213
+ {
214
+ name: "list_group_projects",
215
+ description: "List projects in a GitLab group with filtering options",
216
+ inputSchema: zodToJsonSchema(ListGroupProjectsSchema),
217
+ },
218
+ ];
219
+ // Define which tools are read-only
220
+ const readOnlyTools = [
221
+ "search_repositories",
222
+ "get_file_contents",
223
+ "get_merge_request",
224
+ "get_merge_request_diffs",
225
+ "list_merge_request_discussions",
226
+ "list_issues",
227
+ "get_issue",
228
+ "list_issue_links",
229
+ "get_issue_link",
230
+ "list_namespaces",
231
+ "get_namespace",
232
+ "verify_namespace",
233
+ "get_project",
234
+ "list_projects",
235
+ "list_labels",
236
+ "get_label",
237
+ "list_group_projects",
238
+ ];
239
+ /**
240
+ * Smart URL handling for GitLab API
241
+ *
242
+ * @param {string | undefined} url - Input GitLab API URL
243
+ * @returns {string} Normalized GitLab API URL with /api/v4 path
244
+ */
245
+ function normalizeGitLabApiUrl(url) {
246
+ if (!url) {
247
+ return "https://gitlab.com/api/v4";
248
+ }
249
+ // Remove trailing slash if present
250
+ let normalizedUrl = url.endsWith("/") ? url.slice(0, -1) : url;
251
+ // Check if URL already has /api/v4
252
+ if (!normalizedUrl.endsWith("/api/v4") &&
253
+ !normalizedUrl.endsWith("/api/v4/")) {
254
+ // Append /api/v4 if not already present
255
+ normalizedUrl = `${normalizedUrl}/api/v4`;
256
+ }
257
+ return normalizedUrl;
258
+ }
259
+ // Use the normalizeGitLabApiUrl function to handle various URL formats
260
+ const GITLAB_API_URL = normalizeGitLabApiUrl(process.env.GITLAB_API_URL || "");
261
+ if (!GITLAB_PERSONAL_ACCESS_TOKEN) {
262
+ console.error("GITLAB_PERSONAL_ACCESS_TOKEN environment variable is not set");
263
+ process.exit(1);
264
+ }
265
+ /**
266
+ * Common headers for GitLab API requests
267
+ * GitLab API 공통 헤더 (Common headers for GitLab API)
268
+ */
269
+ const DEFAULT_HEADERS = {
270
+ Accept: "application/json",
271
+ "Content-Type": "application/json",
272
+ Authorization: `Bearer ${GITLAB_PERSONAL_ACCESS_TOKEN}`,
273
+ };
274
+ /**
275
+ * Utility function for handling GitLab API errors
276
+ * API 에러 처리를 위한 유틸리티 함수 (Utility function for handling API errors)
277
+ *
278
+ * @param {import("node-fetch").Response} response - The response from GitLab API
279
+ * @throws {Error} Throws an error with response details if the request failed
280
+ */
281
+ async function handleGitLabError(response) {
282
+ if (!response.ok) {
283
+ const errorBody = await response.text();
284
+ // Check specifically for Rate Limit error
285
+ if (response.status === 403 &&
286
+ errorBody.includes("User API Key Rate limit exceeded")) {
287
+ console.error("GitLab API Rate Limit Exceeded:", errorBody);
288
+ console.log("User API Key Rate limit exceeded. Please try again later.");
289
+ throw new Error(`GitLab API Rate Limit Exceeded: ${errorBody}`);
290
+ }
291
+ else {
292
+ // Handle other API errors
293
+ throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
294
+ }
295
+ }
296
+ }
297
+ /**
298
+ * Create a fork of a GitLab project
299
+ * 프로젝트 포크 생성 (Create a project fork)
300
+ *
301
+ * @param {string} projectId - The ID or URL-encoded path of the project
302
+ * @param {string} [namespace] - The namespace to fork the project to
303
+ * @returns {Promise<GitLabFork>} The created fork
304
+ */
305
+ async function forkProject(projectId, namespace) {
306
+ // API 엔드포인트 URL 생성
307
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/fork`);
308
+ if (namespace) {
309
+ url.searchParams.append("namespace", namespace);
310
+ }
311
+ const response = await fetch(url.toString(), {
312
+ method: "POST",
313
+ headers: DEFAULT_HEADERS,
314
+ });
315
+ // 이미 존재하는 프로젝트인 경우 처리
316
+ if (response.status === 409) {
317
+ throw new Error("Project already exists in the target namespace");
318
+ }
319
+ await handleGitLabError(response);
320
+ const data = await response.json();
321
+ return GitLabForkSchema.parse(data);
322
+ }
323
+ /**
324
+ * Create a new branch in a GitLab project
325
+ * 새로운 브랜치 생성 (Create a new branch)
326
+ *
327
+ * @param {string} projectId - The ID or URL-encoded path of the project
328
+ * @param {z.infer<typeof CreateBranchOptionsSchema>} options - Branch creation options
329
+ * @returns {Promise<GitLabReference>} The created branch reference
330
+ */
331
+ async function createBranch(projectId, options) {
332
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/repository/branches`);
333
+ const response = await fetch(url.toString(), {
334
+ method: "POST",
335
+ headers: DEFAULT_HEADERS,
336
+ body: JSON.stringify({
337
+ branch: options.name,
338
+ ref: options.ref,
339
+ }),
340
+ });
341
+ await handleGitLabError(response);
342
+ return GitLabReferenceSchema.parse(await response.json());
343
+ }
344
+ /**
345
+ * Get the default branch for a GitLab project
346
+ * 프로젝트의 기본 브랜치 조회 (Get the default branch of a project)
347
+ *
348
+ * @param {string} projectId - The ID or URL-encoded path of the project
349
+ * @returns {Promise<string>} The name of the default branch
350
+ */
351
+ async function getDefaultBranchRef(projectId) {
352
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}`);
353
+ const response = await fetch(url.toString(), {
354
+ headers: DEFAULT_HEADERS,
355
+ });
356
+ await handleGitLabError(response);
357
+ const project = GitLabRepositorySchema.parse(await response.json());
358
+ return project.default_branch ?? "main";
359
+ }
360
+ /**
361
+ * Get the contents of a file from a GitLab project
362
+ * 파일 내용 조회 (Get file contents)
363
+ *
364
+ * @param {string} projectId - The ID or URL-encoded path of the project
365
+ * @param {string} filePath - The path of the file to get
366
+ * @param {string} [ref] - The name of the branch, tag or commit
367
+ * @returns {Promise<GitLabContent>} The file content
368
+ */
369
+ async function getFileContents(projectId, filePath, ref) {
370
+ const encodedPath = encodeURIComponent(filePath);
371
+ // ref가 없는 경우 default branch를 가져옴
372
+ if (!ref) {
373
+ ref = await getDefaultBranchRef(projectId);
374
+ }
375
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/repository/files/${encodedPath}`);
376
+ url.searchParams.append("ref", ref);
377
+ const response = await fetch(url.toString(), {
378
+ headers: DEFAULT_HEADERS,
379
+ });
380
+ // 파일을 찾을 수 없는 경우 처리
381
+ if (response.status === 404) {
382
+ throw new Error(`File not found: ${filePath}`);
383
+ }
384
+ await handleGitLabError(response);
385
+ const data = await response.json();
386
+ const parsedData = GitLabContentSchema.parse(data);
387
+ // Base64로 인코딩된 파일 내용을 UTF-8로 디코딩
388
+ if (!Array.isArray(parsedData) && parsedData.content) {
389
+ parsedData.content = Buffer.from(parsedData.content, "base64").toString("utf8");
390
+ parsedData.encoding = "utf8";
391
+ }
392
+ return parsedData;
393
+ }
394
+ /**
395
+ * Create a new issue in a GitLab project
396
+ * 이슈 생성 (Create an issue)
397
+ *
398
+ * @param {string} projectId - The ID or URL-encoded path of the project
399
+ * @param {z.infer<typeof CreateIssueOptionsSchema>} options - Issue creation options
400
+ * @returns {Promise<GitLabIssue>} The created issue
401
+ */
402
+ async function createIssue(projectId, options) {
403
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/issues`);
404
+ const response = await fetch(url.toString(), {
405
+ method: "POST",
406
+ headers: DEFAULT_HEADERS,
407
+ body: JSON.stringify({
408
+ title: options.title,
409
+ description: options.description,
410
+ assignee_ids: options.assignee_ids,
411
+ milestone_id: options.milestone_id,
412
+ labels: options.labels?.join(","),
413
+ }),
414
+ });
415
+ // 잘못된 요청 처리
416
+ if (response.status === 400) {
417
+ const errorBody = await response.text();
418
+ throw new Error(`Invalid request: ${errorBody}`);
419
+ }
420
+ await handleGitLabError(response);
421
+ const data = await response.json();
422
+ return GitLabIssueSchema.parse(data);
423
+ }
424
+ /**
425
+ * List issues in a GitLab project
426
+ * 프로젝트의 이슈 목록 조회
427
+ *
428
+ * @param {string} projectId - The ID or URL-encoded path of the project
429
+ * @param {Object} options - Options for listing issues
430
+ * @returns {Promise<GitLabIssue[]>} List of issues
431
+ */
432
+ async function listIssues(projectId, options = {}) {
433
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/issues`);
434
+ // Add all query parameters
435
+ Object.entries(options).forEach(([key, value]) => {
436
+ if (value !== undefined) {
437
+ if (key === "label_name" && Array.isArray(value)) {
438
+ // Handle array of labels
439
+ url.searchParams.append(key, value.join(","));
440
+ }
441
+ else {
442
+ url.searchParams.append(key, value.toString());
443
+ }
444
+ }
445
+ });
446
+ const response = await fetch(url.toString(), {
447
+ headers: DEFAULT_HEADERS,
448
+ });
449
+ await handleGitLabError(response);
450
+ const data = await response.json();
451
+ return z.array(GitLabIssueSchema).parse(data);
452
+ }
453
+ /**
454
+ * Get a single issue from a GitLab project
455
+ * 단일 이슈 조회
456
+ *
457
+ * @param {string} projectId - The ID or URL-encoded path of the project
458
+ * @param {number} issueIid - The internal ID of the project issue
459
+ * @returns {Promise<GitLabIssue>} The issue
460
+ */
461
+ async function getIssue(projectId, issueIid) {
462
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/issues/${issueIid}`);
463
+ const response = await fetch(url.toString(), {
464
+ headers: DEFAULT_HEADERS,
465
+ });
466
+ await handleGitLabError(response);
467
+ const data = await response.json();
468
+ return GitLabIssueSchema.parse(data);
469
+ }
470
+ /**
471
+ * Update an issue in a GitLab project
472
+ * 이슈 업데이트
473
+ *
474
+ * @param {string} projectId - The ID or URL-encoded path of the project
475
+ * @param {number} issueIid - The internal ID of the project issue
476
+ * @param {Object} options - Update options for the issue
477
+ * @returns {Promise<GitLabIssue>} The updated issue
478
+ */
479
+ async function updateIssue(projectId, issueIid, options) {
480
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/issues/${issueIid}`);
481
+ // Convert labels array to comma-separated string if present
482
+ const body = { ...options };
483
+ if (body.labels && Array.isArray(body.labels)) {
484
+ body.labels = body.labels.join(",");
485
+ }
486
+ const response = await fetch(url.toString(), {
487
+ method: "PUT",
488
+ headers: DEFAULT_HEADERS,
489
+ body: JSON.stringify(body),
490
+ });
491
+ await handleGitLabError(response);
492
+ const data = await response.json();
493
+ return GitLabIssueSchema.parse(data);
494
+ }
495
+ /**
496
+ * Delete an issue from a GitLab project
497
+ * 이슈 삭제
498
+ *
499
+ * @param {string} projectId - The ID or URL-encoded path of the project
500
+ * @param {number} issueIid - The internal ID of the project issue
501
+ * @returns {Promise<void>}
502
+ */
503
+ async function deleteIssue(projectId, issueIid) {
504
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/issues/${issueIid}`);
505
+ const response = await fetch(url.toString(), {
506
+ method: "DELETE",
507
+ headers: DEFAULT_HEADERS,
508
+ });
509
+ await handleGitLabError(response);
510
+ }
511
+ /**
512
+ * List all issue links for a specific issue
513
+ * 이슈 관계 목록 조회
514
+ *
515
+ * @param {string} projectId - The ID or URL-encoded path of the project
516
+ * @param {number} issueIid - The internal ID of the project issue
517
+ * @returns {Promise<GitLabIssueWithLinkDetails[]>} List of issues with link details
518
+ */
519
+ async function listIssueLinks(projectId, issueIid) {
520
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/issues/${issueIid}/links`);
521
+ const response = await fetch(url.toString(), {
522
+ headers: DEFAULT_HEADERS,
523
+ });
524
+ await handleGitLabError(response);
525
+ const data = await response.json();
526
+ return z.array(GitLabIssueWithLinkDetailsSchema).parse(data);
527
+ }
528
+ /**
529
+ * Get a specific issue link
530
+ * 특정 이슈 관계 조회
531
+ *
532
+ * @param {string} projectId - The ID or URL-encoded path of the project
533
+ * @param {number} issueIid - The internal ID of the project issue
534
+ * @param {number} issueLinkId - The ID of the issue link
535
+ * @returns {Promise<GitLabIssueLink>} The issue link
536
+ */
537
+ async function getIssueLink(projectId, issueIid, issueLinkId) {
538
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/issues/${issueIid}/links/${issueLinkId}`);
539
+ const response = await fetch(url.toString(), {
540
+ headers: DEFAULT_HEADERS,
541
+ });
542
+ await handleGitLabError(response);
543
+ const data = await response.json();
544
+ return GitLabIssueLinkSchema.parse(data);
545
+ }
546
+ /**
547
+ * Create an issue link between two issues
548
+ * 이슈 관계 생성
549
+ *
550
+ * @param {string} projectId - The ID or URL-encoded path of the project
551
+ * @param {number} issueIid - The internal ID of the project issue
552
+ * @param {string} targetProjectId - The ID or URL-encoded path of the target project
553
+ * @param {number} targetIssueIid - The internal ID of the target project issue
554
+ * @param {string} linkType - The type of the relation (relates_to, blocks, is_blocked_by)
555
+ * @returns {Promise<GitLabIssueLink>} The created issue link
556
+ */
557
+ async function createIssueLink(projectId, issueIid, targetProjectId, targetIssueIid, linkType = "relates_to") {
558
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/issues/${issueIid}/links`);
559
+ const response = await fetch(url.toString(), {
560
+ method: "POST",
561
+ headers: DEFAULT_HEADERS,
562
+ body: JSON.stringify({
563
+ target_project_id: targetProjectId,
564
+ target_issue_iid: targetIssueIid,
565
+ link_type: linkType,
566
+ }),
567
+ });
568
+ await handleGitLabError(response);
569
+ const data = await response.json();
570
+ return GitLabIssueLinkSchema.parse(data);
571
+ }
572
+ /**
573
+ * Delete an issue link
574
+ * 이슈 관계 삭제
575
+ *
576
+ * @param {string} projectId - The ID or URL-encoded path of the project
577
+ * @param {number} issueIid - The internal ID of the project issue
578
+ * @param {number} issueLinkId - The ID of the issue link
579
+ * @returns {Promise<void>}
580
+ */
581
+ async function deleteIssueLink(projectId, issueIid, issueLinkId) {
582
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/issues/${issueIid}/links/${issueLinkId}`);
583
+ const response = await fetch(url.toString(), {
584
+ method: "DELETE",
585
+ headers: DEFAULT_HEADERS,
586
+ });
587
+ await handleGitLabError(response);
588
+ }
589
+ /**
590
+ * Create a new merge request in a GitLab project
591
+ * 병합 요청 생성
592
+ *
593
+ * @param {string} projectId - The ID or URL-encoded path of the project
594
+ * @param {z.infer<typeof CreateMergeRequestOptionsSchema>} options - Merge request creation options
595
+ * @returns {Promise<GitLabMergeRequest>} The created merge request
596
+ */
597
+ async function createMergeRequest(projectId, options) {
598
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/merge_requests`);
599
+ const response = await fetch(url.toString(), {
600
+ method: "POST",
601
+ headers: {
602
+ Accept: "application/json",
603
+ "Content-Type": "application/json",
604
+ Authorization: `Bearer ${GITLAB_PERSONAL_ACCESS_TOKEN}`,
605
+ },
606
+ body: JSON.stringify({
607
+ title: options.title,
608
+ description: options.description,
609
+ source_branch: options.source_branch,
610
+ target_branch: options.target_branch,
611
+ allow_collaboration: options.allow_collaboration,
612
+ draft: options.draft,
613
+ }),
614
+ });
615
+ if (response.status === 400) {
616
+ const errorBody = await response.text();
617
+ throw new Error(`Invalid request: ${errorBody}`);
618
+ }
619
+ if (!response.ok) {
620
+ const errorBody = await response.text();
621
+ throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
622
+ }
623
+ const data = await response.json();
624
+ return GitLabMergeRequestSchema.parse(data);
625
+ }
626
+ /**
627
+ * List merge request discussion items
628
+ * 병합 요청 토론 목록 조회
629
+ *
630
+ * @param {string} projectId - The ID or URL-encoded path of the project
631
+ * @param {number} mergeRequestIid - The IID of a merge request
632
+ * @returns {Promise<GitLabDiscussion[]>} List of discussions
633
+ */
634
+ async function listMergeRequestDiscussions(projectId, mergeRequestIid) {
635
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/merge_requests/${mergeRequestIid}/discussions`);
636
+ const response = await fetch(url.toString(), {
637
+ headers: DEFAULT_HEADERS,
638
+ });
639
+ await handleGitLabError(response);
640
+ const data = await response.json();
641
+ // Ensure the response is parsed as an array of discussions
642
+ return z.array(GitLabDiscussionSchema).parse(data);
643
+ }
644
+ /**
645
+ * Modify an existing merge request thread note
646
+ * 병합 요청 토론 노트 수정
647
+ *
648
+ * @param {string} projectId - The ID or URL-encoded path of the project
649
+ * @param {number} mergeRequestIid - The IID of a merge request
650
+ * @param {string} discussionId - The ID of a thread
651
+ * @param {number} noteId - The ID of a thread note
652
+ * @param {string} body - The new content of the note
653
+ * @param {boolean} [resolved] - Resolve/unresolve state
654
+ * @returns {Promise<GitLabDiscussionNote>} The updated note
655
+ */
656
+ async function updateMergeRequestNote(projectId, mergeRequestIid, discussionId, noteId, body, resolved) {
657
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/merge_requests/${mergeRequestIid}/discussions/${discussionId}/notes/${noteId}`);
658
+ const payload = { body };
659
+ if (resolved !== undefined) {
660
+ payload.resolved = resolved;
661
+ }
662
+ const response = await fetch(url.toString(), {
663
+ method: "PUT",
664
+ headers: DEFAULT_HEADERS,
665
+ body: JSON.stringify(payload),
666
+ });
667
+ await handleGitLabError(response);
668
+ const data = await response.json();
669
+ return GitLabDiscussionNoteSchema.parse(data);
670
+ }
671
+ /**
672
+ * Create or update a file in a GitLab project
673
+ * 파일 생성 또는 업데이트
674
+ *
675
+ * @param {string} projectId - The ID or URL-encoded path of the project
676
+ * @param {string} filePath - The path of the file to create or update
677
+ * @param {string} content - The content of the file
678
+ * @param {string} commitMessage - The commit message
679
+ * @param {string} branch - The branch name
680
+ * @param {string} [previousPath] - The previous path of the file in case of rename
681
+ * @returns {Promise<GitLabCreateUpdateFileResponse>} The file update response
682
+ */
683
+ async function createOrUpdateFile(projectId, filePath, content, commitMessage, branch, previousPath, last_commit_id, commit_id) {
684
+ const encodedPath = encodeURIComponent(filePath);
685
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/repository/files/${encodedPath}`);
686
+ const body = {
687
+ branch,
688
+ content,
689
+ commit_message: commitMessage,
690
+ encoding: "text",
691
+ ...(previousPath ? { previous_path: previousPath } : {}),
692
+ };
693
+ // Check if file exists
694
+ let method = "POST";
695
+ try {
696
+ // Get file contents to check existence and retrieve commit IDs
697
+ const fileData = await getFileContents(projectId, filePath, branch);
698
+ method = "PUT";
699
+ // If fileData is not an array, it's a file content object with commit IDs
700
+ if (!Array.isArray(fileData)) {
701
+ // Use commit IDs from the file data if not provided in parameters
702
+ if (!commit_id && fileData.commit_id) {
703
+ body.commit_id = fileData.commit_id;
704
+ }
705
+ else if (commit_id) {
706
+ body.commit_id = commit_id;
707
+ }
708
+ if (!last_commit_id && fileData.last_commit_id) {
709
+ body.last_commit_id = fileData.last_commit_id;
710
+ }
711
+ else if (last_commit_id) {
712
+ body.last_commit_id = last_commit_id;
713
+ }
714
+ }
715
+ }
716
+ catch (error) {
717
+ if (!(error instanceof Error && error.message.includes("File not found"))) {
718
+ throw error;
719
+ }
720
+ // File doesn't exist, use POST - no need for commit IDs for new files
721
+ // But still use any provided as parameters if they exist
722
+ if (commit_id) {
723
+ body.commit_id = commit_id;
724
+ }
725
+ if (last_commit_id) {
726
+ body.last_commit_id = last_commit_id;
727
+ }
728
+ }
729
+ const response = await fetch(url.toString(), {
730
+ method,
731
+ headers: {
732
+ Accept: "application/json",
733
+ "Content-Type": "application/json",
734
+ Authorization: `Bearer ${GITLAB_PERSONAL_ACCESS_TOKEN}`,
735
+ },
736
+ body: JSON.stringify(body),
737
+ });
738
+ if (!response.ok) {
739
+ const errorBody = await response.text();
740
+ throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
741
+ }
742
+ const data = await response.json();
743
+ return GitLabCreateUpdateFileResponseSchema.parse(data);
744
+ }
745
+ /**
746
+ * Create a tree structure in a GitLab project repository
747
+ * 저장소에 트리 구조 생성
748
+ *
749
+ * @param {string} projectId - The ID or URL-encoded path of the project
750
+ * @param {FileOperation[]} files - Array of file operations
751
+ * @param {string} [ref] - The name of the branch, tag or commit
752
+ * @returns {Promise<GitLabTree>} The created tree
753
+ */
754
+ async function createTree(projectId, files, ref) {
755
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/repository/tree`);
756
+ if (ref) {
757
+ url.searchParams.append("ref", ref);
758
+ }
759
+ const response = await fetch(url.toString(), {
760
+ method: "POST",
761
+ headers: {
762
+ Accept: "application/json",
763
+ "Content-Type": "application/json",
764
+ Authorization: `Bearer ${GITLAB_PERSONAL_ACCESS_TOKEN}`,
765
+ },
766
+ body: JSON.stringify({
767
+ files: files.map((file) => ({
768
+ file_path: file.path,
769
+ content: file.content,
770
+ encoding: "text",
771
+ })),
772
+ }),
773
+ });
774
+ if (response.status === 400) {
775
+ const errorBody = await response.text();
776
+ throw new Error(`Invalid request: ${errorBody}`);
777
+ }
778
+ if (!response.ok) {
779
+ const errorBody = await response.text();
780
+ throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
781
+ }
782
+ const data = await response.json();
783
+ return GitLabTreeSchema.parse(data);
784
+ }
785
+ /**
786
+ * Create a commit in a GitLab project repository
787
+ * 저장소에 커밋 생성
788
+ *
789
+ * @param {string} projectId - The ID or URL-encoded path of the project
790
+ * @param {string} message - The commit message
791
+ * @param {string} branch - The branch name
792
+ * @param {FileOperation[]} actions - Array of file operations for the commit
793
+ * @returns {Promise<GitLabCommit>} The created commit
794
+ */
795
+ async function createCommit(projectId, message, branch, actions) {
796
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/repository/commits`);
797
+ const response = await fetch(url.toString(), {
798
+ method: "POST",
799
+ headers: {
800
+ Accept: "application/json",
801
+ "Content-Type": "application/json",
802
+ Authorization: `Bearer ${GITLAB_PERSONAL_ACCESS_TOKEN}`,
803
+ },
804
+ body: JSON.stringify({
805
+ branch,
806
+ commit_message: message,
807
+ actions: actions.map((action) => ({
808
+ action: "create",
809
+ file_path: action.path,
810
+ content: action.content,
811
+ encoding: "text",
812
+ })),
813
+ }),
814
+ });
815
+ if (response.status === 400) {
816
+ const errorBody = await response.text();
817
+ throw new Error(`Invalid request: ${errorBody}`);
818
+ }
819
+ if (!response.ok) {
820
+ const errorBody = await response.text();
821
+ throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
822
+ }
823
+ const data = await response.json();
824
+ return GitLabCommitSchema.parse(data);
825
+ }
826
+ /**
827
+ * Search for GitLab projects
828
+ * 프로젝트 검색
829
+ *
830
+ * @param {string} query - The search query
831
+ * @param {number} [page=1] - The page number
832
+ * @param {number} [perPage=20] - Number of items per page
833
+ * @returns {Promise<GitLabSearchResponse>} The search results
834
+ */
835
+ async function searchProjects(query, page = 1, perPage = 20) {
836
+ const url = new URL(`${GITLAB_API_URL}/projects`);
837
+ url.searchParams.append("search", query);
838
+ url.searchParams.append("page", page.toString());
839
+ url.searchParams.append("per_page", perPage.toString());
840
+ url.searchParams.append("order_by", "id");
841
+ url.searchParams.append("sort", "desc");
842
+ const response = await fetch(url.toString(), {
843
+ headers: {
844
+ Accept: "application/json",
845
+ "Content-Type": "application/json",
846
+ Authorization: `Bearer ${GITLAB_PERSONAL_ACCESS_TOKEN}`,
847
+ },
848
+ });
849
+ if (!response.ok) {
850
+ const errorBody = await response.text();
851
+ throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
852
+ }
853
+ const projects = (await response.json());
854
+ const totalCount = response.headers.get("x-total");
855
+ const totalPages = response.headers.get("x-total-pages");
856
+ // GitLab API doesn't return these headers for results > 10,000
857
+ const count = totalCount ? parseInt(totalCount) : projects.length;
858
+ return GitLabSearchResponseSchema.parse({
859
+ count,
860
+ total_pages: totalPages ? parseInt(totalPages) : Math.ceil(count / perPage),
861
+ current_page: page,
862
+ items: projects,
863
+ });
864
+ }
865
+ /**
866
+ * Create a new GitLab repository
867
+ * 새 저장소 생성
868
+ *
869
+ * @param {z.infer<typeof CreateRepositoryOptionsSchema>} options - Repository creation options
870
+ * @returns {Promise<GitLabRepository>} The created repository
871
+ */
872
+ async function createRepository(options) {
873
+ const response = await fetch(`${GITLAB_API_URL}/projects`, {
874
+ method: "POST",
875
+ headers: {
876
+ Accept: "application/json",
877
+ "Content-Type": "application/json",
878
+ Authorization: `Bearer ${GITLAB_PERSONAL_ACCESS_TOKEN}`,
879
+ },
880
+ body: JSON.stringify({
881
+ name: options.name,
882
+ description: options.description,
883
+ visibility: options.visibility,
884
+ initialize_with_readme: options.initialize_with_readme,
885
+ default_branch: "main",
886
+ path: options.name.toLowerCase().replace(/\s+/g, "-"),
887
+ }),
888
+ });
889
+ if (!response.ok) {
890
+ const errorBody = await response.text();
891
+ throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
892
+ }
893
+ const data = await response.json();
894
+ return GitLabRepositorySchema.parse(data);
895
+ }
896
+ /**
897
+ * Get merge request details
898
+ * MR 조회 함수 (Function to retrieve merge request)
899
+ *
900
+ * @param {string} projectId - The ID or URL-encoded path of the project
901
+ * @param {number} mergeRequestIid - The internal ID of the merge request
902
+ * @returns {Promise<GitLabMergeRequest>} The merge request details
903
+ */
904
+ async function getMergeRequest(projectId, mergeRequestIid) {
905
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/merge_requests/${mergeRequestIid}`);
906
+ const response = await fetch(url.toString(), {
907
+ headers: DEFAULT_HEADERS,
908
+ });
909
+ await handleGitLabError(response);
910
+ return GitLabMergeRequestSchema.parse(await response.json());
911
+ }
912
+ /**
913
+ * Get merge request changes/diffs
914
+ * MR 변경사항 조회 함수 (Function to retrieve merge request changes)
915
+ *
916
+ * @param {string} projectId - The ID or URL-encoded path of the project
917
+ * @param {number} mergeRequestIid - The internal ID of the merge request
918
+ * @param {string} [view] - The view type for the diff (inline or parallel)
919
+ * @returns {Promise<GitLabMergeRequestDiff[]>} The merge request diffs
920
+ */
921
+ async function getMergeRequestDiffs(projectId, mergeRequestIid, view) {
922
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/merge_requests/${mergeRequestIid}/changes`);
923
+ if (view) {
924
+ url.searchParams.append("view", view);
925
+ }
926
+ const response = await fetch(url.toString(), {
927
+ headers: DEFAULT_HEADERS,
928
+ });
929
+ await handleGitLabError(response);
930
+ const data = (await response.json());
931
+ return z.array(GitLabMergeRequestDiffSchema).parse(data.changes);
932
+ }
933
+ /**
934
+ * Update a merge request
935
+ * MR 업데이트 함수 (Function to update merge request)
936
+ *
937
+ * @param {string} projectId - The ID or URL-encoded path of the project
938
+ * @param {number} mergeRequestIid - The internal ID of the merge request
939
+ * @param {Object} options - The update options
940
+ * @returns {Promise<GitLabMergeRequest>} The updated merge request
941
+ */
942
+ async function updateMergeRequest(projectId, mergeRequestIid, options) {
943
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/merge_requests/${mergeRequestIid}`);
944
+ const response = await fetch(url.toString(), {
945
+ method: "PUT",
946
+ headers: DEFAULT_HEADERS,
947
+ body: JSON.stringify(options),
948
+ });
949
+ await handleGitLabError(response);
950
+ return GitLabMergeRequestSchema.parse(await response.json());
951
+ }
952
+ /**
953
+ * Create a new note (comment) on an issue or merge request
954
+ * 📦 새로운 함수: createNote - 이슈 또는 병합 요청에 노트(댓글)를 추가하는 함수
955
+ * (New function: createNote - Function to add a note (comment) to an issue or merge request)
956
+ *
957
+ * @param {string} projectId - The ID or URL-encoded path of the project
958
+ * @param {"issue" | "merge_request"} noteableType - The type of the item to add a note to (issue or merge_request)
959
+ * @param {number} noteableIid - The internal ID of the issue or merge request
960
+ * @param {string} body - The content of the note
961
+ * @returns {Promise<any>} The created note
962
+ */
963
+ async function createNote(projectId, noteableType, // 'issue' 또는 'merge_request' 타입 명시
964
+ noteableIid, body) {
965
+ // ⚙️ 응답 타입은 GitLab API 문서에 따라 조정 가능
966
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/${noteableType}s/${noteableIid}/notes` // Using plural form (issues/merge_requests) as per GitLab API documentation
967
+ );
968
+ const response = await fetch(url.toString(), {
969
+ method: "POST",
970
+ headers: DEFAULT_HEADERS,
971
+ body: JSON.stringify({ body }),
972
+ });
973
+ if (!response.ok) {
974
+ const errorText = await response.text();
975
+ throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
976
+ }
977
+ return await response.json();
978
+ }
979
+ /**
980
+ * List all namespaces
981
+ * 사용 가능한 모든 네임스페이스 목록 조회
982
+ *
983
+ * @param {Object} options - Options for listing namespaces
984
+ * @param {string} [options.search] - Search query to filter namespaces
985
+ * @param {boolean} [options.owned_only] - Only return namespaces owned by the authenticated user
986
+ * @param {boolean} [options.top_level_only] - Only return top-level namespaces
987
+ * @returns {Promise<GitLabNamespace[]>} List of namespaces
988
+ */
989
+ async function listNamespaces(options) {
990
+ const url = new URL(`${GITLAB_API_URL}/namespaces`);
991
+ if (options.search) {
992
+ url.searchParams.append("search", options.search);
993
+ }
994
+ if (options.owned_only) {
995
+ url.searchParams.append("owned_only", "true");
996
+ }
997
+ if (options.top_level_only) {
998
+ url.searchParams.append("top_level_only", "true");
999
+ }
1000
+ const response = await fetch(url.toString(), {
1001
+ headers: DEFAULT_HEADERS,
1002
+ });
1003
+ await handleGitLabError(response);
1004
+ const data = await response.json();
1005
+ return z.array(GitLabNamespaceSchema).parse(data);
1006
+ }
1007
+ /**
1008
+ * Get details on a namespace
1009
+ * 네임스페이스 상세 정보 조회
1010
+ *
1011
+ * @param {string} id - The ID or URL-encoded path of the namespace
1012
+ * @returns {Promise<GitLabNamespace>} The namespace details
1013
+ */
1014
+ async function getNamespace(id) {
1015
+ const url = new URL(`${GITLAB_API_URL}/namespaces/${encodeURIComponent(id)}`);
1016
+ const response = await fetch(url.toString(), {
1017
+ headers: DEFAULT_HEADERS,
1018
+ });
1019
+ await handleGitLabError(response);
1020
+ const data = await response.json();
1021
+ return GitLabNamespaceSchema.parse(data);
1022
+ }
1023
+ /**
1024
+ * Verify if a namespace exists
1025
+ * 네임스페이스 존재 여부 확인
1026
+ *
1027
+ * @param {string} namespacePath - The path of the namespace to check
1028
+ * @param {number} [parentId] - The ID of the parent namespace
1029
+ * @returns {Promise<GitLabNamespaceExistsResponse>} The verification result
1030
+ */
1031
+ async function verifyNamespaceExistence(namespacePath, parentId) {
1032
+ const url = new URL(`${GITLAB_API_URL}/namespaces/${encodeURIComponent(namespacePath)}/exists`);
1033
+ if (parentId) {
1034
+ url.searchParams.append("parent_id", parentId.toString());
1035
+ }
1036
+ const response = await fetch(url.toString(), {
1037
+ headers: DEFAULT_HEADERS,
1038
+ });
1039
+ await handleGitLabError(response);
1040
+ const data = await response.json();
1041
+ return GitLabNamespaceExistsResponseSchema.parse(data);
1042
+ }
1043
+ /**
1044
+ * Get a single project
1045
+ * 단일 프로젝트 조회
1046
+ *
1047
+ * @param {string} projectId - The ID or URL-encoded path of the project
1048
+ * @param {Object} options - Options for getting project details
1049
+ * @param {boolean} [options.license] - Include project license data
1050
+ * @param {boolean} [options.statistics] - Include project statistics
1051
+ * @param {boolean} [options.with_custom_attributes] - Include custom attributes in response
1052
+ * @returns {Promise<GitLabProject>} Project details
1053
+ */
1054
+ async function getProject(projectId, options = {}) {
1055
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}`);
1056
+ if (options.license) {
1057
+ url.searchParams.append("license", "true");
1058
+ }
1059
+ if (options.statistics) {
1060
+ url.searchParams.append("statistics", "true");
1061
+ }
1062
+ if (options.with_custom_attributes) {
1063
+ url.searchParams.append("with_custom_attributes", "true");
1064
+ }
1065
+ const response = await fetch(url.toString(), {
1066
+ headers: DEFAULT_HEADERS,
1067
+ });
1068
+ await handleGitLabError(response);
1069
+ const data = await response.json();
1070
+ return GitLabRepositorySchema.parse(data);
1071
+ }
1072
+ /**
1073
+ * List projects
1074
+ * 프로젝트 목록 조회
1075
+ *
1076
+ * @param {Object} options - Options for listing projects
1077
+ * @returns {Promise<GitLabProject[]>} List of projects
1078
+ */
1079
+ async function listProjects(options = {}) {
1080
+ // Construct the query parameters
1081
+ const params = new URLSearchParams();
1082
+ for (const [key, value] of Object.entries(options)) {
1083
+ if (value !== undefined && value !== null) {
1084
+ if (typeof value === "boolean") {
1085
+ params.append(key, value ? "true" : "false");
1086
+ }
1087
+ else {
1088
+ params.append(key, String(value));
1089
+ }
1090
+ }
1091
+ }
1092
+ // Make the API request
1093
+ const response = await fetch(`${GITLAB_API_URL}/projects?${params.toString()}`, {
1094
+ method: "GET",
1095
+ headers: DEFAULT_HEADERS,
1096
+ });
1097
+ // Handle errors
1098
+ await handleGitLabError(response);
1099
+ // Parse and return the data
1100
+ const data = await response.json();
1101
+ return z.array(GitLabProjectSchema).parse(data);
1102
+ }
1103
+ /**
1104
+ * List labels for a project
1105
+ *
1106
+ * @param projectId The ID or URL-encoded path of the project
1107
+ * @param options Optional parameters for listing labels
1108
+ * @returns Array of GitLab labels
1109
+ */
1110
+ async function listLabels(projectId, options = {}) {
1111
+ // Construct the URL with project path
1112
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/labels`);
1113
+ // Add query parameters
1114
+ Object.entries(options).forEach(([key, value]) => {
1115
+ if (value !== undefined) {
1116
+ if (typeof value === "boolean") {
1117
+ url.searchParams.append(key, value ? "true" : "false");
1118
+ }
1119
+ else {
1120
+ url.searchParams.append(key, String(value));
1121
+ }
1122
+ }
1123
+ });
1124
+ // Make the API request
1125
+ const response = await fetch(url.toString(), {
1126
+ headers: DEFAULT_HEADERS,
1127
+ });
1128
+ // Handle errors
1129
+ await handleGitLabError(response);
1130
+ // Parse and return the data
1131
+ const data = await response.json();
1132
+ return data;
1133
+ }
1134
+ /**
1135
+ * Get a single label from a project
1136
+ *
1137
+ * @param projectId The ID or URL-encoded path of the project
1138
+ * @param labelId The ID or name of the label
1139
+ * @param includeAncestorGroups Whether to include ancestor groups
1140
+ * @returns GitLab label
1141
+ */
1142
+ async function getLabel(projectId, labelId, includeAncestorGroups) {
1143
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/labels/${encodeURIComponent(String(labelId))}`);
1144
+ // Add query parameters
1145
+ if (includeAncestorGroups !== undefined) {
1146
+ url.searchParams.append("include_ancestor_groups", includeAncestorGroups ? "true" : "false");
1147
+ }
1148
+ // Make the API request
1149
+ const response = await fetch(url.toString(), {
1150
+ headers: DEFAULT_HEADERS,
1151
+ });
1152
+ // Handle errors
1153
+ await handleGitLabError(response);
1154
+ // Parse and return the data
1155
+ const data = await response.json();
1156
+ return data;
1157
+ }
1158
+ /**
1159
+ * Create a new label in a project
1160
+ *
1161
+ * @param projectId The ID or URL-encoded path of the project
1162
+ * @param options Options for creating the label
1163
+ * @returns Created GitLab label
1164
+ */
1165
+ async function createLabel(projectId, options) {
1166
+ // Make the API request
1167
+ const response = await fetch(`${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/labels`, {
1168
+ method: "POST",
1169
+ headers: DEFAULT_HEADERS,
1170
+ body: JSON.stringify(options),
1171
+ });
1172
+ // Handle errors
1173
+ await handleGitLabError(response);
1174
+ // Parse and return the data
1175
+ const data = await response.json();
1176
+ return data;
1177
+ }
1178
+ /**
1179
+ * Update an existing label in a project
1180
+ *
1181
+ * @param projectId The ID or URL-encoded path of the project
1182
+ * @param labelId The ID or name of the label to update
1183
+ * @param options Options for updating the label
1184
+ * @returns Updated GitLab label
1185
+ */
1186
+ async function updateLabel(projectId, labelId, options) {
1187
+ // Make the API request
1188
+ const response = await fetch(`${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/labels/${encodeURIComponent(String(labelId))}`, {
1189
+ method: "PUT",
1190
+ headers: DEFAULT_HEADERS,
1191
+ body: JSON.stringify(options),
1192
+ });
1193
+ // Handle errors
1194
+ await handleGitLabError(response);
1195
+ // Parse and return the data
1196
+ const data = await response.json();
1197
+ return data;
1198
+ }
1199
+ /**
1200
+ * Delete a label from a project
1201
+ *
1202
+ * @param projectId The ID or URL-encoded path of the project
1203
+ * @param labelId The ID or name of the label to delete
1204
+ */
1205
+ async function deleteLabel(projectId, labelId) {
1206
+ // Make the API request
1207
+ const response = await fetch(`${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/labels/${encodeURIComponent(String(labelId))}`, {
1208
+ method: "DELETE",
1209
+ headers: DEFAULT_HEADERS,
1210
+ });
1211
+ // Handle errors
1212
+ await handleGitLabError(response);
1213
+ }
1214
+ /**
1215
+ * List all projects in a GitLab group
1216
+ *
1217
+ * @param {z.infer<typeof ListGroupProjectsSchema>} options - Options for listing group projects
1218
+ * @returns {Promise<GitLabProject[]>} Array of projects in the group
1219
+ */
1220
+ async function listGroupProjects(options) {
1221
+ const url = new URL(`${GITLAB_API_URL}/groups/${encodeURIComponent(options.group_id)}/projects`);
1222
+ // Add optional parameters to URL
1223
+ if (options.include_subgroups)
1224
+ url.searchParams.append("include_subgroups", "true");
1225
+ if (options.search)
1226
+ url.searchParams.append("search", options.search);
1227
+ if (options.order_by)
1228
+ url.searchParams.append("order_by", options.order_by);
1229
+ if (options.sort)
1230
+ url.searchParams.append("sort", options.sort);
1231
+ if (options.page)
1232
+ url.searchParams.append("page", options.page.toString());
1233
+ if (options.per_page)
1234
+ url.searchParams.append("per_page", options.per_page.toString());
1235
+ if (options.archived !== undefined)
1236
+ url.searchParams.append("archived", options.archived.toString());
1237
+ if (options.visibility)
1238
+ url.searchParams.append("visibility", options.visibility);
1239
+ if (options.with_issues_enabled !== undefined)
1240
+ url.searchParams.append("with_issues_enabled", options.with_issues_enabled.toString());
1241
+ if (options.with_merge_requests_enabled !== undefined)
1242
+ url.searchParams.append("with_merge_requests_enabled", options.with_merge_requests_enabled.toString());
1243
+ if (options.min_access_level !== undefined)
1244
+ url.searchParams.append("min_access_level", options.min_access_level.toString());
1245
+ if (options.with_programming_language)
1246
+ url.searchParams.append("with_programming_language", options.with_programming_language);
1247
+ if (options.starred !== undefined)
1248
+ url.searchParams.append("starred", options.starred.toString());
1249
+ if (options.statistics !== undefined)
1250
+ url.searchParams.append("statistics", options.statistics.toString());
1251
+ if (options.with_custom_attributes !== undefined)
1252
+ url.searchParams.append("with_custom_attributes", options.with_custom_attributes.toString());
1253
+ if (options.with_security_reports !== undefined)
1254
+ url.searchParams.append("with_security_reports", options.with_security_reports.toString());
1255
+ const response = await fetch(url.toString(), {
1256
+ method: "GET",
1257
+ headers: DEFAULT_HEADERS,
1258
+ });
1259
+ await handleGitLabError(response);
1260
+ const projects = await response.json();
1261
+ return GitLabProjectSchema.array().parse(projects);
1262
+ }
1263
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
1264
+ // If read-only mode is enabled, filter out write operations
1265
+ const tools = GITLAB_READ_ONLY_MODE
1266
+ ? allTools.filter((tool) => readOnlyTools.includes(tool.name))
1267
+ : allTools;
1268
+ return {
1269
+ tools,
1270
+ };
1271
+ });
1272
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1273
+ try {
1274
+ if (!request.params.arguments) {
1275
+ throw new Error("Arguments are required");
1276
+ }
1277
+ switch (request.params.name) {
1278
+ case "fork_repository": {
1279
+ const forkArgs = ForkRepositorySchema.parse(request.params.arguments);
1280
+ try {
1281
+ const forkedProject = await forkProject(forkArgs.project_id, forkArgs.namespace);
1282
+ return {
1283
+ content: [
1284
+ { type: "text", text: JSON.stringify(forkedProject, null, 2) },
1285
+ ],
1286
+ };
1287
+ }
1288
+ catch (forkError) {
1289
+ console.error("Error forking repository:", forkError);
1290
+ let forkErrorMessage = "Failed to fork repository";
1291
+ if (forkError instanceof Error) {
1292
+ forkErrorMessage = `${forkErrorMessage}: ${forkError.message}`;
1293
+ }
1294
+ return {
1295
+ content: [
1296
+ {
1297
+ type: "text",
1298
+ text: JSON.stringify({ error: forkErrorMessage }, null, 2),
1299
+ },
1300
+ ],
1301
+ };
1302
+ }
1303
+ }
1304
+ case "create_branch": {
1305
+ const args = CreateBranchSchema.parse(request.params.arguments);
1306
+ let ref = args.ref;
1307
+ if (!ref) {
1308
+ ref = await getDefaultBranchRef(args.project_id);
1309
+ }
1310
+ const branch = await createBranch(args.project_id, {
1311
+ name: args.branch,
1312
+ ref,
1313
+ });
1314
+ return {
1315
+ content: [{ type: "text", text: JSON.stringify(branch, null, 2) }],
1316
+ };
1317
+ }
1318
+ case "search_repositories": {
1319
+ const args = SearchRepositoriesSchema.parse(request.params.arguments);
1320
+ const results = await searchProjects(args.search, args.page, args.per_page);
1321
+ return {
1322
+ content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
1323
+ };
1324
+ }
1325
+ case "create_repository": {
1326
+ const args = CreateRepositorySchema.parse(request.params.arguments);
1327
+ const repository = await createRepository(args);
1328
+ return {
1329
+ content: [
1330
+ { type: "text", text: JSON.stringify(repository, null, 2) },
1331
+ ],
1332
+ };
1333
+ }
1334
+ case "get_file_contents": {
1335
+ const args = GetFileContentsSchema.parse(request.params.arguments);
1336
+ const contents = await getFileContents(args.project_id, args.file_path, args.ref);
1337
+ return {
1338
+ content: [{ type: "text", text: JSON.stringify(contents, null, 2) }],
1339
+ };
1340
+ }
1341
+ case "create_or_update_file": {
1342
+ const args = CreateOrUpdateFileSchema.parse(request.params.arguments);
1343
+ 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);
1344
+ return {
1345
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
1346
+ };
1347
+ }
1348
+ case "push_files": {
1349
+ const args = PushFilesSchema.parse(request.params.arguments);
1350
+ const result = await createCommit(args.project_id, args.commit_message, args.branch, args.files.map((f) => ({ path: f.file_path, content: f.content })));
1351
+ return {
1352
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
1353
+ };
1354
+ }
1355
+ case "create_issue": {
1356
+ const args = CreateIssueSchema.parse(request.params.arguments);
1357
+ const { project_id, ...options } = args;
1358
+ const issue = await createIssue(project_id, options);
1359
+ return {
1360
+ content: [{ type: "text", text: JSON.stringify(issue, null, 2) }],
1361
+ };
1362
+ }
1363
+ case "create_merge_request": {
1364
+ const args = CreateMergeRequestSchema.parse(request.params.arguments);
1365
+ const { project_id, ...options } = args;
1366
+ const mergeRequest = await createMergeRequest(project_id, options);
1367
+ return {
1368
+ content: [
1369
+ { type: "text", text: JSON.stringify(mergeRequest, null, 2) },
1370
+ ],
1371
+ };
1372
+ }
1373
+ case "update_merge_request_note": {
1374
+ const args = UpdateMergeRequestNoteSchema.parse(request.params.arguments);
1375
+ const note = await updateMergeRequestNote(args.project_id, args.merge_request_iid, args.discussion_id, args.note_id, args.body, args.resolved // Pass resolved if provided
1376
+ );
1377
+ return {
1378
+ content: [{ type: "text", text: JSON.stringify(note, null, 2) }],
1379
+ };
1380
+ }
1381
+ case "get_merge_request": {
1382
+ const args = GetMergeRequestSchema.parse(request.params.arguments);
1383
+ const mergeRequest = await getMergeRequest(args.project_id, args.merge_request_iid);
1384
+ return {
1385
+ content: [
1386
+ { type: "text", text: JSON.stringify(mergeRequest, null, 2) },
1387
+ ],
1388
+ };
1389
+ }
1390
+ case "get_merge_request_diffs": {
1391
+ const args = GetMergeRequestDiffsSchema.parse(request.params.arguments);
1392
+ const diffs = await getMergeRequestDiffs(args.project_id, args.merge_request_iid, args.view);
1393
+ return {
1394
+ content: [{ type: "text", text: JSON.stringify(diffs, null, 2) }],
1395
+ };
1396
+ }
1397
+ case "update_merge_request": {
1398
+ const args = UpdateMergeRequestSchema.parse(request.params.arguments);
1399
+ const { project_id, merge_request_iid, ...options } = args;
1400
+ const mergeRequest = await updateMergeRequest(project_id, merge_request_iid, options);
1401
+ return {
1402
+ content: [
1403
+ { type: "text", text: JSON.stringify(mergeRequest, null, 2) },
1404
+ ],
1405
+ };
1406
+ }
1407
+ case "list_merge_request_discussions": {
1408
+ const args = ListMergeRequestDiscussionsSchema.parse(request.params.arguments);
1409
+ const discussions = await listMergeRequestDiscussions(args.project_id, args.merge_request_iid);
1410
+ return {
1411
+ content: [
1412
+ { type: "text", text: JSON.stringify(discussions, null, 2) },
1413
+ ],
1414
+ };
1415
+ }
1416
+ case "list_namespaces": {
1417
+ const args = ListNamespacesSchema.parse(request.params.arguments);
1418
+ const url = new URL(`${GITLAB_API_URL}/namespaces`);
1419
+ if (args.search) {
1420
+ url.searchParams.append("search", args.search);
1421
+ }
1422
+ if (args.page) {
1423
+ url.searchParams.append("page", args.page.toString());
1424
+ }
1425
+ if (args.per_page) {
1426
+ url.searchParams.append("per_page", args.per_page.toString());
1427
+ }
1428
+ if (args.owned) {
1429
+ url.searchParams.append("owned", args.owned.toString());
1430
+ }
1431
+ const response = await fetch(url.toString(), {
1432
+ headers: DEFAULT_HEADERS,
1433
+ });
1434
+ await handleGitLabError(response);
1435
+ const data = await response.json();
1436
+ const namespaces = z.array(GitLabNamespaceSchema).parse(data);
1437
+ return {
1438
+ content: [
1439
+ { type: "text", text: JSON.stringify(namespaces, null, 2) },
1440
+ ],
1441
+ };
1442
+ }
1443
+ case "get_namespace": {
1444
+ const args = GetNamespaceSchema.parse(request.params.arguments);
1445
+ const url = new URL(`${GITLAB_API_URL}/namespaces/${encodeURIComponent(args.namespace_id)}`);
1446
+ const response = await fetch(url.toString(), {
1447
+ headers: DEFAULT_HEADERS,
1448
+ });
1449
+ await handleGitLabError(response);
1450
+ const data = await response.json();
1451
+ const namespace = GitLabNamespaceSchema.parse(data);
1452
+ return {
1453
+ content: [{ type: "text", text: JSON.stringify(namespace, null, 2) }],
1454
+ };
1455
+ }
1456
+ case "verify_namespace": {
1457
+ const args = VerifyNamespaceSchema.parse(request.params.arguments);
1458
+ const url = new URL(`${GITLAB_API_URL}/namespaces/${encodeURIComponent(args.path)}/exists`);
1459
+ const response = await fetch(url.toString(), {
1460
+ headers: DEFAULT_HEADERS,
1461
+ });
1462
+ await handleGitLabError(response);
1463
+ const data = await response.json();
1464
+ const namespaceExists = GitLabNamespaceExistsResponseSchema.parse(data);
1465
+ return {
1466
+ content: [
1467
+ { type: "text", text: JSON.stringify(namespaceExists, null, 2) },
1468
+ ],
1469
+ };
1470
+ }
1471
+ case "get_project": {
1472
+ const args = GetProjectSchema.parse(request.params.arguments);
1473
+ const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(args.project_id)}`);
1474
+ const response = await fetch(url.toString(), {
1475
+ headers: DEFAULT_HEADERS,
1476
+ });
1477
+ await handleGitLabError(response);
1478
+ const data = await response.json();
1479
+ const project = GitLabProjectSchema.parse(data);
1480
+ return {
1481
+ content: [{ type: "text", text: JSON.stringify(project, null, 2) }],
1482
+ };
1483
+ }
1484
+ case "list_projects": {
1485
+ const args = ListProjectsSchema.parse(request.params.arguments);
1486
+ const projects = await listProjects(args);
1487
+ return {
1488
+ content: [{ type: "text", text: JSON.stringify(projects, null, 2) }],
1489
+ };
1490
+ }
1491
+ case "create_note": {
1492
+ const args = CreateNoteSchema.parse(request.params.arguments);
1493
+ const { project_id, noteable_type, noteable_iid, body } = args;
1494
+ const note = await createNote(project_id, noteable_type, noteable_iid, body);
1495
+ return {
1496
+ content: [{ type: "text", text: JSON.stringify(note, null, 2) }],
1497
+ };
1498
+ }
1499
+ case "list_issues": {
1500
+ const args = ListIssuesSchema.parse(request.params.arguments);
1501
+ const { project_id, ...options } = args;
1502
+ const issues = await listIssues(project_id, options);
1503
+ return {
1504
+ content: [{ type: "text", text: JSON.stringify(issues, null, 2) }],
1505
+ };
1506
+ }
1507
+ case "get_issue": {
1508
+ const args = GetIssueSchema.parse(request.params.arguments);
1509
+ const issue = await getIssue(args.project_id, args.issue_iid);
1510
+ return {
1511
+ content: [{ type: "text", text: JSON.stringify(issue, null, 2) }],
1512
+ };
1513
+ }
1514
+ case "update_issue": {
1515
+ const args = UpdateIssueSchema.parse(request.params.arguments);
1516
+ const { project_id, issue_iid, ...options } = args;
1517
+ const issue = await updateIssue(project_id, issue_iid, options);
1518
+ return {
1519
+ content: [{ type: "text", text: JSON.stringify(issue, null, 2) }],
1520
+ };
1521
+ }
1522
+ case "delete_issue": {
1523
+ const args = DeleteIssueSchema.parse(request.params.arguments);
1524
+ await deleteIssue(args.project_id, args.issue_iid);
1525
+ return {
1526
+ content: [
1527
+ {
1528
+ type: "text",
1529
+ text: JSON.stringify({ status: "success", message: "Issue deleted successfully" }, null, 2),
1530
+ },
1531
+ ],
1532
+ };
1533
+ }
1534
+ case "list_issue_links": {
1535
+ const args = ListIssueLinksSchema.parse(request.params.arguments);
1536
+ const links = await listIssueLinks(args.project_id, args.issue_iid);
1537
+ return {
1538
+ content: [{ type: "text", text: JSON.stringify(links, null, 2) }],
1539
+ };
1540
+ }
1541
+ case "get_issue_link": {
1542
+ const args = GetIssueLinkSchema.parse(request.params.arguments);
1543
+ const link = await getIssueLink(args.project_id, args.issue_iid, args.issue_link_id);
1544
+ return {
1545
+ content: [{ type: "text", text: JSON.stringify(link, null, 2) }],
1546
+ };
1547
+ }
1548
+ case "create_issue_link": {
1549
+ const args = CreateIssueLinkSchema.parse(request.params.arguments);
1550
+ const link = await createIssueLink(args.project_id, args.issue_iid, args.target_project_id, args.target_issue_iid, args.link_type);
1551
+ return {
1552
+ content: [{ type: "text", text: JSON.stringify(link, null, 2) }],
1553
+ };
1554
+ }
1555
+ case "delete_issue_link": {
1556
+ const args = DeleteIssueLinkSchema.parse(request.params.arguments);
1557
+ await deleteIssueLink(args.project_id, args.issue_iid, args.issue_link_id);
1558
+ return {
1559
+ content: [
1560
+ {
1561
+ type: "text",
1562
+ text: JSON.stringify({
1563
+ status: "success",
1564
+ message: "Issue link deleted successfully",
1565
+ }, null, 2),
1566
+ },
1567
+ ],
1568
+ };
1569
+ }
1570
+ case "list_labels": {
1571
+ const args = ListLabelsSchema.parse(request.params.arguments);
1572
+ const labels = await listLabels(args.project_id, args);
1573
+ return {
1574
+ content: [{ type: "text", text: JSON.stringify(labels, null, 2) }],
1575
+ };
1576
+ }
1577
+ case "get_label": {
1578
+ const args = GetLabelSchema.parse(request.params.arguments);
1579
+ const label = await getLabel(args.project_id, args.label_id, args.include_ancestor_groups);
1580
+ return {
1581
+ content: [{ type: "text", text: JSON.stringify(label, null, 2) }],
1582
+ };
1583
+ }
1584
+ case "create_label": {
1585
+ const args = CreateLabelSchema.parse(request.params.arguments);
1586
+ const label = await createLabel(args.project_id, args);
1587
+ return {
1588
+ content: [{ type: "text", text: JSON.stringify(label, null, 2) }],
1589
+ };
1590
+ }
1591
+ case "update_label": {
1592
+ const args = UpdateLabelSchema.parse(request.params.arguments);
1593
+ const { project_id, label_id, ...options } = args;
1594
+ const label = await updateLabel(project_id, label_id, options);
1595
+ return {
1596
+ content: [{ type: "text", text: JSON.stringify(label, null, 2) }],
1597
+ };
1598
+ }
1599
+ case "delete_label": {
1600
+ const args = DeleteLabelSchema.parse(request.params.arguments);
1601
+ await deleteLabel(args.project_id, args.label_id);
1602
+ return {
1603
+ content: [
1604
+ {
1605
+ type: "text",
1606
+ text: JSON.stringify({ status: "success", message: "Label deleted successfully" }, null, 2),
1607
+ },
1608
+ ],
1609
+ };
1610
+ }
1611
+ case "list_group_projects": {
1612
+ const args = ListGroupProjectsSchema.parse(request.params.arguments);
1613
+ const projects = await listGroupProjects(args);
1614
+ return {
1615
+ content: [{ type: "text", text: JSON.stringify(projects, null, 2) }],
1616
+ };
1617
+ }
1618
+ default:
1619
+ throw new Error(`Unknown tool: ${request.params.name}`);
1620
+ }
1621
+ }
1622
+ catch (error) {
1623
+ if (error instanceof z.ZodError) {
1624
+ throw new Error(`Invalid arguments: ${error.errors
1625
+ .map((e) => `${e.path.join(".")}: ${e.message}`)
1626
+ .join(", ")}`);
1627
+ }
1628
+ throw error;
1629
+ }
1630
+ });
1631
+ /**
1632
+ * Initialize and run the server
1633
+ * 서버 초기화 및 실행
1634
+ */
1635
+ async function runServer() {
1636
+ try {
1637
+ console.error("========================");
1638
+ console.error(`GitLab MCP Server v${SERVER_VERSION}`);
1639
+ console.error(`API URL: ${GITLAB_API_URL}`);
1640
+ console.error("========================");
1641
+ const transport = new StdioServerTransport();
1642
+ await server.connect(transport);
1643
+ console.error("GitLab MCP Server running on stdio");
1644
+ }
1645
+ catch (error) {
1646
+ console.error("Error initializing server:", error);
1647
+ process.exit(1);
1648
+ }
1649
+ }
1650
+ runServer().catch((error) => {
1651
+ console.error("Fatal error in main():", error);
1652
+ process.exit(1);
1653
+ });