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 | ```