#
tokens: 44197/50000 1/25 files (page 2/2)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 2 of 2. Use http://codebase.md/harshmaur/gitlab-mcp?lines=true&page={x} to view the full context.

# Directory Structure

```
├── .env.example
├── .eslintrc.json
├── .github
│   ├── pr-validation-guide.md
│   └── workflows
│       ├── auto-merge.yml
│       ├── docker-publish.yml
│       └── pr-test.yml
├── .gitignore
├── .prettierignore
├── .prettierrc
├── .secrets
├── CHANGELOG.md
├── Dockerfile
├── docs
│   └── setup-github-secrets.md
├── event.json
├── index.ts
├── LICENSE
├── package-lock.json
├── package.json
├── README.md
├── schemas.ts
├── scripts
│   ├── generate-tools-readme.ts
│   ├── image_push.sh
│   └── validate-pr.sh
├── smithery.yaml
├── test
│   └── validate-api.js
├── test-note.ts
└── tsconfig.json
```

# Files

--------------------------------------------------------------------------------
/index.ts:
--------------------------------------------------------------------------------

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