diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml new file mode 100644 index 0000000..d28f272 --- /dev/null +++ b/.github/workflows/npm-publish.yml @@ -0,0 +1,23 @@ +name: NPM Publish + +on: + release: + types: [published] + +jobs: + npm: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + registry-url: 'https://registry.npmjs.org/' + - name: Install dependencies + run: npm ci + - name: Publish to npm + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: npm publish --access public diff --git a/README.md b/README.md index 253a814..d4fc4e1 100644 --- a/README.md +++ b/README.md @@ -186,80 +186,87 @@ $ sh scripts/image_push.sh docker_user_name ## Tools ๐Ÿ› ๏ธ + -`verify_namespace` - Verify if a namespace path exists -`update_wiki_page` - Update an existing wiki page in a GitLab project -`update_merge_request` - Update a merge request (Either mergeRequestIid or branchName must be provided) -`update_merge_request_note` - Modify an existing merge request thread note -`update_label` - Update an existing label in a project -`update_issue` - Update an issue in a GitLab project -`update_issue_note` - Modify an existing issue thread note -`update_draft_note` - Update an existing draft note -`search_repositories` - Search for GitLab projects -`retry_pipeline` - Retry a failed or canceled pipeline -`push_files` - Push multiple files to a GitLab project in a single commit -`publish_draft_note` - Publish a single draft note -`promote_milestone` - Promote a milestone to the next stage -`my_issues` - List issues assigned to the authenticated user -`mr_discussions` - List discussion items for a merge request -`list_wiki_pages` - List wiki pages in a GitLab project -`list_projects` - List projects accessible by the current user -`list_project_members` - List members of a GitLab project -`list_pipelines` - List pipelines in a GitLab project with filtering options -`list_pipeline_trigger_jobs` - List all trigger jobs (bridges) in a specific pipeline that trigger downstream pipelines -`list_pipeline_jobs` - List all jobs in a specific pipeline -`list_namespaces` - List all namespaces available to the current user -`list_milestones` - List milestones in a GitLab project with filtering options -`list_merge_requests` - List merge requests in a GitLab project with filtering options -`list_merge_request_diffs` - List merge request diffs with pagination support (Either mergeRequestIid or branchName must be provided) -`list_labels` - List labels for a project -`list_issues` - List issues in a GitLab project with filtering options -`list_issue_links` - List all issue links for a specific issue -`list_issue_discussions` - List discussions for an issue in a GitLab project -`list_group_projects` - List projects in a GitLab group with filtering options -`list_draft_notes` - List draft notes for a merge request -`get_wiki_page` - Get details of a specific wiki page -`get_users` - Get GitLab user details by usernames -`get_repository_tree` - Get the repository tree for a GitLab project (list files and directories) -`get_project` - Get details of a specific project -`get_pipeline` - Get details of a specific pipeline in a GitLab project -`get_pipeline_job` - Get details of a GitLab pipeline job number -`get_pipeline_job_output` - Get the output/trace of a GitLab pipeline job number -`get_namespace` - Get details of a namespace by ID or path -`get_milestone` - Get details of a specific milestone -`get_milestone_merge_requests` - Get merge requests associated with a specific milestone -`get_milestone_issue` - Get issues associated with a specific milestone -`get_milestone_burndown_events` - Get burndown events for a specific milestone -`get_merge_request` - Get details of a merge request (Either mergeRequestIid or branchName must be provided) -`get_merge_request_diffs` - Get the changes/diffs of a merge request (Either mergeRequestIid or branchName must be provided) -`get_label` - Get a single label from a project -`get_issue` - Get details of a specific issue in a GitLab project -`get_issue_link` - Get a specific issue link -`get_file_contents` - Get the contents of a file or directory from a GitLab project -`get_branch_diffs` - Get the changes/diffs between two branches or commits in a GitLab project -`fork_repository` - Fork a GitLab project to your account or specified namespace -`edit_milestone` - Edit an existing milestone in a GitLab project -`delete_wiki_page` - Delete a wiki page from a GitLab project -`delete_milestone` - Delete a milestone from a GitLab project -`delete_label` - Delete a label from a project -`delete_issue` - Delete an issue from a GitLab project -`delete_issue_link` - Delete an issue link -`delete_draft_note` - Delete a draft note -`download_attachment` - Download an uploaded file from a GitLab project by secret and filename -`create_wiki_page` - Create a new wiki page in a GitLab project -`create_repository` - Create a new GitLab project -`create_pipeline` - Create a new pipeline for a branch or tag -`create_or_update_file` - Create or update a single file in a GitLab project -`create_note` - Create a new note (comment) to an issue or merge request -`create_milestone` - Create a new milestone in a GitLab project -`create_merge_request` - Create a new merge request in a GitLab project -`create_merge_request_thread` - Create a new thread on a merge request -`create_merge_request_note` - Add a new note to an existing merge request thread -`create_label` - Create a new label in a project -`create_issue` - Create a new issue in a GitLab project -`create_issue_note` - Add a new note to an existing issue thread -`create_issue_link` - Create an issue link between two issues -`create_draft_note` - Create a draft note for a merge request -`create_branch` - Create a new branch in a GitLab project -`cancel_pipeline` - Cancel a running pipeline -`bulk_publish_draft_notes` - Publish all draft notes for a merge request +1. `merge_merge_request` - Merge a merge request in a GitLab project +2. `create_or_update_file` - Create or update a single file in a GitLab project +3. `search_repositories` - Search for GitLab projects +4. `create_repository` - Create a new GitLab project +5. `get_file_contents` - Get the contents of a file or directory from a GitLab project +6. `push_files` - Push multiple files to a GitLab project in a single commit +7. `create_issue` - Create a new issue in a GitLab project +8. `create_merge_request` - Create a new merge request in a GitLab project +9. `fork_repository` - Fork a GitLab project to your account or specified namespace +10. `create_branch` - Create a new branch in a GitLab project +11. `get_merge_request` - Get details of a merge request (Either mergeRequestIid or branchName must be provided) +12. `get_merge_request_diffs` - Get the changes/diffs of a merge request (Either mergeRequestIid or branchName must be provided) +13. `list_merge_request_diffs` - List merge request diffs with pagination support (Either mergeRequestIid or branchName must be provided) +14. `get_branch_diffs` - Get the changes/diffs between two branches or commits in a GitLab project +15. `update_merge_request` - Update a merge request (Either mergeRequestIid or branchName must be provided) +16. `create_note` - Create a new note (comment) to an issue or merge request +17. `create_merge_request_thread` - Create a new thread on a merge request +18. `mr_discussions` - List discussion items for a merge request +19. `update_merge_request_note` - Modify an existing merge request thread note +20. `create_merge_request_note` - Add a new note to an existing merge request thread +21. `get_draft_note` - Get a single draft note from a merge request +22. `list_draft_notes` - List draft notes for a merge request +23. `create_draft_note` - Create a draft note for a merge request +24. `update_draft_note` - Update an existing draft note +25. `delete_draft_note` - Delete a draft note +26. `publish_draft_note` - Publish a single draft note +27. `bulk_publish_draft_notes` - Publish all draft notes for a merge request +28. `update_issue_note` - Modify an existing issue thread note +29. `create_issue_note` - Add a new note to an existing issue thread +30. `list_issues` - List issues (default: created by current user only; use scope='all' for all accessible issues) +31. `my_issues` - List issues assigned to the authenticated user (defaults to open issues) +32. `get_issue` - Get details of a specific issue in a GitLab project +33. `update_issue` - Update an issue in a GitLab project +34. `delete_issue` - Delete an issue from a GitLab project +35. `list_issue_links` - List all issue links for a specific issue +36. `list_issue_discussions` - List discussions for an issue in a GitLab project +37. `get_issue_link` - Get a specific issue link +38. `create_issue_link` - Create an issue link between two issues +39. `delete_issue_link` - Delete an issue link +40. `list_namespaces` - List all namespaces available to the current user +41. `get_namespace` - Get details of a namespace by ID or path +42. `verify_namespace` - Verify if a namespace path exists +43. `get_project` - Get details of a specific project +44. `list_projects` - List projects accessible by the current user +45. `list_project_members` - List members of a GitLab project +46. `list_labels` - List labels for a project +47. `get_label` - Get a single label from a project +48. `create_label` - Create a new label in a project +49. `update_label` - Update an existing label in a project +50. `delete_label` - Delete a label from a project +51. `list_group_projects` - List projects in a GitLab group with filtering options +52. `list_wiki_pages` - List wiki pages in a GitLab project +53. `get_wiki_page` - Get details of a specific wiki page +54. `create_wiki_page` - Create a new wiki page in a GitLab project +55. `update_wiki_page` - Update an existing wiki page in a GitLab project +56. `delete_wiki_page` - Delete a wiki page from a GitLab project +57. `get_repository_tree` - Get the repository tree for a GitLab project (list files and directories) +58. `list_pipelines` - List pipelines in a GitLab project with filtering options +59. `get_pipeline` - Get details of a specific pipeline in a GitLab project +60. `list_pipeline_jobs` - List all jobs in a specific pipeline +61. `list_pipeline_trigger_jobs` - List all trigger jobs (bridges) in a specific pipeline that trigger downstream pipelines +62. `get_pipeline_job` - Get details of a GitLab pipeline job number +63. `get_pipeline_job_output` - Get the output/trace of a GitLab pipeline job with optional pagination to limit context window usage +64. `create_pipeline` - Create a new pipeline for a branch or tag +65. `retry_pipeline` - Retry a failed or canceled pipeline +66. `cancel_pipeline` - Cancel a running pipeline +67. `list_merge_requests` - List merge requests in a GitLab project with filtering options +68. `list_milestones` - List milestones in a GitLab project with filtering options +69. `get_milestone` - Get details of a specific milestone +70. `create_milestone` - Create a new milestone in a GitLab project +71. `edit_milestone` - Edit an existing milestone in a GitLab project +72. `delete_milestone` - Delete a milestone from a GitLab project +73. `get_milestone_issue` - Get issues associated with a specific milestone +74. `get_milestone_merge_requests` - Get merge requests associated with a specific milestone +75. `promote_milestone` - Promote a milestone to the next stage +76. `get_milestone_burndown_events` - Get burndown events for a specific milestone +77. `get_users` - Get GitLab user details by usernames +78. `list_commits` - List repository commits with filtering options +79. `get_commit` - Get details of a specific commit +80. `get_commit_diff` - Get changes/diffs of a specific commit +81. `list_group_iterations` - List group iterations with filtering options +82. `upload_markdown` - Upload a file to a GitLab project for use in markdown content +83. `download_attachment` - Download an uploaded file from a GitLab project by secret and filename diff --git a/docs/oauth2_proxy.md b/docs/oauth2_proxy.md new file mode 100644 index 0000000..005d32c --- /dev/null +++ b/docs/oauth2_proxy.md @@ -0,0 +1,147 @@ +# GitLab MCP OAuth2 Proxy Configuration + +This guide explains how to configure the GitLab MCP server to use OAuth2 proxy authentication, allowing users to authenticate with GitLab OAuth2 applications instead of using personal access tokens. + +## Overview + +The OAuth2 proxy mode enables dynamic client registration and token management, providing a more secure and flexible authentication method compared to static personal access tokens. + +**Note:** OAuth2 proxy mode is only available when using SSE or STREAMABLE_HTTP transport modes. It is not supported with STDIO transport. + +## Required Environment Variables + +To enable OAuth2 proxy mode, you must set the following environment variables: + +### Core OAuth2 Configuration + +```bash +# GitLab OAuth2 Application Credentials +GITLAB_OAUTH2_CLIENT_ID=your_gitlab_app_id +GITLAB_OAUTH2_CLIENT_SECRET=your_gitlab_app_secret + +# GitLab OAuth2 Endpoints +GITLAB_OAUTH2_AUTHORIZATION_URL=https://gitlab.com/oauth/authorize +GITLAB_OAUTH2_TOKEN_URL=https://gitlab.com/oauth/token +GITLAB_OAUTH2_ISSUER_URL=https://gitlab.com +GITLAB_OAUTH2_BASE_URL=http://localhost:3000 # Your MCP server URL + +# OAuth2 Redirect Configuration +GITLAB_OAUTH2_REDIRECT_URL=http://localhost:3000/callback +``` + +### Optional Configuration + +```bash +# Token Revocation Endpoint (optional) +GITLAB_OAUTH2_REVOCATION_URL=https://gitlab.com/oauth/revoke + +# Database Path (defaults to in-memory if not set) +GITLAB_OAUTH2_DB_PATH=/path/to/oauth.db +``` + +## Setting Up a GitLab OAuth2 Application + +1. Go to your GitLab instance (e.g., https://gitlab.com) +2. Navigate to **User Settings** โ†’ **Applications** +3. Create a new application with: + - **Name**: Your MCP Server (or any descriptive name) + - **Redirect URI**: Must match `GITLAB_OAUTH2_REDIRECT_URL` exactly (e.g., `http://localhost:3000/callback`) + - **Scopes**: Select the following: + - `api` - Access the authenticated user's API + - `openid` - Authenticate using OpenID Connect + - `profile` - Read user's profile data + - `email` - Read user's email address + +4. After creation, GitLab will provide: + - **Application ID**: Use this for `GITLAB_OAUTH2_CLIENT_ID` + - **Secret**: Use this for `GITLAB_OAUTH2_CLIENT_SECRET` + +## Configuration Examples + +### Development Setup (localhost) + +```bash +# .env file +GITLAB_API_URL=https://gitlab.com + +GITLAB_OAUTH2_CLIENT_ID=your_app_id_here +GITLAB_OAUTH2_CLIENT_SECRET=your_app_secret_here +GITLAB_OAUTH2_AUTHORIZATION_URL=https://gitlab.com/oauth/authorize +GITLAB_OAUTH2_TOKEN_URL=https://gitlab.com/oauth/token +GITLAB_OAUTH2_ISSUER_URL=https://gitlab.com +GITLAB_OAUTH2_BASE_URL=http://localhost:3000 +GITLAB_OAUTH2_REDIRECT_URL=http://localhost:3000/callback +``` + +### Production Setup + +```bash +# .env file +GITLAB_API_URL=https://gitlab.company.com + +GITLAB_OAUTH2_CLIENT_ID=your_app_id_here +GITLAB_OAUTH2_CLIENT_SECRET=your_app_secret_here +GITLAB_OAUTH2_AUTHORIZATION_URL=https://gitlab.company.com/oauth/authorize +GITLAB_OAUTH2_TOKEN_URL=https://gitlab.company.com/oauth/token +GITLAB_OAUTH2_ISSUER_URL=https://gitlab.company.com +GITLAB_OAUTH2_BASE_URL=https://mcp.company.com +GITLAB_OAUTH2_REDIRECT_URL=https://mcp.company.com/callback +GITLAB_OAUTH2_DB_PATH=/var/lib/gitlab-mcp/oauth.db +``` + +## Database Storage + +By default, the OAuth2 proxy uses an in-memory SQLite database. For production use, specify a persistent database path: + +```bash +GITLAB_OAUTH2_DB_PATH=/path/to/persistent/oauth.db +``` + +The database stores: +- OAuth client registrations +- State mappings for OAuth flow +- Access token hashes (using Argon2 for security) + +## Security Considerations + +1. **Client Secrets**: Never commit `GITLAB_OAUTH2_CLIENT_SECRET` to version control +2. **HTTPS**: Always use HTTPS for production deployments +3. **Token Storage**: Access tokens are hashed using Argon2 before storage +4. **Token Expiry**: Tokens expire after 1 hour by default +5. **State Expiry**: OAuth state parameters expire after 15 minutes + +## Troubleshooting + +### Common Issues + +1. **"Protected resource URL mismatch"** + - Ensure `GITLAB_OAUTH2_BASE_URL` matches your server's actual URL + - Check that the redirect URI in GitLab matches `GITLAB_OAUTH2_REDIRECT_URL` + +2. **"Invalid redirect URI"** + - The redirect URI must match exactly (including protocol and port) + - No trailing slashes unless specified in GitLab + +3. **"Invalid scope"** + - Ensure your GitLab OAuth app has the required scopes enabled + - The MCP server requests: `api`, `openid`, `profile`, `email` + + +## Starting the Server + +OAuth2 proxy mode requires SSE or STREAMABLE_HTTP transport. + +The server will log: +``` +Configuring GitLab OAuth2 proxy authentication +``` + +**Note:** STDIO transport mode does not support OAuth2 proxy authentication. + +## Client Configuration + +MCP clients connecting to an OAuth2-enabled server should use the OAuth2 flow instead of providing a GitLab token directly. The server will handle dynamic client registration and token management automatically. + +## Note + +OAuth2 proxy support is currently in beta. While functional, it may have limitations compared to personal access token authentication. Please report any issues to the project's issue tracker. diff --git a/docs/passthrough_mode.md b/docs/passthrough_mode.md new file mode 100644 index 0000000..1a736eb --- /dev/null +++ b/docs/passthrough_mode.md @@ -0,0 +1,183 @@ +# GitLab MCP Passthrough Authentication Mode + +This guide explains how to configure the GitLab MCP server to use passthrough authentication mode, where users provide their own GitLab Personal Access Token (PAT) with each request. + +## Overview + +Passthrough mode allows multiple users to use the same MCP server instance with their own GitLab credentials. Each user provides their GitLab Personal Access Token via the `Gitlab-Token` header, and the server uses that token for all GitLab API requests. + +This mode is ideal for: +- Multi-user environments +- Scenarios where you don't want to store tokens on the server +- Testing with different access levels + +## Configuration + +To enable passthrough mode, set the following environment variable: + +```bash +GITLAB_PAT_PASSTHROUGH=true +``` + +**Important:** When using passthrough mode, do not set: +- `GITLAB_PERSONAL_ACCESS_TOKEN` +- `GITLAB_OAUTH2_CLIENT_ID` (or any OAuth2 configuration) + +## How It Works + +1. The MCP server starts without any pre-configured authentication +2. Each client request must include a `Gitlab-Token` header with a valid GitLab PAT +3. The server uses the provided token for that specific request +4. No tokens are stored or cached by the server + +## Client Configuration + +### Using with MCP Client + +When connecting to a passthrough-enabled server, clients must provide the GitLab token with each request: + +```typescript +// Example client configuration +const client = new MCPClient({ + url: 'http://localhost:3000', + headers: { + 'Gitlab-Token': 'your-gitlab-personal-access-token' + } +}); +``` + +### Using with cURL + +```bash +curl -H "Gitlab-Token: your-gitlab-personal-access-token" \ + http://localhost:3000/your-endpoint +``` + +### Using with HTTP Libraries + +```javascript +// Node.js with fetch +const response = await fetch('http://localhost:3000/your-endpoint', { + headers: { + 'Gitlab-Token': 'your-gitlab-personal-access-token' + } +}); +``` + +## Creating a GitLab Personal Access Token + +1. Go to GitLab (e.g., https://gitlab.com) +2. Navigate to **User Settings** โ†’ **Access Tokens** +3. Create a new token with: + - **Token name**: Descriptive name (e.g., "MCP Client") + - **Expiration date**: Set as needed + - **Scopes**: Select based on your needs: + - `api` - Full API access (recommended) + - `read_api` - Read-only API access + - `read_repository` - Read repository content + - `write_repository` - Write repository content + +4. Copy the generated token and use it in the `Gitlab-Token` header + +## Security Considerations + +1. **Token Transmission**: Tokens are sent with every request + - Always use HTTPS in production to encrypt tokens in transit + - Never log or store tokens on the client side in plain text + +2. **No Server Storage**: The server does not store any tokens + - Each request is authenticated independently + - No session management or token caching + +3. **Token Scope**: Users control their own access levels + - Each user's token determines what they can access + - Server has no control over permissions + +## Error Handling + +### Missing Token +If a request is made without the `Gitlab-Token` header: +``` +Status: 401 Unauthorized +Body: "Please set a Gitlab-Token header in your request" +``` + +### Invalid Token Format +If the token is not a string or is sent multiple times: +``` +Status: 401 Unauthorized +Body: "Gitlab-Token must only be set once" +``` + +### Invalid GitLab Token +If GitLab rejects the token: +``` +Status: 401 Unauthorized +Body: GitLab API error message +``` + +## Starting the Server + +Start the server with passthrough mode enabled: + +```bash +GITLAB_PAT_PASSTHROUGH=true npm dev +``` + +The server will log: +``` +Configuring GitLab PAT passthrough authentication. Users must set the Gitlab-Token header in their requests +``` + +## Comparison with Other Modes + +| Feature | Passthrough | Static PAT | OAuth2 Proxy | +|---------|------------|------------|--------------| +| Multi-user support | โœ… Yes | โŒ No | โœ… Yes | +| Token storage | โŒ None | โœ… Server | โœ… Database | +| Setup complexity | Low | Low | High | +| Transport support | All | All | SSE/HTTP only | +| User token control | โœ… Full | โŒ None | โš ๏ธ Limited | + +## Example: Full Request Flow + +1. User creates a GitLab PAT with necessary scopes +2. User configures their MCP client with the token: + ```javascript + const client = new MCPClient({ + url: 'http://localhost:3000', + headers: { + 'Gitlab-Token': 'glpat-xxxxxxxxxxxxxxxxxxxx' + } + }); + ``` +3. Client makes a request to list projects +4. MCP server receives request with token header +5. Server forwards the token to GitLab API +6. GitLab validates token and returns data +7. Server returns data to client + +## Troubleshooting + +### Token Not Working +- Verify the token has not expired +- Check that the token has the required scopes +- Ensure the token is from the correct GitLab instance +- Try the token directly with GitLab API to verify it works + +### Multiple Users Issues +- Each user must use their own token +- Tokens should not be shared between users +- Consider OAuth2 mode for better multi-user management + +## Best Practices + +1. **Token Rotation**: Regularly rotate PATs for security +2. **Minimal Scopes**: Use tokens with only necessary scopes +3. **HTTPS Only**: Always use HTTPS in production +4. **Client Security**: Store tokens securely on client side +5. **Monitoring**: Log request counts but never log tokens + +## Note + +Passthrough mode is ideal for development and multi-user scenarios where each user manages their own credentials. For production deployments with many users, consider using OAuth2 proxy mode for better token management and security. diff --git a/index.ts b/index.ts index 370e29d..533f775 100644 --- a/index.ts +++ b/index.ts @@ -1,5374 +1,279 @@ #!/usr/bin/env node -import { Server } from "@modelcontextprotocol/sdk/server/index.js"; -import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; -import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; -import express, { Request, Response } from "express"; -import fetchCookie from "fetch-cookie"; -import fs from "fs"; -import { HttpProxyAgent } from "http-proxy-agent"; -import { HttpsProxyAgent } from "https-proxy-agent"; -import nodeFetch from "node-fetch"; -import path, { dirname } from "path"; -import { SocksProxyAgent } from "socks-proxy-agent"; -import { CookieJar, parse as parseCookie } from "tough-cookie"; -import { fileURLToPath } from "url"; -import { z } from "zod"; -import { zodToJsonSchema } from "zod-to-json-schema"; -// Add type imports for proxy agents -import { Agent } from "http"; -import { Agent as HttpsAgent } from "https"; -import { URL } from "url"; -import { - BulkPublishDraftNotesSchema, - CancelPipelineSchema, - CreateBranchOptionsSchema, - CreateBranchSchema, - CreateDraftNoteSchema, - CreateIssueLinkSchema, - CreateIssueNoteSchema, - CreateIssueOptionsSchema, - CreateIssueSchema, - CreateLabelSchema, // Added - CreateMergeRequestNoteSchema, - CreateMergeRequestOptionsSchema, - CreateMergeRequestSchema, - CreateMergeRequestThreadSchema, - CreateNoteSchema, - CreateOrUpdateFileSchema, - CreatePipelineSchema, - CreateProjectMilestoneSchema, - CreateRepositoryOptionsSchema, - CreateRepositorySchema, - CreateWikiPageSchema, - DeleteDraftNoteSchema, - DeleteIssueLinkSchema, - DeleteIssueSchema, - DeleteLabelSchema, - DeleteProjectMilestoneSchema, - DeleteWikiPageSchema, - EditProjectMilestoneSchema, - type FileOperation, - ForkRepositorySchema, - GetBranchDiffsSchema, - GetCommitDiffSchema, - GetCommitSchema, - GetDraftNoteSchema, - GetFileContentsSchema, - GetIssueLinkSchema, - GetIssueSchema, - GetLabelSchema, - GetMergeRequestDiffsSchema, - GetMergeRequestSchema, - GetMilestoneBurndownEventsSchema, - GetMilestoneIssuesSchema, - GetMilestoneMergeRequestsSchema, - GetNamespaceSchema, - // pipeline job schemas - GetPipelineJobOutputSchema, - GetPipelineSchema, - GetProjectMilestoneSchema, - GetProjectSchema, - type GetRepositoryTreeOptions, - GetRepositoryTreeSchema, - GetUsersSchema, - GetWikiPageSchema, - type GitLabCommit, - GitLabCommitSchema, - GitLabCompareResult, - GitLabCompareResultSchema, - type GitLabContent, - GitLabContentSchema, - type GitLabCreateUpdateFileResponse, - GitLabCreateUpdateFileResponseSchema, - GitLabDiffSchema, - type GitLabDiscussion, - // Discussion Types - type GitLabDiscussionNote, - // Discussion Schemas - GitLabDiscussionNoteSchema, // Added - GitLabDiscussionSchema, - // Draft Notes Types - type GitLabDraftNote, - // Draft Notes Schemas - GitLabDraftNoteSchema, - type GitLabFork, - GitLabForkSchema, - type GitLabIssue, - type GitLabIssueLink, - GitLabIssueLinkSchema, - GitLabIssueSchema, - type GitLabIssueWithLinkDetails, - GitLabIssueWithLinkDetailsSchema, - type GitLabLabel, - GitLabMarkdownUpload, - GitLabMarkdownUploadSchema, - type GitLabMergeRequest, - type GitLabMergeRequestDiff, - GitLabMergeRequestSchema, - type GitLabMilestones, - GitLabMilestonesSchema, - type GitLabNamespace, - type GitLabNamespaceExistsResponse, - GitLabNamespaceExistsResponseSchema, - GitLabNamespaceSchema, - type GitLabPipeline, - type GitLabPipelineJob, - GitLabPipelineJobSchema, - GitLabPipelineSchema, - type GitLabPipelineTriggerJob, - GitLabPipelineTriggerJobSchema, - type GitLabProject, - type GitLabProjectMember, - GitLabProjectMemberSchema, - GitLabProjectSchema, - type GitLabReference, - GitLabReferenceSchema, - type GitLabRepository, - GitLabRepositorySchema, - type GitLabSearchResponse, - GitLabSearchResponseSchema, - type GitLabTree, - type GitLabTreeItem, - GitLabTreeItemSchema, - GitLabTreeSchema, - type GitLabUser, - GitLabUserSchema, - type GitLabUsersResponse, - GitLabUsersResponseSchema, - type GitLabWikiPage, - GitLabWikiPageSchema, - GroupIteration, - type ListCommitsOptions, - ListCommitsSchema, - ListDraftNotesSchema, - ListGroupIterationsSchema, - ListGroupProjectsSchema, - ListIssueDiscussionsSchema, - ListIssueLinksSchema, - ListIssuesSchema, - ListLabelsSchema, - ListMergeRequestDiffsSchema, // Added - ListMergeRequestDiscussionsSchema, - ListMergeRequestsSchema, - ListNamespacesSchema, - type ListPipelineJobsOptions, - ListPipelineJobsSchema, - type ListPipelinesOptions, - ListPipelinesSchema, - type ListPipelineTriggerJobsOptions, - ListPipelineTriggerJobsSchema, - type ListProjectMembersOptions, - ListProjectMembersSchema, - ListProjectMilestonesSchema, - ListProjectsSchema, - ListWikiPagesOptions, - ListWikiPagesSchema, - MarkdownUploadSchema, - DownloadAttachmentSchema, - MergeMergeRequestSchema, - type MergeRequestThreadPosition, - type MergeRequestThreadPositionCreate, - type MyIssuesOptions, - MyIssuesSchema, - type PaginatedDiscussionsResponse, - PaginatedDiscussionsResponseSchema, - type PaginationOptions, - PromoteProjectMilestoneSchema, - PublishDraftNoteSchema, - PushFilesSchema, - RetryPipelineSchema, - SearchRepositoriesSchema, - UpdateDraftNoteSchema, - UpdateIssueNoteSchema, - UpdateIssueSchema, - UpdateLabelSchema, - UpdateMergeRequestNoteSchema, - UpdateMergeRequestSchema, - UpdateWikiPageSchema, - VerifyNamespaceSchema -} from "./schemas.js"; - -import { randomUUID } from "crypto"; -import { pino } from "pino"; - -const logger = pino({ - level: process.env.LOG_LEVEL || "info", - transport: { - target: "pino-pretty", - options: { - colorize: true, - levelFirst: true, - destination: 2, - }, - }, -}); - -/** - * Available transport modes for MCP server - */ -enum TransportMode { - STDIO = "stdio", - SSE = "sse", - STREAMABLE_HTTP = "streamable-http", -} - -/** - * Read version from package.json - */ -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const packageJsonPath = path.resolve(__dirname, "../package.json"); -let SERVER_VERSION = "unknown"; -try { - if (fs.existsSync(packageJsonPath)) { - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); - SERVER_VERSION = packageJson.version || SERVER_VERSION; - } -} catch (error) { - // Warning: Could not read version from package.json - silently continue -} - -const server = new Server( - { - name: "better-gitlab-mcp-server", - version: SERVER_VERSION, - }, - { - capabilities: { - tools: {}, - }, - } -); - -const GITLAB_PERSONAL_ACCESS_TOKEN = process.env.GITLAB_PERSONAL_ACCESS_TOKEN; -const GITLAB_AUTH_COOKIE_PATH = process.env.GITLAB_AUTH_COOKIE_PATH; -const IS_OLD = process.env.GITLAB_IS_OLD === "true"; -const GITLAB_READ_ONLY_MODE = process.env.GITLAB_READ_ONLY_MODE === "true"; -const USE_GITLAB_WIKI = process.env.USE_GITLAB_WIKI === "true"; -const USE_MILESTONE = process.env.USE_MILESTONE === "true"; -const USE_PIPELINE = process.env.USE_PIPELINE === "true"; -const SSE = process.env.SSE === "true"; -const STREAMABLE_HTTP = process.env.STREAMABLE_HTTP === "true"; -const HOST = process.env.HOST || "0.0.0.0"; -const PORT = process.env.PORT || 3002; -// Add proxy configuration -const HTTP_PROXY = process.env.HTTP_PROXY; -const HTTPS_PROXY = process.env.HTTPS_PROXY; -const NODE_TLS_REJECT_UNAUTHORIZED = process.env.NODE_TLS_REJECT_UNAUTHORIZED; -const GITLAB_CA_CERT_PATH = process.env.GITLAB_CA_CERT_PATH; - -let sslOptions = undefined; -if (NODE_TLS_REJECT_UNAUTHORIZED === "0") { - sslOptions = { rejectUnauthorized: false }; -} else if (GITLAB_CA_CERT_PATH) { - const ca = fs.readFileSync(GITLAB_CA_CERT_PATH); - sslOptions = { ca }; -} - -// Configure proxy agents if proxies are set -let httpAgent: Agent | undefined = undefined; -let httpsAgent: Agent | undefined = undefined; - -if (HTTP_PROXY) { - if (HTTP_PROXY.startsWith("socks")) { - httpAgent = new SocksProxyAgent(HTTP_PROXY); - } else { - httpAgent = new HttpProxyAgent(HTTP_PROXY); - } -} -if (HTTPS_PROXY) { - if (HTTPS_PROXY.startsWith("socks")) { - httpsAgent = new SocksProxyAgent(HTTPS_PROXY); - } else { - httpsAgent = new HttpsProxyAgent(HTTPS_PROXY, sslOptions); - } -} -httpsAgent = httpsAgent || new HttpsAgent(sslOptions); -httpAgent = httpAgent || new Agent(); - -// Create cookie jar with clean Netscape file parsing -const createCookieJar = (): CookieJar | null => { - if (!GITLAB_AUTH_COOKIE_PATH) return null; - - try { - const cookiePath = GITLAB_AUTH_COOKIE_PATH.startsWith("~/") - ? path.join(process.env.HOME || "", GITLAB_AUTH_COOKIE_PATH.slice(2)) - : GITLAB_AUTH_COOKIE_PATH; - - const jar = new CookieJar(); - const cookieContent = fs.readFileSync(cookiePath, "utf8"); - - cookieContent.split("\n").forEach(line => { - // Handle #HttpOnly_ prefix - if (line.startsWith("#HttpOnly_")) { - line = line.slice(10); - } - // Skip comments and empty lines - if (line.startsWith("#") || !line.trim()) { - return; - } - - // Parse Netscape format: domain, flag, path, secure, expires, name, value - const parts = line.split("\t"); - if (parts.length >= 7) { - const [domain, , path, secure, expires, name, value] = parts; - - // Build cookie string in standard format - const cookieStr = `${name}=${value}; Domain=${domain}; Path=${path}${secure === "TRUE" ? "; Secure" : ""}${expires !== "0" ? `; Expires=${new Date(parseInt(expires) * 1000).toUTCString()}` : ""}`; - - // Use tough-cookie's parse function for robust parsing - const cookie = parseCookie(cookieStr); - if (cookie) { - const url = `${secure === "TRUE" ? "https" : "http"}://${domain.startsWith(".") ? domain.slice(1) : domain}`; - jar.setCookieSync(cookie, url); - } - } - }); - - return jar; - } catch (error) { - logger.error("Error loading cookie file:", error); - return null; - } -}; - -// Initialize cookie jar and fetch -const cookieJar = createCookieJar(); -const fetch = cookieJar ? fetchCookie(nodeFetch, cookieJar) : nodeFetch; - -// Ensure session is established for the current request -async function ensureSessionForRequest(): Promise { - if (!cookieJar || !GITLAB_AUTH_COOKIE_PATH) return; - - // Extract the base URL from GITLAB_API_URL - const apiUrl = new URL(GITLAB_API_URL); - const baseUrl = `${apiUrl.protocol}//${apiUrl.hostname}`; - - // Check if we already have GitLab session cookies - const gitlabCookies = cookieJar.getCookiesSync(baseUrl); - const hasSessionCookie = gitlabCookies.some( - cookie => cookie.key === "_gitlab_session" || cookie.key === "remember_user_token" - ); - - if (!hasSessionCookie) { - try { - // Establish session with a lightweight request - await fetch(`${GITLAB_API_URL}/user`, { - ...DEFAULT_FETCH_CONFIG, - redirect: "follow", - }).catch(() => { - // Ignore errors - the important thing is that cookies get set during redirects - }); - - // Small delay to ensure cookies are fully processed - await new Promise(resolve => setTimeout(resolve, 100)); - } catch (error) { - // Ignore session establishment errors - } - } -} - -// Modify DEFAULT_HEADERS to include agent configuration -const DEFAULT_HEADERS: Record = { - Accept: "application/json", - "Content-Type": "application/json", -}; -if (IS_OLD) { - DEFAULT_HEADERS["Private-Token"] = `${GITLAB_PERSONAL_ACCESS_TOKEN}`; -} else { - DEFAULT_HEADERS["Authorization"] = `Bearer ${GITLAB_PERSONAL_ACCESS_TOKEN}`; -} - -// Create a default fetch configuration object that includes proxy agents if set -const DEFAULT_FETCH_CONFIG = { - headers: DEFAULT_HEADERS, - agent: (parsedUrl: URL) => { - if (parsedUrl.protocol === "https:") { - return httpsAgent; - } - return httpAgent; - }, -}; - -// Define all available tools -const allTools = [ - { - name: "merge_merge_request", - description: "Merge a merge request in a GitLab project", - inputSchema: zodToJsonSchema(MergeMergeRequestSchema), - }, - { - name: "create_or_update_file", - description: "Create or update a single file in a GitLab project", - inputSchema: zodToJsonSchema(CreateOrUpdateFileSchema), - }, - { - name: "search_repositories", - description: "Search for GitLab projects", - inputSchema: zodToJsonSchema(SearchRepositoriesSchema), - }, - { - name: "create_repository", - description: "Create a new GitLab project", - inputSchema: zodToJsonSchema(CreateRepositorySchema), - }, - { - name: "get_file_contents", - description: "Get the contents of a file or directory from a GitLab project", - inputSchema: zodToJsonSchema(GetFileContentsSchema), - }, - { - name: "push_files", - description: "Push multiple files to a GitLab project in a single commit", - inputSchema: zodToJsonSchema(PushFilesSchema), - }, - { - name: "create_issue", - description: "Create a new issue in a GitLab project", - inputSchema: zodToJsonSchema(CreateIssueSchema), - }, - { - name: "create_merge_request", - description: "Create a new merge request in a GitLab project", - inputSchema: zodToJsonSchema(CreateMergeRequestSchema), - }, - { - name: "fork_repository", - description: "Fork a GitLab project to your account or specified namespace", - inputSchema: zodToJsonSchema(ForkRepositorySchema), - }, - { - name: "create_branch", - description: "Create a new branch in a GitLab project", - inputSchema: zodToJsonSchema(CreateBranchSchema), - }, - { - name: "get_merge_request", - description: - "Get details of a merge request (Either mergeRequestIid or branchName must be provided)", - inputSchema: zodToJsonSchema(GetMergeRequestSchema), - }, - { - name: "get_merge_request_diffs", - description: - "Get the changes/diffs of a merge request (Either mergeRequestIid or branchName must be provided)", - inputSchema: zodToJsonSchema(GetMergeRequestDiffsSchema), - }, - { - name: "list_merge_request_diffs", - description: - "List merge request diffs with pagination support (Either mergeRequestIid or branchName must be provided)", - inputSchema: zodToJsonSchema(ListMergeRequestDiffsSchema), - }, - { - name: "get_branch_diffs", - description: "Get the changes/diffs between two branches or commits in a GitLab project", - inputSchema: zodToJsonSchema(GetBranchDiffsSchema), - }, - { - name: "update_merge_request", - description: "Update a merge request (Either mergeRequestIid or branchName must be provided)", - inputSchema: zodToJsonSchema(UpdateMergeRequestSchema), - }, - { - name: "create_note", - description: "Create a new note (comment) to an issue or merge request", - inputSchema: zodToJsonSchema(CreateNoteSchema), - }, - { - name: "create_merge_request_thread", - description: "Create a new thread on a merge request", - inputSchema: zodToJsonSchema(CreateMergeRequestThreadSchema), - }, - { - name: "mr_discussions", - description: "List discussion items for a merge request", - inputSchema: zodToJsonSchema(ListMergeRequestDiscussionsSchema), - }, - { - name: "update_merge_request_note", - description: "Modify an existing merge request thread note", - inputSchema: zodToJsonSchema(UpdateMergeRequestNoteSchema), - }, - { - name: "create_merge_request_note", - description: "Add a new note to an existing merge request thread", - inputSchema: zodToJsonSchema(CreateMergeRequestNoteSchema), - }, - { - name: "get_draft_note", - description: "Get a single draft note from a merge request", - inputSchema: zodToJsonSchema(GetDraftNoteSchema), - }, - { - name: "list_draft_notes", - description: "List draft notes for a merge request", - inputSchema: zodToJsonSchema(ListDraftNotesSchema), - }, - { - name: "create_draft_note", - description: "Create a draft note for a merge request", - inputSchema: zodToJsonSchema(CreateDraftNoteSchema), - }, - { - name: "update_draft_note", - description: "Update an existing draft note", - inputSchema: zodToJsonSchema(UpdateDraftNoteSchema), - }, - { - name: "delete_draft_note", - description: "Delete a draft note", - inputSchema: zodToJsonSchema(DeleteDraftNoteSchema), - }, - { - name: "publish_draft_note", - description: "Publish a single draft note", - inputSchema: zodToJsonSchema(PublishDraftNoteSchema), - }, - { - name: "bulk_publish_draft_notes", - description: "Publish all draft notes for a merge request", - inputSchema: zodToJsonSchema(BulkPublishDraftNotesSchema), - }, - { - name: "update_issue_note", - description: "Modify an existing issue thread note", - inputSchema: zodToJsonSchema(UpdateIssueNoteSchema), - }, - { - name: "create_issue_note", - description: "Add a new note to an existing issue thread", - inputSchema: zodToJsonSchema(CreateIssueNoteSchema), - }, - { - name: "list_issues", - description: - "List issues (default: created by current user only; use scope='all' for all accessible issues)", - inputSchema: zodToJsonSchema(ListIssuesSchema), - }, - { - name: "my_issues", - description: "List issues assigned to the authenticated user (defaults to open issues)", - inputSchema: zodToJsonSchema(MyIssuesSchema), - }, - { - name: "get_issue", - description: "Get details of a specific issue in a GitLab project", - inputSchema: zodToJsonSchema(GetIssueSchema), - }, - { - name: "update_issue", - description: "Update an issue in a GitLab project", - inputSchema: zodToJsonSchema(UpdateIssueSchema), - }, - { - name: "delete_issue", - description: "Delete an issue from a GitLab project", - inputSchema: zodToJsonSchema(DeleteIssueSchema), - }, - { - name: "list_issue_links", - description: "List all issue links for a specific issue", - inputSchema: zodToJsonSchema(ListIssueLinksSchema), - }, - { - name: "list_issue_discussions", - description: "List discussions for an issue in a GitLab project", - inputSchema: zodToJsonSchema(ListIssueDiscussionsSchema), - }, - { - name: "get_issue_link", - description: "Get a specific issue link", - inputSchema: zodToJsonSchema(GetIssueLinkSchema), - }, - { - name: "create_issue_link", - description: "Create an issue link between two issues", - inputSchema: zodToJsonSchema(CreateIssueLinkSchema), - }, - { - name: "delete_issue_link", - description: "Delete an issue link", - inputSchema: zodToJsonSchema(DeleteIssueLinkSchema), - }, - { - name: "list_namespaces", - description: "List all namespaces available to the current user", - inputSchema: zodToJsonSchema(ListNamespacesSchema), - }, - { - name: "get_namespace", - description: "Get details of a namespace by ID or path", - inputSchema: zodToJsonSchema(GetNamespaceSchema), - }, - { - name: "verify_namespace", - description: "Verify if a namespace path exists", - inputSchema: zodToJsonSchema(VerifyNamespaceSchema), - }, - { - name: "get_project", - description: "Get details of a specific project", - inputSchema: zodToJsonSchema(GetProjectSchema), - }, - { - name: "list_projects", - description: "List projects accessible by the current user", - inputSchema: zodToJsonSchema(ListProjectsSchema), - }, - { - name: "list_project_members", - description: "List members of a GitLab project", - inputSchema: zodToJsonSchema(ListProjectMembersSchema), - }, - { - name: "list_labels", - description: "List labels for a project", - inputSchema: zodToJsonSchema(ListLabelsSchema), - }, - { - name: "get_label", - description: "Get a single label from a project", - inputSchema: zodToJsonSchema(GetLabelSchema), - }, - { - name: "create_label", - description: "Create a new label in a project", - inputSchema: zodToJsonSchema(CreateLabelSchema), - }, - { - name: "update_label", - description: "Update an existing label in a project", - inputSchema: zodToJsonSchema(UpdateLabelSchema), - }, - { - name: "delete_label", - description: "Delete a label from a project", - inputSchema: zodToJsonSchema(DeleteLabelSchema), - }, - { - name: "list_group_projects", - description: "List projects in a GitLab group with filtering options", - inputSchema: zodToJsonSchema(ListGroupProjectsSchema), - }, - { - name: "list_wiki_pages", - description: "List wiki pages in a GitLab project", - inputSchema: zodToJsonSchema(ListWikiPagesSchema), - }, - { - name: "get_wiki_page", - description: "Get details of a specific wiki page", - inputSchema: zodToJsonSchema(GetWikiPageSchema), - }, - { - name: "create_wiki_page", - description: "Create a new wiki page in a GitLab project", - inputSchema: zodToJsonSchema(CreateWikiPageSchema), - }, - { - name: "update_wiki_page", - description: "Update an existing wiki page in a GitLab project", - inputSchema: zodToJsonSchema(UpdateWikiPageSchema), - }, - { - name: "delete_wiki_page", - description: "Delete a wiki page from a GitLab project", - inputSchema: zodToJsonSchema(DeleteWikiPageSchema), - }, - { - name: "get_repository_tree", - description: "Get the repository tree for a GitLab project (list files and directories)", - inputSchema: zodToJsonSchema(GetRepositoryTreeSchema), - }, - { - name: "list_pipelines", - description: "List pipelines in a GitLab project with filtering options", - inputSchema: zodToJsonSchema(ListPipelinesSchema), - }, - { - name: "get_pipeline", - description: "Get details of a specific pipeline in a GitLab project", - inputSchema: zodToJsonSchema(GetPipelineSchema), - }, - { - name: "list_pipeline_jobs", - description: "List all jobs in a specific pipeline", - inputSchema: zodToJsonSchema(ListPipelineJobsSchema), - }, - { - name: "list_pipeline_trigger_jobs", - description: - "List all trigger jobs (bridges) in a specific pipeline that trigger downstream pipelines", - inputSchema: zodToJsonSchema(ListPipelineTriggerJobsSchema), - }, - { - name: "get_pipeline_job", - description: "Get details of a GitLab pipeline job number", - inputSchema: zodToJsonSchema(GetPipelineJobOutputSchema), - }, - { - name: "get_pipeline_job_output", - description: - "Get the output/trace of a GitLab pipeline job with optional pagination to limit context window usage", - inputSchema: zodToJsonSchema(GetPipelineJobOutputSchema), - }, - { - name: "create_pipeline", - description: "Create a new pipeline for a branch or tag", - inputSchema: zodToJsonSchema(CreatePipelineSchema), - }, - { - name: "retry_pipeline", - description: "Retry a failed or canceled pipeline", - inputSchema: zodToJsonSchema(RetryPipelineSchema), - }, - { - name: "cancel_pipeline", - description: "Cancel a running pipeline", - inputSchema: zodToJsonSchema(CancelPipelineSchema), - }, - { - name: "list_merge_requests", - description: "List merge requests in a GitLab project with filtering options", - inputSchema: zodToJsonSchema(ListMergeRequestsSchema), - }, - { - name: "list_milestones", - description: "List milestones in a GitLab project with filtering options", - inputSchema: zodToJsonSchema(ListProjectMilestonesSchema), - }, - { - name: "get_milestone", - description: "Get details of a specific milestone", - inputSchema: zodToJsonSchema(GetProjectMilestoneSchema), - }, - { - name: "create_milestone", - description: "Create a new milestone in a GitLab project", - inputSchema: zodToJsonSchema(CreateProjectMilestoneSchema), - }, - { - name: "edit_milestone", - description: "Edit an existing milestone in a GitLab project", - inputSchema: zodToJsonSchema(EditProjectMilestoneSchema), - }, - { - name: "delete_milestone", - description: "Delete a milestone from a GitLab project", - inputSchema: zodToJsonSchema(DeleteProjectMilestoneSchema), - }, - { - name: "get_milestone_issue", - description: "Get issues associated with a specific milestone", - inputSchema: zodToJsonSchema(GetMilestoneIssuesSchema), - }, - { - name: "get_milestone_merge_requests", - description: "Get merge requests associated with a specific milestone", - inputSchema: zodToJsonSchema(GetMilestoneMergeRequestsSchema), - }, - { - name: "promote_milestone", - description: "Promote a milestone to the next stage", - inputSchema: zodToJsonSchema(PromoteProjectMilestoneSchema), - }, - { - name: "get_milestone_burndown_events", - description: "Get burndown events for a specific milestone", - inputSchema: zodToJsonSchema(GetMilestoneBurndownEventsSchema), - }, - { - name: "get_users", - description: "Get GitLab user details by usernames", - inputSchema: zodToJsonSchema(GetUsersSchema), - }, - { - name: "list_commits", - description: "List repository commits with filtering options", - inputSchema: zodToJsonSchema(ListCommitsSchema), - }, - { - name: "get_commit", - description: "Get details of a specific commit", - inputSchema: zodToJsonSchema(GetCommitSchema), - }, - { - name: "get_commit_diff", - description: "Get changes/diffs of a specific commit", - inputSchema: zodToJsonSchema(GetCommitDiffSchema), - }, - { - name: "list_group_iterations", - description: "List group iterations with filtering options", - inputSchema: zodToJsonSchema(ListGroupIterationsSchema), - }, - { - name: "upload_markdown", - description: "Upload a file to a GitLab project for use in markdown content", - inputSchema: zodToJsonSchema(MarkdownUploadSchema), - }, - { - name: "download_attachment", - description: "Download an uploaded file from a GitLab project by secret and filename", - inputSchema: zodToJsonSchema(DownloadAttachmentSchema), - }, -]; - -// Define which tools are read-only -const readOnlyTools = [ - "search_repositories", - "get_file_contents", - "get_merge_request", - "get_merge_request_diffs", - "get_branch_diffs", - "mr_discussions", - "list_issues", - "my_issues", - "list_merge_requests", - "get_issue", - "list_issue_links", - "list_issue_discussions", - "get_issue_link", - "list_namespaces", - "get_namespace", - "verify_namespace", - "get_project", - "list_projects", - "list_project_members", - "get_pipeline", - "list_pipelines", - "list_pipeline_jobs", - "list_pipeline_trigger_jobs", - "get_pipeline_job", - "get_pipeline_job_output", - "list_labels", - "get_label", - "list_group_projects", - "get_repository_tree", - "list_milestones", - "get_milestone", - "get_milestone_issue", - "get_milestone_merge_requests", - "get_milestone_burndown_events", - "list_wiki_pages", - "get_wiki_page", - "get_users", - "list_commits", - "get_commit", - "get_commit_diff", - "list_group_iterations", - "get_group_iteration", - "download_attachment", -]; - -// Define which tools are related to wiki and can be toggled by USE_GITLAB_WIKI -const wikiToolNames = [ - "list_wiki_pages", - "get_wiki_page", - "create_wiki_page", - "update_wiki_page", - "delete_wiki_page", - "upload_wiki_attachment", -]; - -// Define which tools are related to milestones and can be toggled by USE_MILESTONE -const milestoneToolNames = [ - "list_milestones", - "get_milestone", - "create_milestone", - "edit_milestone", - "delete_milestone", - "get_milestone_issue", - "get_milestone_merge_requests", - "promote_milestone", - "get_milestone_burndown_events", -]; - -// Define which tools are related to pipelines and can be toggled by USE_PIPELINE -const pipelineToolNames = [ - "list_pipelines", - "get_pipeline", - "list_pipeline_jobs", - "list_pipeline_trigger_jobs", - "get_pipeline_job", - "get_pipeline_job_output", - "create_pipeline", - "retry_pipeline", - "cancel_pipeline", -]; - -/** - * Smart URL handling for GitLab API - * - * @param {string | undefined} url - Input GitLab API URL - * @returns {string} Normalized GitLab API URL with /api/v4 path - */ -function normalizeGitLabApiUrl(url?: string): string { - if (!url) { - return "https://gitlab.com/api/v4"; - } - - // Remove trailing slash if present - let normalizedUrl = url.endsWith("/") ? url.slice(0, -1) : url; - - // Check if URL already has /api/v4 - if (!normalizedUrl.endsWith("/api/v4") && !normalizedUrl.endsWith("/api/v4/")) { - // Append /api/v4 if not already present - normalizedUrl = `${normalizedUrl}/api/v4`; - } - - return normalizedUrl; -} - -// Use the normalizeGitLabApiUrl function to handle various URL formats -const GITLAB_API_URL = normalizeGitLabApiUrl(process.env.GITLAB_API_URL || ""); -const GITLAB_PROJECT_ID = process.env.GITLAB_PROJECT_ID; -const GITLAB_ALLOWED_PROJECT_IDS = process.env.GITLAB_ALLOWED_PROJECT_IDS?.split(',').map(id => id.trim()).filter(Boolean) || []; - -if (!GITLAB_PERSONAL_ACCESS_TOKEN) { - logger.error("GITLAB_PERSONAL_ACCESS_TOKEN environment variable is not set"); - process.exit(1); -} - -/** - * Utility function for handling GitLab API errors - * API ์—๋Ÿฌ ์ฒ˜๋ฆฌ๋ฅผ ์œ„ํ•œ ์œ ํ‹ธ๋ฆฌํ‹ฐ ํ•จ์ˆ˜ (Utility function for handling API errors) - * - * @param {import("node-fetch").Response} response - The response from GitLab API - * @throws {Error} Throws an error with response details if the request failed - */ -async function handleGitLabError(response: import("node-fetch").Response): Promise { - if (!response.ok) { - const errorBody = await response.text(); - // Check specifically for Rate Limit error - if (response.status === 403 && errorBody.includes("User API Key Rate limit exceeded")) { - logger.error("GitLab API Rate Limit Exceeded:", errorBody); - logger.error("User API Key Rate limit exceeded. Please try again later."); - throw new Error(`GitLab API Rate Limit Exceeded: ${errorBody}`); - } else { - // Handle other API errors - throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`); - } - } -} - -/** - * @param {string} projectId - The project ID parameter passed to the function - * @returns {string} The project ID to use for the API call - * @throws {Error} If GITLAB_ALLOWED_PROJECT_IDS is set and the requested project is not in the whitelist - */ -function getEffectiveProjectId(projectId: string): string { - if (GITLAB_ALLOWED_PROJECT_IDS.length > 0) { - // If there's only one allowed project, use it as default - if (GITLAB_ALLOWED_PROJECT_IDS.length === 1 && !projectId) { - return GITLAB_ALLOWED_PROJECT_IDS[0]; - } - - // If a project ID is provided, check if it's in the whitelist - if (projectId && !GITLAB_ALLOWED_PROJECT_IDS.includes(projectId)) { - throw new Error(`Access denied: Project ${projectId} is not in the allowed project list: ${GITLAB_ALLOWED_PROJECT_IDS.join(', ')}`); - } - - // If no project ID provided but we have multiple allowed projects, require an explicit choice - if (!projectId && GITLAB_ALLOWED_PROJECT_IDS.length > 1) { - throw new Error(`Multiple projects allowed (${GITLAB_ALLOWED_PROJECT_IDS.join(', ')}). Please specify a project ID.`); - } - - return projectId || GITLAB_ALLOWED_PROJECT_IDS[0]; - } - return GITLAB_PROJECT_ID || projectId; -} - -/** - * Create a fork of a GitLab project - * ํ”„๋กœ์ ํŠธ ํฌํฌ ์ƒ์„ฑ (Create a project fork) - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {string} [namespace] - The namespace to fork the project to - * @returns {Promise} The created fork - */ -async function forkProject(projectId: string, namespace?: string): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const effectiveProjectId = getEffectiveProjectId(projectId); - const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/fork`); - - if (namespace) { - url.searchParams.append("namespace", namespace); - } - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "POST", - }); - - // ์ด๋ฏธ ์กด์žฌํ•˜๋Š” ํ”„๋กœ์ ํŠธ์ธ ๊ฒฝ์šฐ ์ฒ˜๋ฆฌ - if (response.status === 409) { - throw new Error("Project already exists in the target namespace"); - } - - await handleGitLabError(response); - const data = await response.json(); - return GitLabForkSchema.parse(data); -} - -/** - * Create a new branch in a GitLab project - * ์ƒˆ๋กœ์šด ๋ธŒ๋žœ์น˜ ์ƒ์„ฑ (Create a new branch) - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {z.infer} options - Branch creation options - * @returns {Promise} The created branch reference - */ -async function createBranch( - projectId: string, - options: z.infer -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const effectiveProjectId = getEffectiveProjectId(projectId); - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/repository/branches` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "POST", - body: JSON.stringify({ - branch: options.name, - ref: options.ref, - }), - }); - - await handleGitLabError(response); - return GitLabReferenceSchema.parse(await response.json()); -} - -/** - * Get the default branch for a GitLab project - * ํ”„๋กœ์ ํŠธ์˜ ๊ธฐ๋ณธ ๋ธŒ๋žœ์น˜ ์กฐํšŒ (Get the default branch of a project) - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @returns {Promise} The name of the default branch - */ -async function getDefaultBranchRef(projectId: string): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const effectiveProjectId = getEffectiveProjectId(projectId); - const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}`); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - await handleGitLabError(response); - const project = GitLabRepositorySchema.parse(await response.json()); - return project.default_branch ?? "main"; -} - -/** - * Get the contents of a file from a GitLab project - * ํŒŒ์ผ ๋‚ด์šฉ ์กฐํšŒ (Get file contents) - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {string} filePath - The path of the file to get - * @param {string} [ref] - The name of the branch, tag or commit - * @returns {Promise} The file content - */ -async function getFileContents( - projectId: string, - filePath: string, - ref?: string -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const effectiveProjectId = getEffectiveProjectId(projectId); - const encodedPath = encodeURIComponent(filePath); - - // ref๊ฐ€ ์—†๋Š” ๊ฒฝ์šฐ default branch๋ฅผ ๊ฐ€์ ธ์˜ด - if (!ref) { - ref = await getDefaultBranchRef(projectId); - } - - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/repository/files/${encodedPath}` - ); - - url.searchParams.append("ref", ref); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - // ํŒŒ์ผ์„ ์ฐพ์„ ์ˆ˜ ์—†๋Š” ๊ฒฝ์šฐ ์ฒ˜๋ฆฌ - if (response.status === 404) { - throw new Error(`File not found: ${filePath}`); - } - - await handleGitLabError(response); - const data = await response.json(); - const parsedData = GitLabContentSchema.parse(data); - - // Base64๋กœ ์ธ์ฝ”๋”ฉ๋œ ํŒŒ์ผ ๋‚ด์šฉ์„ UTF-8๋กœ ๋””์ฝ”๋”ฉ - if (!Array.isArray(parsedData) && parsedData.content) { - parsedData.content = Buffer.from(parsedData.content, "base64").toString("utf8"); - parsedData.encoding = "utf8"; - } - - return parsedData; -} - -/** - * Create a new issue in a GitLab project - * ์ด์Šˆ ์ƒ์„ฑ (Create an issue) - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {z.infer} options - Issue creation options - * @returns {Promise} The created issue - */ -async function createIssue( - projectId: string, - options: z.infer -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const effectiveProjectId = getEffectiveProjectId(projectId); - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/issues` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "POST", - body: JSON.stringify({ - title: options.title, - description: options.description, - assignee_ids: options.assignee_ids, - milestone_id: options.milestone_id, - labels: options.labels?.join(","), - }), - }); - - // ์ž˜๋ชป๋œ ์š”์ฒญ ์ฒ˜๋ฆฌ - if (response.status === 400) { - const errorBody = await response.text(); - throw new Error(`Invalid request: ${errorBody}`); - } - - await handleGitLabError(response); - const data = await response.json(); - return GitLabIssueSchema.parse(data); -} - -/** - * List issues across all accessible projects or within a specific project - * ํ”„๋กœ์ ํŠธ์˜ ์ด์Šˆ ๋ชฉ๋ก ์กฐํšŒ - * - * @param {string} projectId - The ID or URL-encoded path of the project (optional) - * @param {Object} options - Options for listing issues - * @returns {Promise} List of issues - */ -async function listIssues( - projectId?: string, - options: Omit, "project_id"> = {} -): Promise { - let url: URL; - if (projectId) { - projectId = decodeURIComponent(projectId); // Decode project ID - const effectiveProjectId = getEffectiveProjectId(projectId); - url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/issues`); - } else { - url = new URL(`${GITLAB_API_URL}/issues`); - } - - // Add all query parameters - Object.entries(options).forEach(([key, value]) => { - if (value !== undefined) { - const keys = ["labels", "assignee_username"]; - if (keys.includes(key)) { - if (Array.isArray(value)) { - // Handle array of labels - value.forEach(label => { - url.searchParams.append(`${key}[]`, label.toString()); - }); - } else if (value) { - url.searchParams.append(`${key}[]`, value.toString()); - } - } else { - url.searchParams.append(key, String(value)); - } - } - }); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - await handleGitLabError(response); - const data = await response.json(); - return z.array(GitLabIssueSchema).parse(data); -} - -/** - * List merge requests in a GitLab project with optional filtering - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {Object} options - Optional filtering parameters - * @returns {Promise} List of merge requests - */ -async function listMergeRequests( - projectId: string, - options: Omit, "project_id"> = {} -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests` - ); - - // Add all query parameters - Object.entries(options).forEach(([key, value]) => { - if (value !== undefined) { - if (key === "labels" && Array.isArray(value)) { - // Handle array of labels - url.searchParams.append(key, value.join(",")); - } else { - url.searchParams.append(key, String(value)); - } - } - }); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - await handleGitLabError(response); - const data = await response.json(); - return z.array(GitLabMergeRequestSchema).parse(data); -} - -/** - * Get a single issue from a GitLab project - * ๋‹จ์ผ ์ด์Šˆ ์กฐํšŒ - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} issueIid - The internal ID of the project issue - * @returns {Promise} The issue - */ -async function getIssue(projectId: string, issueIid: number | string): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/issues/${issueIid}` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - await handleGitLabError(response); - const data = await response.json(); - return GitLabIssueSchema.parse(data); -} - -/** - * Update an issue in a GitLab project - * ์ด์Šˆ ์—…๋ฐ์ดํŠธ - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} issueIid - The internal ID of the project issue - * @param {Object} options - Update options for the issue - * @returns {Promise} The updated issue - */ -async function updateIssue( - projectId: string, - issueIid: number | string, - options: Omit, "project_id" | "issue_iid"> -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/issues/${issueIid}` - ); - - // Convert labels array to comma-separated string if present - const body: Record = { ...options }; - if (body.labels && Array.isArray(body.labels)) { - body.labels = body.labels.join(","); - } - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "PUT", - body: JSON.stringify(body), - }); - - await handleGitLabError(response); - const data = await response.json(); - return GitLabIssueSchema.parse(data); -} - -/** - * Delete an issue from a GitLab project - * ์ด์Šˆ ์‚ญ์ œ - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} issueIid - The internal ID of the project issue - * @returns {Promise} - */ -async function deleteIssue(projectId: string, issueIid: number | string): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/issues/${issueIid}` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "DELETE", - }); - - await handleGitLabError(response); -} - -/** - * List all issue links for a specific issue - * ์ด์Šˆ ๊ด€๊ณ„ ๋ชฉ๋ก ์กฐํšŒ - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} issueIid - The internal ID of the project issue - * @returns {Promise} List of issues with link details - */ -async function listIssueLinks( - projectId: string, - issueIid: number | string -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/issues/${issueIid}/links` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - await handleGitLabError(response); - const data = await response.json(); - return z.array(GitLabIssueWithLinkDetailsSchema).parse(data); -} - -/** - * Get a specific issue link - * ํŠน์ • ์ด์Šˆ ๊ด€๊ณ„ ์กฐํšŒ - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} issueIid - The internal ID of the project issue - * @param {number} issueLinkId - The ID of the issue link - * @returns {Promise} The issue link - */ -async function getIssueLink( - projectId: string, - issueIid: number | string, - issueLinkId: number | string -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/issues/${issueIid}/links/${issueLinkId}` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - await handleGitLabError(response); - const data = await response.json(); - return GitLabIssueLinkSchema.parse(data); -} - -/** - * Create an issue link between two issues - * ์ด์Šˆ ๊ด€๊ณ„ ์ƒ์„ฑ - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} issueIid - The internal ID of the project issue - * @param {string} targetProjectId - The ID or URL-encoded path of the target project - * @param {number} targetIssueIid - The internal ID of the target project issue - * @param {string} linkType - The type of the relation (relates_to, blocks, is_blocked_by) - * @returns {Promise} The created issue link - */ -async function createIssueLink( - projectId: string, - issueIid: number | string, - targetProjectId: string, - targetIssueIid: number | string, - linkType: "relates_to" | "blocks" | "is_blocked_by" = "relates_to" -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - targetProjectId = decodeURIComponent(targetProjectId); // Decode target project ID as well - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/issues/${issueIid}/links` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "POST", - body: JSON.stringify({ - target_project_id: targetProjectId, - target_issue_iid: targetIssueIid, - link_type: linkType, - }), - }); - - await handleGitLabError(response); - const data = await response.json(); - return GitLabIssueLinkSchema.parse(data); -} - -/** - * Delete an issue link - * ์ด์Šˆ ๊ด€๊ณ„ ์‚ญ์ œ - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} issueIid - The internal ID of the project issue - * @param {number} issueLinkId - The ID of the issue link - * @returns {Promise} - */ -async function deleteIssueLink( - projectId: string, - issueIid: number | string, - issueLinkId: number | string -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/issues/${issueIid}/links/${issueLinkId}` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "DELETE", - }); - - await handleGitLabError(response); -} - -/** - * Create a new merge request in a GitLab project - * ๋ณ‘ํ•ฉ ์š”์ฒญ ์ƒ์„ฑ - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {z.infer} options - Merge request creation options - * @returns {Promise} The created merge request - */ -async function createMergeRequest( - projectId: string, - options: z.infer -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "POST", - body: JSON.stringify({ - title: options.title, - description: options.description, - source_branch: options.source_branch, - target_branch: options.target_branch, - target_project_id: options.target_project_id, - assignee_ids: options.assignee_ids, - reviewer_ids: options.reviewer_ids, - labels: options.labels?.join(","), - allow_collaboration: options.allow_collaboration, - draft: options.draft, - remove_source_branch: options.remove_source_branch, - squash: options.squash, - }), - }); - - if (response.status === 400) { - const errorBody = await response.text(); - throw new Error(`Invalid request: ${errorBody}`); - } - - if (!response.ok) { - const errorBody = await response.text(); - throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`); - } - - const data = await response.json(); - return GitLabMergeRequestSchema.parse(data); -} - -/** - * Shared helper function for listing discussions - * ํ† ๋ก  ๋ชฉ๋ก ์กฐํšŒ๋ฅผ ์œ„ํ•œ ๊ณต์œ  ํ—ฌํผ ํ•จ์ˆ˜ - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {"issues" | "merge_requests"} resourceType - The type of resource (issues or merge_requests) - * @param {number} resourceIid - The IID of the issue or merge request - * @param {PaginationOptions} options - Pagination and sorting options - * @returns {Promise} Paginated list of discussions - */ -async function listDiscussions( - projectId: string, - resourceType: "issues" | "merge_requests", - resourceIid: number | string, - options: PaginationOptions = {} -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/${resourceType}/${resourceIid}/discussions` - ); - - // Add query parameters for pagination and sorting - if (options.page) { - url.searchParams.append("page", options.page.toString()); - } - if (options.per_page) { - url.searchParams.append("per_page", options.per_page.toString()); - } - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - await handleGitLabError(response); - const discussions = await response.json(); - - // Extract pagination headers - const pagination = { - x_next_page: response.headers.get("x-next-page") - ? parseInt(response.headers.get("x-next-page")!) - : null, - x_page: response.headers.get("x-page") ? parseInt(response.headers.get("x-page")!) : undefined, - x_per_page: response.headers.get("x-per-page") - ? parseInt(response.headers.get("x-per-page")!) - : undefined, - x_prev_page: response.headers.get("x-prev-page") - ? parseInt(response.headers.get("x-prev-page")!) - : null, - x_total: response.headers.get("x-total") ? parseInt(response.headers.get("x-total")!) : null, - x_total_pages: response.headers.get("x-total-pages") - ? parseInt(response.headers.get("x-total-pages")!) - : null, - }; - - return PaginatedDiscussionsResponseSchema.parse({ - items: discussions, - pagination: pagination, - }); -} - -/** - * List merge request discussion items - * ๋ณ‘ํ•ฉ ์š”์ฒญ ํ† ๋ก  ๋ชฉ๋ก ์กฐํšŒ - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} mergeRequestIid - The IID of a merge request - * @param {DiscussionPaginationOptions} options - Pagination and sorting options - * @returns {Promise} List of discussions - */ -async function listMergeRequestDiscussions( - projectId: string, - mergeRequestIid: number | string, - options: PaginationOptions = {} -): Promise { - return listDiscussions(projectId, "merge_requests", mergeRequestIid, options); -} - -/** - * List discussions for an issue - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} issueIid - The internal ID of the project issue - * @param {DiscussionPaginationOptions} options - Pagination and sorting options - * @returns {Promise} List of issue discussions - */ -async function listIssueDiscussions( - projectId: string, - issueIid: number | string, - options: PaginationOptions = {} -): Promise { - return listDiscussions(projectId, "issues", issueIid, options); -} - -/** - * Modify an existing merge request thread note - * ๋ณ‘ํ•ฉ ์š”์ฒญ ํ† ๋ก  ๋…ธํŠธ ์ˆ˜์ • - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} mergeRequestIid - The IID of a merge request - * @param {string} discussionId - The ID of a thread - * @param {number} noteId - The ID of a thread note - * @param {string} body - The new content of the note - * @param {boolean} [resolved] - Resolve/unresolve state - * @returns {Promise} The updated note - */ -async function updateMergeRequestNote( - projectId: string, - mergeRequestIid: number | string, - discussionId: string, - noteId: number | string, - body?: string, - resolved?: boolean -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/merge_requests/${mergeRequestIid}/discussions/${discussionId}/notes/${noteId}` - ); - - // Only one of body or resolved can be sent according to GitLab API - const payload: { body?: string; resolved?: boolean } = {}; - if (body !== undefined) { - payload.body = body; - } else if (resolved !== undefined) { - payload.resolved = resolved; - } - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "PUT", - body: JSON.stringify(payload), - }); - - await handleGitLabError(response); - const data = await response.json(); - return GitLabDiscussionNoteSchema.parse(data); -} - -/** - * Update an issue discussion note - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} issueIid - The IID of an issue - * @param {string} discussionId - The ID of a thread - * @param {number} noteId - The ID of a thread note - * @param {string} body - The new content of the note - * @returns {Promise} The updated note - */ -async function updateIssueNote( - projectId: string, - issueIid: number | string, - discussionId: string, - noteId: number | string, - body: string -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/issues/${issueIid}/discussions/${discussionId}/notes/${noteId}` - ); - - const payload = { body }; - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "PUT", - body: JSON.stringify(payload), - }); - - await handleGitLabError(response); - const data = await response.json(); - return GitLabDiscussionNoteSchema.parse(data); -} - -/** - * Create a note in an issue discussion - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} issueIid - The IID of an issue - * @param {string} discussionId - The ID of a thread - * @param {string} body - The content of the new note - * @param {string} [createdAt] - The creation date of the note (ISO 8601 format) - * @returns {Promise} The created note - */ -async function createIssueNote( - projectId: string, - issueIid: number | string, - discussionId: string, - body: string, - createdAt?: string -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/issues/${issueIid}/discussions/${discussionId}/notes` - ); - - const payload: { body: string; created_at?: string } = { body }; - if (createdAt) { - payload.created_at = createdAt; - } - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "POST", - body: JSON.stringify(payload), - }); - - await handleGitLabError(response); - const data = await response.json(); - return GitLabDiscussionNoteSchema.parse(data); -} - -/** - * Add a new note to an existing merge request thread - * ๊ธฐ์กด ๋ณ‘ํ•ฉ ์š”์ฒญ ์Šค๋ ˆ๋“œ์— ์ƒˆ ๋…ธํŠธ ์ถ”๊ฐ€ - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} mergeRequestIid - The IID of a merge request - * @param {string} discussionId - The ID of a thread - * @param {string} body - The content of the new note - * @param {string} [createdAt] - The creation date of the note (ISO 8601 format) - * @returns {Promise} The created note - */ -async function createMergeRequestNote( - projectId: string, - mergeRequestIid: number | string, - discussionId: string, - body: string, - createdAt?: string -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/merge_requests/${mergeRequestIid}/discussions/${discussionId}/notes` - ); - - const payload: { body: string; created_at?: string } = { body }; - if (createdAt) { - payload.created_at = createdAt; - } - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "POST", - body: JSON.stringify(payload), - }); - - await handleGitLabError(response); - const data = await response.json(); - return GitLabDiscussionNoteSchema.parse(data); -} - -/** - * Create or update a file in a GitLab project - * ํŒŒ์ผ ์ƒ์„ฑ ๋˜๋Š” ์—…๋ฐ์ดํŠธ - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {string} filePath - The path of the file to create or update - * @param {string} content - The content of the file - * @param {string} commitMessage - The commit message - * @param {string} branch - The branch name - * @param {string} [previousPath] - The previous path of the file in case of rename - * @returns {Promise} The file update response - */ -async function createOrUpdateFile( - projectId: string, - filePath: string, - content: string, - commitMessage: string, - branch: string, - previousPath?: string, - last_commit_id?: string, - commit_id?: string -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const encodedPath = encodeURIComponent(filePath); - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/repository/files/${encodedPath}` - ); - - const body: Record = { - branch, - content, - commit_message: commitMessage, - encoding: "text", - ...(previousPath ? { previous_path: previousPath } : {}), - }; - - // Check if file exists - let method = "POST"; - try { - // Get file contents to check existence and retrieve commit IDs - const fileData = await getFileContents(projectId, filePath, branch); - method = "PUT"; - - // If fileData is not an array, it's a file content object with commit IDs - if (!Array.isArray(fileData)) { - // Use commit IDs from the file data if not provided in parameters - if (!commit_id && fileData.commit_id) { - body.commit_id = fileData.commit_id; - } else if (commit_id) { - body.commit_id = commit_id; - } - - if (!last_commit_id && fileData.last_commit_id) { - body.last_commit_id = fileData.last_commit_id; - } else if (last_commit_id) { - body.last_commit_id = last_commit_id; - } - } - } catch (error) { - if (!(error instanceof Error && error.message.includes("File not found"))) { - throw error; - } - // File doesn't exist, use POST - no need for commit IDs for new files - // But still use any provided as parameters if they exist - if (commit_id) { - body.commit_id = commit_id; - } - if (last_commit_id) { - body.last_commit_id = last_commit_id; - } - } - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method, - body: JSON.stringify(body), - }); - - if (!response.ok) { - const errorBody = await response.text(); - throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`); - } - - const data = await response.json(); - return GitLabCreateUpdateFileResponseSchema.parse(data); -} - -/** - * Create a tree structure in a GitLab project repository - * ์ €์žฅ์†Œ์— ํŠธ๋ฆฌ ๊ตฌ์กฐ ์ƒ์„ฑ - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {FileOperation[]} files - Array of file operations - * @param {string} [ref] - The name of the branch, tag or commit - * @returns {Promise} The created tree - */ -async function createTree( - projectId: string, - files: FileOperation[], - ref?: string -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/repository/tree` - ); - - if (ref) { - url.searchParams.append("ref", ref); - } - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "POST", - body: JSON.stringify({ - files: files.map(file => ({ - file_path: file.path, - content: file.content, - encoding: "text", - })), - }), - }); - - if (response.status === 400) { - const errorBody = await response.text(); - throw new Error(`Invalid request: ${errorBody}`); - } - - if (!response.ok) { - const errorBody = await response.text(); - throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`); - } - - const data = await response.json(); - return GitLabTreeSchema.parse(data); -} - -/** - * Create a commit in a GitLab project repository - * ์ €์žฅ์†Œ์— ์ปค๋ฐ‹ ์ƒ์„ฑ - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {string} message - The commit message - * @param {string} branch - The branch name - * @param {FileOperation[]} actions - Array of file operations for the commit - * @returns {Promise} The created commit - */ -async function createCommit( - projectId: string, - message: string, - branch: string, - actions: FileOperation[] -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/repository/commits` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "POST", - body: JSON.stringify({ - branch, - commit_message: message, - actions: actions.map(action => ({ - action: "create", - file_path: action.path, - content: action.content, - encoding: "text", - })), - }), - }); - - if (response.status === 400) { - const errorBody = await response.text(); - throw new Error(`Invalid request: ${errorBody}`); - } - - if (!response.ok) { - const errorBody = await response.text(); - throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`); - } - - const data = await response.json(); - return GitLabCommitSchema.parse(data); -} - -/** - * Search for GitLab projects - * ํ”„๋กœ์ ํŠธ ๊ฒ€์ƒ‰ - * - * @param {string} query - The search query - * @param {number} [page=1] - The page number - * @param {number} [perPage=20] - Number of items per page - * @returns {Promise} The search results - */ -async function searchProjects( - query: string, - page: number = 1, - perPage: number = 20 -): Promise { - const url = new URL(`${GITLAB_API_URL}/projects`); - url.searchParams.append("search", query); - url.searchParams.append("page", page.toString()); - url.searchParams.append("per_page", perPage.toString()); - url.searchParams.append("order_by", "id"); - url.searchParams.append("sort", "desc"); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - if (!response.ok) { - const errorBody = await response.text(); - throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`); - } - - const projects = (await response.json()) as GitLabRepository[]; - const totalCount = response.headers.get("x-total"); - const totalPages = response.headers.get("x-total-pages"); - - // GitLab API doesn't return these headers for results > 10,000 - const count = totalCount ? parseInt(totalCount) : projects.length; - - return GitLabSearchResponseSchema.parse({ - count, - total_pages: totalPages ? parseInt(totalPages) : Math.ceil(count / perPage), - current_page: page, - items: projects, - }); -} - -/** - * Create a new GitLab repository - * ์ƒˆ ์ €์žฅ์†Œ ์ƒ์„ฑ - * - * @param {z.infer} options - Repository creation options - * @returns {Promise} The created repository - */ -async function createRepository( - options: z.infer -): Promise { - const response = await fetch(`${GITLAB_API_URL}/projects`, { - ...DEFAULT_FETCH_CONFIG, - method: "POST", - body: JSON.stringify({ - name: options.name, - description: options.description, - visibility: options.visibility, - initialize_with_readme: options.initialize_with_readme, - default_branch: "main", - path: options.name.toLowerCase().replace(/\s+/g, "-"), - }), - }); - - if (!response.ok) { - const errorBody = await response.text(); - throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`); - } - - const data = await response.json(); - return GitLabRepositorySchema.parse(data); -} - -/** - * Get merge request details - * MR ์กฐํšŒ ํ•จ์ˆ˜ (Function to retrieve merge request) - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} mergeRequestIid - The internal ID of the merge request (Optional) - * @param {string} [branchName] - The name of the branch to search for merge request by branch name (Optional) - * @returns {Promise} The merge request details - */ -async function getMergeRequest( - projectId: string, - mergeRequestIid?: number | string, - branchName?: string -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - let url: URL; - - if (mergeRequestIid) { - url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/merge_requests/${mergeRequestIid}` - ); - } else if (branchName) { - url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/merge_requests?source_branch=${encodeURIComponent(branchName)}` - ); - } else { - throw new Error("Either mergeRequestIid or branchName must be provided"); - } - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - await handleGitLabError(response); - - const data = await response.json(); - - // If response is an array (Comes from branchName search), return the first item if exist - if (Array.isArray(data) && data.length > 0) { - return GitLabMergeRequestSchema.parse(data[0]); - } - - return GitLabMergeRequestSchema.parse(data); -} - -/** - * Get merge request changes/diffs - * MR ๋ณ€๊ฒฝ์‚ฌํ•ญ ์กฐํšŒ ํ•จ์ˆ˜ (Function to retrieve merge request changes) - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} mergeRequestIid - The internal ID of the merge request (Either mergeRequestIid or branchName must be provided) - * @param {string} [branchName] - The name of the branch to search for merge request by branch name (Either mergeRequestIid or branchName must be provided) - * @param {string} [view] - The view type for the diff (inline or parallel) - * @returns {Promise} The merge request diffs - */ -async function getMergeRequestDiffs( - projectId: string, - mergeRequestIid?: number | string, - branchName?: string, - view?: "inline" | "parallel" -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - if (!mergeRequestIid && !branchName) { - throw new Error("Either mergeRequestIid or branchName must be provided"); - } - - if (branchName && !mergeRequestIid) { - const mergeRequest = await getMergeRequest(projectId, undefined, branchName); - mergeRequestIid = mergeRequest.iid; - } - - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/merge_requests/${mergeRequestIid}/changes` - ); - - if (view) { - url.searchParams.append("view", view); - } - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - await handleGitLabError(response); - const data = (await response.json()) as { changes: unknown }; - return z.array(GitLabDiffSchema).parse(data.changes); -} - -/** - * Get merge request changes with detailed information including commits, diff_refs, and more - * ๋งˆ์ง€๋ง‰์œผ๋กœ ์ถ”๊ฐ€๋œ ์ƒ์„ธํ•œ MR ๋ณ€๊ฒฝ์‚ฌํ•ญ ์กฐํšŒ ํ•จ์ˆ˜ (Detailed merge request changes retrieval function) - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} mergeRequestIid - The internal ID of the merge request (Either mergeRequestIid or branchName must be provided) - * @param {string} [branchName] - The name of the branch to search for merge request by branch name (Either mergeRequestIid or branchName must be provided) - * @param {boolean} [unidiff] - Return diff in unidiff format - * @returns {Promise} The complete merge request changes response - */ -async function listMergeRequestDiffs( - projectId: string, - mergeRequestIid?: number | string, - branchName?: string, - page?: number, - perPage?: number, - unidiff?: boolean -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - if (!mergeRequestIid && !branchName) { - throw new Error("Either mergeRequestIid or branchName must be provided"); - } - - if (branchName && !mergeRequestIid) { - const mergeRequest = await getMergeRequest(projectId, undefined, branchName); - mergeRequestIid = mergeRequest.iid; - } - - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/merge_requests/${mergeRequestIid}/diffs` - ); - - if (page) { - url.searchParams.append("page", page.toString()); - } - - if (perPage) { - url.searchParams.append("per_page", perPage.toString()); - } - - if (unidiff) { - url.searchParams.append("unidiff", "true"); - } - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - await handleGitLabError(response); - return await response.json(); // Return full response including commits, diff_refs, changes, etc. -} - -/** - * Get branch comparison diffs - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {string} from - The branch name or commit SHA to compare from - * @param {string} to - The branch name or commit SHA to compare to - * @param {boolean} [straight] - Comparison method: false for '...' (default), true for '--' - * @returns {Promise} Branch comparison results - */ -async function getBranchDiffs( - projectId: string, - from: string, - to: string, - straight?: boolean -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/repository/compare` - ); - - url.searchParams.append("from", from); - url.searchParams.append("to", to); - - if (straight !== undefined) { - url.searchParams.append("straight", straight.toString()); - } - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - if (!response.ok) { - const errorBody = await response.text(); - throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`); - } - - const data = await response.json(); - return GitLabCompareResultSchema.parse(data); -} - -/** - * Update a merge request - * MR ์—…๋ฐ์ดํŠธ ํ•จ์ˆ˜ (Function to update merge request) - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} mergeRequestIid - The internal ID of the merge request (Optional) - * @param {string} branchName - The name of the branch to search for merge request by branch name (Optional) - * @param {Object} options - The update options - * @returns {Promise} The updated merge request - */ -async function updateMergeRequest( - projectId: string, - options: Omit< - z.infer, - "project_id" | "merge_request_iid" | "source_branch" - >, - mergeRequestIid?: number | string, - branchName?: string -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - if (!mergeRequestIid && !branchName) { - throw new Error("Either mergeRequestIid or branchName must be provided"); - } - - if (branchName && !mergeRequestIid) { - const mergeRequest = await getMergeRequest(projectId, undefined, branchName); - mergeRequestIid = mergeRequest.iid; - } - - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "PUT", - body: JSON.stringify(options), - }); - - await handleGitLabError(response); - return GitLabMergeRequestSchema.parse(await response.json()); -} - -/** - * Merge a merge request - * ใƒžใƒผใ‚ธใƒชใ‚ฏใ‚จใ‚นใƒˆใ‚’ใƒžใƒผใ‚ธใ™ใ‚‹ - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} mergeRequestIid - The internal ID of the merge request - * @param {Object} options - Options for merging the merge request - * @returns {Promise} The merged merge request - */ -async function mergeMergeRequest( - projectId: string, - options: Omit, "project_id" | "merge_request_iid">, - mergeRequestIid?: number | string -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/merge` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "PUT", - body: JSON.stringify(options), - }); - - await handleGitLabError(response); - return GitLabMergeRequestSchema.parse(await response.json()); -} - -/** - * Create a new note (comment) on an issue or merge request - * ๐Ÿ“ฆ ์ƒˆ๋กœ์šด ํ•จ์ˆ˜: createNote - ์ด์Šˆ ๋˜๋Š” ๋ณ‘ํ•ฉ ์š”์ฒญ์— ๋…ธํŠธ(๋Œ“๊ธ€)๋ฅผ ์ถ”๊ฐ€ํ•˜๋Š” ํ•จ์ˆ˜ - * (New function: createNote - Function to add a note (comment) to an issue or merge request) - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {"issue" | "merge_request"} noteableType - The type of the item to add a note to (issue or merge_request) - * @param {number} noteableIid - The internal ID of the issue or merge request - * @param {string} body - The content of the note - * @returns {Promise} The created note - */ -async function createNote( - projectId: string, - noteableType: "issue" | "merge_request", // 'issue' ๋˜๋Š” 'merge_request' ํƒ€์ž… ๋ช…์‹œ - noteableIid: number | string, - body: string -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - // โš™๏ธ ์‘๋‹ต ํƒ€์ž…์€ GitLab API ๋ฌธ์„œ์— ๋”ฐ๋ผ ์กฐ์ • ๊ฐ€๋Šฅ - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/${noteableType}s/${noteableIid}/notes` // Using plural form (issues/merge_requests) as per GitLab API documentation - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "POST", - body: JSON.stringify({ body }), - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`); - } - - return await response.json(); -} - -/** - * List draft notes for a merge request - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number|string} mergeRequestIid - The internal ID of the merge request - * @returns {Promise} Array of draft notes - */ -async function getDraftNote( - project_id: string, - merge_request_iid: string, - draft_note_id: string -): Promise { - const response = await fetch( - `/projects/${encodeURIComponent(project_id)}/merge_requests/${merge_request_iid}/draft_notes/${draft_note_id}` - ); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`); - } - - const data = await response.json(); - return GitLabDraftNoteSchema.parse(data); -} - -async function listDraftNotes( - projectId: string, - mergeRequestIid: number | string -): Promise { - projectId = decodeURIComponent(projectId); - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/merge_requests/${mergeRequestIid}/draft_notes` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "GET", - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`); - } - - const data = await response.json(); - return z.array(GitLabDraftNoteSchema).parse(data); -} - -/** - * Create a draft note for a merge request - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number|string} mergeRequestIid - The internal ID of the merge request - * @param {string} body - The content of the draft note - * @param {MergeRequestThreadPosition} [position] - Position information for diff notes - * @param {boolean} [resolveDiscussion] - Whether to resolve the discussion when publishing - * @returns {Promise} The created draft note - */ -async function createDraftNote( - projectId: string, - mergeRequestIid: number | string, - body: string, - position?: MergeRequestThreadPositionCreate, - resolveDiscussion?: boolean -): Promise { - projectId = decodeURIComponent(projectId); - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/merge_requests/${mergeRequestIid}/draft_notes` - ); - - const requestBody: any = { note: body }; - if (position) { - requestBody.position = position; - } - if (resolveDiscussion !== undefined) { - requestBody.resolve_discussion = resolveDiscussion; - } - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "POST", - body: JSON.stringify(requestBody), - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`); - } - - const data = await response.json(); - return GitLabDraftNoteSchema.parse(data); -} - -/** - * Update an existing draft note - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number|string} mergeRequestIid - The internal ID of the merge request - * @param {number|string} draftNoteId - The ID of the draft note - * @param {string} [body] - The updated content of the draft note - * @param {MergeRequestThreadPosition} [position] - Updated position information - * @param {boolean} [resolveDiscussion] - Whether to resolve the discussion when publishing - * @returns {Promise} The updated draft note - */ -async function updateDraftNote( - projectId: string, - mergeRequestIid: number | string, - draftNoteId: number | string, - body?: string, - position?: MergeRequestThreadPositionCreate, - resolveDiscussion?: boolean -): Promise { - projectId = decodeURIComponent(projectId); - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/merge_requests/${mergeRequestIid}/draft_notes/${draftNoteId}` - ); - - const requestBody: any = {}; - if (body !== undefined) { - requestBody.note = body; - } - if (position) { - requestBody.position = position; - } - if (resolveDiscussion !== undefined) { - requestBody.resolve_discussion = resolveDiscussion; - } - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "PUT", - body: JSON.stringify(requestBody), - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`); - } - - const data = await response.json(); - return GitLabDraftNoteSchema.parse(data); -} - -/** - * Delete a draft note - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number|string} mergeRequestIid - The internal ID of the merge request - * @param {number|string} draftNoteId - The ID of the draft note - * @returns {Promise} - */ -async function deleteDraftNote( - projectId: string, - mergeRequestIid: number | string, - draftNoteId: number | string -): Promise { - projectId = decodeURIComponent(projectId); - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/merge_requests/${mergeRequestIid}/draft_notes/${draftNoteId}` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "DELETE", - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`); - } -} - -/** - * Publish a single draft note - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number|string} mergeRequestIid - The internal ID of the merge request - * @param {number|string} draftNoteId - The ID of the draft note - * @returns {Promise} The published note - */ -async function publishDraftNote( - projectId: string, - mergeRequestIid: number | string, - draftNoteId: number | string -): Promise { - projectId = decodeURIComponent(projectId); - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/merge_requests/${mergeRequestIid}/draft_notes/${draftNoteId}/publish` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "PUT", - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`); - } - - // Handle empty response (204 No Content) or successful response - const responseText = await response.text(); - if (!responseText || responseText.trim() === '') { - // Return a success indicator for empty responses - return { - id: draftNoteId.toString(), - body: "Draft note published successfully", - author: { id: "unknown", username: "unknown" }, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - system: false, - noteable_id: mergeRequestIid.toString(), - noteable_type: "MergeRequest" - } as any; - } - - try { - const data = JSON.parse(responseText); - return GitLabDiscussionNoteSchema.parse(data); - } catch (parseError) { - // If JSON parsing fails but the operation was successful (2xx status), - // return a success indicator - console.warn(`JSON parse error for successful publish operation: ${parseError}`); - return { - id: draftNoteId.toString(), - body: "Draft note published successfully (response parse error)", - author: { id: "unknown", username: "unknown" }, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - system: false, - noteable_id: mergeRequestIid.toString(), - noteable_type: "MergeRequest" - } as any; - } -} - -/** - * Publish all draft notes for a merge request - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number|string} mergeRequestIid - The internal ID of the merge request - * @returns {Promise} Array of published notes - */ -async function bulkPublishDraftNotes( - projectId: string, - mergeRequestIid: number | string -): Promise { - projectId = decodeURIComponent(projectId); - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/merge_requests/${mergeRequestIid}/draft_notes/bulk_publish` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "POST", // Changed from PUT to POST - body: JSON.stringify({}), // Send empty body for POST request - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`); - } - - // Handle empty response (204 No Content) or successful response - const responseText = await response.text(); - if (!responseText || responseText.trim() === '') { - // Return empty array for successful bulk publish with no content - return []; - } - - try { - const data = JSON.parse(responseText); - return z.array(GitLabDiscussionNoteSchema).parse(data); - } catch (parseError) { - // If JSON parsing fails but the operation was successful (2xx status), - // return empty array indicating successful bulk publish - console.warn(`JSON parse error for successful bulk publish operation: ${parseError}`); - return []; - } -} - -/** - * Create a new thread on a merge request - * ๐Ÿ“ฆ ์ƒˆ๋กœ์šด ํ•จ์ˆ˜: createMergeRequestThread - ๋ณ‘ํ•ฉ ์š”์ฒญ์— ์ƒˆ๋กœ์šด ์Šค๋ ˆ๋“œ(ํ† ๋ก )๋ฅผ ์ƒ์„ฑํ•˜๋Š” ํ•จ์ˆ˜ - * (New function: createMergeRequestThread - Function to create a new thread (discussion) on a merge request) - * - * This function provides more capabilities than createNote, including the ability to: - * - Create diff notes (comments on specific lines of code) - * - Specify exact positions for comments - * - Set creation timestamps - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} mergeRequestIid - The internal ID of the merge request - * @param {string} body - The content of the thread - * @param {MergeRequestThreadPosition} [position] - Position information for diff notes - * @param {string} [createdAt] - ISO 8601 formatted creation date - * @returns {Promise} The created discussion thread - */ -async function createMergeRequestThread( - projectId: string, - mergeRequestIid: number | string, - body: string, - position?: MergeRequestThreadPosition, - createdAt?: string -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/merge_requests/${mergeRequestIid}/discussions` - ); - - const payload: Record = { body }; - - // Add optional parameters if provided - if (position) { - payload.position = position; - } - - if (createdAt) { - payload.created_at = createdAt; - } - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "POST", - body: JSON.stringify(payload), - }); - - await handleGitLabError(response); - const data = await response.json(); - return GitLabDiscussionSchema.parse(data); -} - -/** - * List all namespaces - * ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ๋ชจ๋“  ๋„ค์ž„์ŠคํŽ˜์ด์Šค ๋ชฉ๋ก ์กฐํšŒ - * - * @param {Object} options - Options for listing namespaces - * @param {string} [options.search] - Search query to filter namespaces - * @param {boolean} [options.owned_only] - Only return namespaces owned by the authenticated user - * @param {boolean} [options.top_level_only] - Only return top-level namespaces - * @returns {Promise} List of namespaces - */ -async function listNamespaces(options: { - search?: string; - owned_only?: boolean; - top_level_only?: boolean; -}): Promise { - const url = new URL(`${GITLAB_API_URL}/namespaces`); - - if (options.search) { - url.searchParams.append("search", options.search); - } - - if (options.owned_only) { - url.searchParams.append("owned_only", "true"); - } - - if (options.top_level_only) { - url.searchParams.append("top_level_only", "true"); - } - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - await handleGitLabError(response); - const data = await response.json(); - return z.array(GitLabNamespaceSchema).parse(data); -} - -/** - * Get details on a namespace - * ๋„ค์ž„์ŠคํŽ˜์ด์Šค ์ƒ์„ธ ์ •๋ณด ์กฐํšŒ - * - * @param {string} id - The ID or URL-encoded path of the namespace - * @returns {Promise} The namespace details - */ -async function getNamespace(id: string): Promise { - const url = new URL(`${GITLAB_API_URL}/namespaces/${encodeURIComponent(id)}`); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - await handleGitLabError(response); - const data = await response.json(); - return GitLabNamespaceSchema.parse(data); -} - -/** - * Verify if a namespace exists - * ๋„ค์ž„์ŠคํŽ˜์ด์Šค ์กด์žฌ ์—ฌ๋ถ€ ํ™•์ธ - * - * @param {string} namespacePath - The path of the namespace to check - * @param {number} [parentId] - The ID of the parent namespace - * @returns {Promise} The verification result - */ -async function verifyNamespaceExistence( - namespacePath: string, - parentId?: number -): Promise { - const url = new URL(`${GITLAB_API_URL}/namespaces/${encodeURIComponent(namespacePath)}/exists`); - - if (parentId) { - url.searchParams.append("parent_id", parentId.toString()); - } - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - await handleGitLabError(response); - const data = await response.json(); - return GitLabNamespaceExistsResponseSchema.parse(data); -} - -/** - * Get a single project - * ๋‹จ์ผ ํ”„๋กœ์ ํŠธ ์กฐํšŒ - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {Object} options - Options for getting project details - * @param {boolean} [options.license] - Include project license data - * @param {boolean} [options.statistics] - Include project statistics - * @param {boolean} [options.with_custom_attributes] - Include custom attributes in response - * @returns {Promise} Project details - */ -async function getProject( - projectId: string, - options: { - license?: boolean; - statistics?: boolean; - with_custom_attributes?: boolean; - } = {} -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}` - ); - - if (options.license) { - url.searchParams.append("license", "true"); - } - - if (options.statistics) { - url.searchParams.append("statistics", "true"); - } - - if (options.with_custom_attributes) { - url.searchParams.append("with_custom_attributes", "true"); - } - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - await handleGitLabError(response); - const data = await response.json(); - return GitLabRepositorySchema.parse(data); -} - -/** - * List projects - * ํ”„๋กœ์ ํŠธ ๋ชฉ๋ก ์กฐํšŒ - * - * @param {Object} options - Options for listing projects - * @returns {Promise} List of projects - */ -async function listProjects( - options: z.infer = {} -): Promise { - // Construct the query parameters - const params = new URLSearchParams(); - for (const [key, value] of Object.entries(options)) { - if (value !== undefined && value !== null) { - if (typeof value === "boolean") { - params.append(key, value ? "true" : "false"); - } else { - params.append(key, String(value)); - } - } - } - - // Make the API request - const response = await fetch(`${GITLAB_API_URL}/projects?${params.toString()}`, { - ...DEFAULT_FETCH_CONFIG, - }); - - // Handle errors - await handleGitLabError(response); - - // Parse and return the data - const data = await response.json(); - return z.array(GitLabProjectSchema).parse(data); -} - -/** - * List labels for a project - * - * @param projectId The ID or URL-encoded path of the project - * @param options Optional parameters for listing labels - * @returns Array of GitLab labels - */ -async function listLabels( - projectId: string, - options: Omit, "project_id"> = {} -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - // Construct the URL with project path - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/labels` - ); - - // Add query parameters - Object.entries(options).forEach(([key, value]) => { - if (value !== undefined) { - if (typeof value === "boolean") { - url.searchParams.append(key, value ? "true" : "false"); - } else { - url.searchParams.append(key, String(value)); - } - } - }); - - // Make the API request - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - // Handle errors - await handleGitLabError(response); - - // Parse and return the data - const data = await response.json(); - return data as GitLabLabel[]; -} - -/** - * Get a single label from a project - * - * @param projectId The ID or URL-encoded path of the project - * @param labelId The ID or name of the label - * @param includeAncestorGroups Whether to include ancestor groups - * @returns GitLab label - */ -async function getLabel( - projectId: string, - labelId: number | string, - includeAncestorGroups?: boolean -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/labels/${encodeURIComponent(String(labelId))}` - ); - - // Add query parameters - if (includeAncestorGroups !== undefined) { - url.searchParams.append("include_ancestor_groups", includeAncestorGroups ? "true" : "false"); - } - - // Make the API request - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - // Handle errors - await handleGitLabError(response); - - // Parse and return the data - const data = await response.json(); - return data as GitLabLabel; -} - -/** - * Create a new label in a project - * - * @param projectId The ID or URL-encoded path of the project - * @param options Options for creating the label - * @returns Created GitLab label - */ -async function createLabel( - projectId: string, - options: Omit, "project_id"> -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - // Make the API request - const response = await fetch( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/labels`, - { - ...DEFAULT_FETCH_CONFIG, - method: "POST", - body: JSON.stringify(options), - } - ); - - // Handle errors - await handleGitLabError(response); - - // Parse and return the data - const data = await response.json(); - return data as GitLabLabel; -} - -/** - * Update an existing label in a project - * - * @param projectId The ID or URL-encoded path of the project - * @param labelId The ID or name of the label to update - * @param options Options for updating the label - * @returns Updated GitLab label - */ -async function updateLabel( - projectId: string, - labelId: number | string, - options: Omit, "project_id" | "label_id"> -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - // Make the API request - const response = await fetch( - `${GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/labels/${encodeURIComponent(String(labelId))}`, - { - ...DEFAULT_FETCH_CONFIG, - method: "PUT", - body: JSON.stringify(options), - } - ); - - // Handle errors - await handleGitLabError(response); - - // Parse and return the data - const data = await response.json(); - return data as GitLabLabel; -} - -/** - * Delete a label from a project - * - * @param projectId The ID or URL-encoded path of the project - * @param labelId The ID or name of the label to delete - */ -async function deleteLabel(projectId: string, labelId: number | string): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - // Make the API request - const response = await fetch( - `${GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/labels/${encodeURIComponent(String(labelId))}`, - { - ...DEFAULT_FETCH_CONFIG, - method: "DELETE", - } - ); - - // Handle errors - await handleGitLabError(response); -} - -/** - * List all projects in a GitLab group - * - * @param {z.infer} options - Options for listing group projects - * @returns {Promise} Array of projects in the group - */ -async function listGroupProjects( - options: z.infer -): Promise { - const url = new URL(`${GITLAB_API_URL}/groups/${encodeURIComponent(options.group_id)}/projects`); - - // Add optional parameters to URL - if (options.include_subgroups) url.searchParams.append("include_subgroups", "true"); - if (options.search) url.searchParams.append("search", options.search); - if (options.order_by) url.searchParams.append("order_by", options.order_by); - if (options.sort) url.searchParams.append("sort", options.sort); - if (options.page) url.searchParams.append("page", options.page.toString()); - if (options.per_page) url.searchParams.append("per_page", options.per_page.toString()); - if (options.archived !== undefined) - url.searchParams.append("archived", options.archived.toString()); - if (options.visibility) url.searchParams.append("visibility", options.visibility); - if (options.with_issues_enabled !== undefined) - url.searchParams.append("with_issues_enabled", options.with_issues_enabled.toString()); - if (options.with_merge_requests_enabled !== undefined) - url.searchParams.append( - "with_merge_requests_enabled", - options.with_merge_requests_enabled.toString() - ); - if (options.min_access_level !== undefined) - url.searchParams.append("min_access_level", options.min_access_level.toString()); - if (options.with_programming_language) - url.searchParams.append("with_programming_language", options.with_programming_language); - if (options.starred !== undefined) url.searchParams.append("starred", options.starred.toString()); - if (options.statistics !== undefined) - url.searchParams.append("statistics", options.statistics.toString()); - if (options.with_custom_attributes !== undefined) - url.searchParams.append("with_custom_attributes", options.with_custom_attributes.toString()); - if (options.with_security_reports !== undefined) - url.searchParams.append("with_security_reports", options.with_security_reports.toString()); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - await handleGitLabError(response); - const projects = await response.json(); - return GitLabProjectSchema.array().parse(projects); -} - -// Wiki API helper functions -/** - * List wiki pages in a project - */ -async function listWikiPages( - projectId: string, - options: Omit = {} -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/wikis` - ); - if (options.page) url.searchParams.append("page", options.page.toString()); - if (options.per_page) url.searchParams.append("per_page", options.per_page.toString()); - if (options.with_content) - url.searchParams.append("with_content", options.with_content.toString()); - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - await handleGitLabError(response); - const data = await response.json(); - return GitLabWikiPageSchema.array().parse(data); -} - -/** - * Get a specific wiki page - */ -async function getWikiPage(projectId: string, slug: string): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const response = await fetch( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/wikis/${encodeURIComponent(slug)}`, - { ...DEFAULT_FETCH_CONFIG } - ); - await handleGitLabError(response); - const data = await response.json(); - return GitLabWikiPageSchema.parse(data); -} - -/** - * Create a new wiki page - */ -async function createWikiPage( - projectId: string, - title: string, - content: string, - format?: string -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const body: Record = { title, content }; - if (format) body.format = format; - const response = await fetch( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/wikis`, - { - ...DEFAULT_FETCH_CONFIG, - method: "POST", - body: JSON.stringify(body), - } - ); - await handleGitLabError(response); - const data = await response.json(); - return GitLabWikiPageSchema.parse(data); -} - -/** - * Update an existing wiki page - */ -async function updateWikiPage( - projectId: string, - slug: string, - title?: string, - content?: string, - format?: string -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const body: Record = {}; - if (title) body.title = title; - if (content) body.content = content; - if (format) body.format = format; - const response = await fetch( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/wikis/${encodeURIComponent(slug)}`, - { - ...DEFAULT_FETCH_CONFIG, - method: "PUT", - body: JSON.stringify(body), - } - ); - await handleGitLabError(response); - const data = await response.json(); - return GitLabWikiPageSchema.parse(data); -} - -/** - * Delete a wiki page - */ -async function deleteWikiPage(projectId: string, slug: string): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const response = await fetch( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/wikis/${encodeURIComponent(slug)}`, - { - ...DEFAULT_FETCH_CONFIG, - method: "DELETE", - } - ); - await handleGitLabError(response); -} - -/** - * List pipelines in a GitLab project - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {ListPipelinesOptions} options - Options for filtering pipelines - * @returns {Promise} List of pipelines - */ -async function listPipelines( - projectId: string, - options: Omit = {} -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines` - ); - - // Add all query parameters - Object.entries(options).forEach(([key, value]) => { - if (value !== undefined) { - url.searchParams.append(key, value.toString()); - } - }); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - await handleGitLabError(response); - const data = await response.json(); - return z.array(GitLabPipelineSchema).parse(data); -} - -/** - * Get details of a specific pipeline - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} pipelineId - The ID of the pipeline - * @returns {Promise} Pipeline details - */ -async function getPipeline( - projectId: string, - pipelineId: number | string -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${pipelineId}` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - if (response.status === 404) { - throw new Error(`Pipeline not found`); - } - - await handleGitLabError(response); - const data = await response.json(); - return GitLabPipelineSchema.parse(data); -} - -/** - * List all jobs in a specific pipeline - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} pipelineId - The ID of the pipeline - * @param {Object} options - Options for filtering jobs - * @returns {Promise} List of pipeline jobs - */ -async function listPipelineJobs( - projectId: string, - pipelineId: number | string, - options: Omit = {} -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${pipelineId}/jobs` - ); - - // Add all query parameters - Object.entries(options).forEach(([key, value]) => { - if (value !== undefined) { - if (typeof value === "boolean") { - url.searchParams.append(key, value ? "true" : "false"); - } else { - url.searchParams.append(key, value.toString()); - } - } - }); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - if (response.status === 404) { - throw new Error(`Pipeline not found`); - } - - await handleGitLabError(response); - const data = await response.json(); - return z.array(GitLabPipelineJobSchema).parse(data); -} - -/** - * List all trigger jobs (bridges) in a specific pipeline - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} pipelineId - The ID of the pipeline - * @param {Object} options - Options for filtering trigger jobs - * @returns {Promise} List of pipeline trigger jobs - */ -async function listPipelineTriggerJobs( - projectId: string, - pipelineId: number | string, - options: Omit = {} -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${pipelineId}/bridges` - ); - - // Add all query parameters - Object.entries(options).forEach(([key, value]) => { - if (value !== undefined) { - if (typeof value === "boolean") { - url.searchParams.append(key, value ? "true" : "false"); - } else { - url.searchParams.append(key, value.toString()); - } - } - }); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - if (response.status === 404) { - throw new Error(`Pipeline not found`); - } - - await handleGitLabError(response); - const data = await response.json(); - return z.array(GitLabPipelineTriggerJobSchema).parse(data); -} - -async function getPipelineJob( - projectId: string, - jobId: number | string -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/jobs/${jobId}` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - if (response.status === 404) { - throw new Error(`Job not found`); - } - - await handleGitLabError(response); - const data = await response.json(); - return GitLabPipelineJobSchema.parse(data); -} - -/** - * Get the output/trace of a pipeline job - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} jobId - The ID of the job - * @param {number} limit - Maximum number of lines to return from the end (default: 1000) - * @param {number} offset - Number of lines to skip from the end (default: 0) - * @returns {Promise} The job output/trace - */ -async function getPipelineJobOutput( - projectId: string, - jobId: number | string, - limit?: number, - offset?: number -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/jobs/${jobId}/trace` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - headers: { - ...DEFAULT_HEADERS, - Accept: "text/plain", // Override Accept header to get plain text - }, - }); - - if (response.status === 404) { - throw new Error(`Job trace not found or job is not finished yet`); - } - - await handleGitLabError(response); - const fullTrace = await response.text(); - - // Apply client-side pagination to limit context window usage - if (limit !== undefined || offset !== undefined) { - const lines = fullTrace.split("\n"); - const startOffset = offset || 0; - const maxLines = limit || 1000; - - // Return lines from the end, skipping offset lines and limiting to maxLines - const startIndex = Math.max(0, lines.length - startOffset - maxLines); - const endIndex = lines.length - startOffset; - - const selectedLines = lines.slice(startIndex, endIndex); - const result = selectedLines.join("\n"); - - // Add metadata about truncation - if (startIndex > 0 || endIndex < lines.length) { - const totalLines = lines.length; - const shownLines = selectedLines.length; - const skippedFromStart = startIndex; - const skippedFromEnd = startOffset; - - return `[Log truncated: showing ${shownLines} of ${totalLines} lines, skipped ${skippedFromStart} from start, ${skippedFromEnd} from end]\n\n${result}`; - } - - return result; - } - - return fullTrace; -} - -/** - * Create a new pipeline - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {string} ref - The branch or tag to run the pipeline on - * @param {Array} variables - Optional variables for the pipeline - * @returns {Promise} The created pipeline - */ -async function createPipeline( - projectId: string, - ref: string, - variables?: Array<{ key: string; value: string }> -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipeline` - ); - - const body: any = { ref }; - if (variables && variables.length > 0) { - body.variables = variables; - } - - const response = await fetch(url.toString(), { - method: "POST", - headers: DEFAULT_HEADERS, - body: JSON.stringify(body), - }); - - await handleGitLabError(response); - const data = await response.json(); - return GitLabPipelineSchema.parse(data); -} - -/** - * Retry a pipeline - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} pipelineId - The ID of the pipeline to retry - * @returns {Promise} The retried pipeline - */ -async function retryPipeline( - projectId: string, - pipelineId: number | string -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${pipelineId}/retry` - ); - - const response = await fetch(url.toString(), { - method: "POST", - headers: DEFAULT_HEADERS, - }); - - await handleGitLabError(response); - const data = await response.json(); - return GitLabPipelineSchema.parse(data); -} - -/** - * Cancel a pipeline - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} pipelineId - The ID of the pipeline to cancel - * @returns {Promise} The canceled pipeline - */ -async function cancelPipeline( - projectId: string, - pipelineId: number | string -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${pipelineId}/cancel` - ); - - const response = await fetch(url.toString(), { - method: "POST", - headers: DEFAULT_HEADERS, - }); - - await handleGitLabError(response); - const data = await response.json(); - return GitLabPipelineSchema.parse(data); -} - -/** - * Get the repository tree for a project - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {GetRepositoryTreeOptions} options - Options for the tree - * @returns {Promise} - */ -async function getRepositoryTree(options: GetRepositoryTreeOptions): Promise { - options.project_id = decodeURIComponent(options.project_id); // Decode project_id within options - const queryParams = new URLSearchParams(); - if (options.path) queryParams.append("path", options.path); - if (options.ref) queryParams.append("ref", options.ref); - if (options.recursive) queryParams.append("recursive", "true"); - if (options.per_page) queryParams.append("per_page", options.per_page.toString()); - if (options.page_token) queryParams.append("page_token", options.page_token); - if (options.pagination) queryParams.append("pagination", options.pagination); - - const headers: Record = { - "Content-Type": "application/json", - }; - if (IS_OLD) { - headers["Private-Token"] = `${GITLAB_PERSONAL_ACCESS_TOKEN}`; - } else { - headers["Authorization"] = `Bearer ${GITLAB_PERSONAL_ACCESS_TOKEN}`; - } - const response = await fetch( - `${GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(options.project_id) - )}/repository/tree?${queryParams.toString()}`, - { - headers, - } - ); - - if (response.status === 404) { - throw new Error("Repository or path not found"); - } - - if (!response.ok) { - throw new Error(`Failed to get repository tree: ${response.statusText}`); - } - - const data = await response.json(); - return z.array(GitLabTreeItemSchema).parse(data); -} - -/** - * List project milestones in a GitLab project - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {Object} options - Options for listing milestones - * @returns {Promise} List of milestones - */ -async function listProjectMilestones( - projectId: string, - options: Omit, "project_id"> -): Promise { - projectId = decodeURIComponent(projectId); - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/milestones` - ); - - Object.entries(options).forEach(([key, value]) => { - if (value !== undefined) { - if (key === "iids" && Array.isArray(value) && value.length > 0) { - value.forEach(iid => { - url.searchParams.append("iids[]", iid.toString()); - }); - } else if (value !== undefined) { - url.searchParams.append(key, value.toString()); - } - } - }); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - await handleGitLabError(response); - const data = await response.json(); - return z.array(GitLabMilestonesSchema).parse(data); -} - -/** - * Get a single milestone in a GitLab project - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} milestoneId - The ID of the milestone - * @returns {Promise} Milestone details - */ -async function getProjectMilestone( - projectId: string, - milestoneId: number | string -): Promise { - projectId = decodeURIComponent(projectId); - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/milestones/${milestoneId}` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - await handleGitLabError(response); - const data = await response.json(); - return GitLabMilestonesSchema.parse(data); -} - -/** - * Create a new milestone in a GitLab project - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {Object} options - Options for creating a milestone - * @returns {Promise} Created milestone - */ -async function createProjectMilestone( - projectId: string, - options: Omit, "project_id"> -): Promise { - projectId = decodeURIComponent(projectId); - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/milestones` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "POST", - body: JSON.stringify(options), - }); - await handleGitLabError(response); - const data = await response.json(); - return GitLabMilestonesSchema.parse(data); -} - -/** - * Edit an existing milestone in a GitLab project - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} milestoneId - The ID of the milestone - * @param {Object} options - Options for editing a milestone - * @returns {Promise} Updated milestone - */ -async function editProjectMilestone( - projectId: string, - milestoneId: number | string, - options: Omit, "project_id" | "milestone_id"> -): Promise { - projectId = decodeURIComponent(projectId); - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/milestones/${milestoneId}` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "PUT", - body: JSON.stringify(options), - }); - await handleGitLabError(response); - const data = await response.json(); - return GitLabMilestonesSchema.parse(data); -} - -/** - * Delete a milestone from a GitLab project - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} milestoneId - The ID of the milestone - * @returns {Promise} - */ -async function deleteProjectMilestone( - projectId: string, - milestoneId: number | string -): Promise { - projectId = decodeURIComponent(projectId); - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/milestones/${milestoneId}` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "DELETE", - }); - await handleGitLabError(response); -} - -/** - * Get all issues assigned to a single milestone - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} milestoneId - The ID of the milestone - * @returns {Promise} List of issues - */ -async function getMilestoneIssues( - projectId: string, - milestoneId: number | string -): Promise { - projectId = decodeURIComponent(projectId); - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/milestones/${milestoneId}/issues` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - await handleGitLabError(response); - const data = await response.json(); - return z.array(GitLabIssueSchema).parse(data); -} - -/** - * Get all merge requests assigned to a single milestone - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} milestoneId - The ID of the milestone - * @returns {Promise} List of merge requests - */ -async function getMilestoneMergeRequests( - projectId: string, - milestoneId: number | string -): Promise { - projectId = decodeURIComponent(projectId); - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/milestones/${milestoneId}/merge_requests` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - await handleGitLabError(response); - const data = await response.json(); - return z.array(GitLabMergeRequestSchema).parse(data); -} - -/** - * Promote a project milestone to a group milestone - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} milestoneId - The ID of the milestone - * @returns {Promise} Promoted milestone - */ -async function promoteProjectMilestone( - projectId: string, - milestoneId: number | string -): Promise { - projectId = decodeURIComponent(projectId); - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/milestones/${milestoneId}/promote` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "POST", - }); - await handleGitLabError(response); - const data = await response.json(); - return GitLabMilestonesSchema.parse(data); -} - -/** - * Get all burndown chart events for a single milestone - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} milestoneId - The ID of the milestone - * @returns {Promise} Burndown chart events - */ -async function getMilestoneBurndownEvents( - projectId: string, - milestoneId: number | string -): Promise { - projectId = decodeURIComponent(projectId); - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/milestones/${milestoneId}/burndown_events` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - await handleGitLabError(response); - const data = await response.json(); - return data as any[]; -} - -/** - * Get a single user from GitLab - * - * @param {string} username - The username to look up - * @returns {Promise} The user data or null if not found - */ -async function getUser(username: string): Promise { - try { - const url = new URL(`${GITLAB_API_URL}/users`); - url.searchParams.append("username", username); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - await handleGitLabError(response); - - const users = await response.json(); - - // GitLab returns an array of users that match the username - if (Array.isArray(users) && users.length > 0) { - // Find exact match for username (case-sensitive) - const exactMatch = users.find(user => user.username === username); - if (exactMatch) { - return GitLabUserSchema.parse(exactMatch); - } - } - - // No matching user found - return null; - } catch (error) { - logger.error(`Error fetching user by username '${username}':`, error); - return null; - } -} - -/** - * Get multiple users from GitLab - * - * @param {string[]} usernames - Array of usernames to look up - * @returns {Promise} Object with usernames as keys and user objects or null as values - */ -async function getUsers(usernames: string[]): Promise { - const users: Record = {}; - - // Process usernames sequentially to avoid rate limiting - for (const username of usernames) { - try { - const user = await getUser(username); - users[username] = user; - } catch (error) { - logger.error(`Error processing username '${username}':`, error); - users[username] = null; - } - } - - return GitLabUsersResponseSchema.parse(users); -} - -/** - * List repository commits - * ์ €์žฅ์†Œ ์ปค๋ฐ‹ ๋ชฉ๋ก ์กฐํšŒ - * - * @param {string} projectId - Project ID or URL-encoded path - * @param {ListCommitsOptions} options - List commits options - * @returns {Promise} List of commits - */ -async function listCommits( - projectId: string, - options: Omit = {} -): Promise { - projectId = decodeURIComponent(projectId); - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/repository/commits` - ); - - // Add query parameters - if (options.ref_name) url.searchParams.append("ref_name", options.ref_name); - if (options.since) url.searchParams.append("since", options.since); - if (options.until) url.searchParams.append("until", options.until); - if (options.path) url.searchParams.append("path", options.path); - if (options.author) url.searchParams.append("author", options.author); - if (options.all) url.searchParams.append("all", options.all.toString()); - if (options.with_stats) url.searchParams.append("with_stats", options.with_stats.toString()); - if (options.first_parent) - url.searchParams.append("first_parent", options.first_parent.toString()); - if (options.order) url.searchParams.append("order", options.order); - if (options.trailers) url.searchParams.append("trailers", options.trailers.toString()); - if (options.page) url.searchParams.append("page", options.page.toString()); - if (options.per_page) url.searchParams.append("per_page", options.per_page.toString()); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - await handleGitLabError(response); - - const data = await response.json(); - return z.array(GitLabCommitSchema).parse(data); -} - -/** - * Get a single commit - * ๋‹จ์ผ ์ปค๋ฐ‹ ์ •๋ณด ์กฐํšŒ - * - * @param {string} projectId - Project ID or URL-encoded path - * @param {string} sha - The commit hash or name of a repository branch or tag - * @param {boolean} [stats] - Include commit stats - * @returns {Promise} The commit details - */ -async function getCommit(projectId: string, sha: string, stats?: boolean): Promise { - projectId = decodeURIComponent(projectId); - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/repository/commits/${encodeURIComponent(sha)}` - ); - - if (stats) { - url.searchParams.append("stats", "true"); - } - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - await handleGitLabError(response); - - const data = await response.json(); - return GitLabCommitSchema.parse(data); -} - -/** - * Get commit diff - * ์ปค๋ฐ‹ ๋ณ€๊ฒฝ์‚ฌํ•ญ ์กฐํšŒ - * - * @param {string} projectId - Project ID or URL-encoded path - * @param {string} sha - The commit hash or name of a repository branch or tag - * @returns {Promise} The commit diffs - */ -async function getCommitDiff(projectId: string, sha: string): Promise { - projectId = decodeURIComponent(projectId); - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/repository/commits/${encodeURIComponent(sha)}/diff` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - await handleGitLabError(response); - - const data = await response.json(); - return z.array(GitLabDiffSchema).parse(data); -} - -/** - * Get the current authenticated user - * ํ˜„์žฌ ์ธ์ฆ๋œ ์‚ฌ์šฉ์ž ๊ฐ€์ ธ์˜ค๊ธฐ - * - * @returns {Promise} The current user - */ -async function getCurrentUser(): Promise { - const response = await fetch(`${GITLAB_API_URL}/user`, DEFAULT_FETCH_CONFIG); - - await handleGitLabError(response); - const data = await response.json(); - return GitLabUserSchema.parse(data); -} - -/** - * List issues assigned to the current authenticated user - * ํ˜„์žฌ ์ธ์ฆ๋œ ์‚ฌ์šฉ์ž์—๊ฒŒ ํ• ๋‹น๋œ ์ด์Šˆ ๋ชฉ๋ก ์กฐํšŒ - * - * @param {MyIssuesOptions} options - Options for filtering issues - * @returns {Promise} List of issues assigned to the current user - */ -async function myIssues(options: MyIssuesOptions = {}): Promise { - // Get current user to find their username - const currentUser = await getCurrentUser(); - - // Use getEffectiveProjectId to handle project ID resolution - const effectiveProjectId = getEffectiveProjectId(options.project_id || ""); - - // Use listIssues with assignee_username filter - let listIssuesOptions: Omit, "project_id"> = { - state: options.state || "opened", // Default to "opened" if not specified - labels: options.labels, - milestone: options.milestone, - search: options.search, - created_after: options.created_after, - created_before: options.created_before, - updated_after: options.updated_after, - updated_before: options.updated_before, - per_page: options.per_page, - page: options.page, - }; - - if (currentUser.username) { - listIssuesOptions.assignee_username = [currentUser.username] - } else { - listIssuesOptions.assignee_id = currentUser.id - } - return listIssues(effectiveProjectId, listIssuesOptions); -} - -/** - * List members of a GitLab project - * GitLab ํ”„๋กœ์ ํŠธ ๋ฉค๋ฒ„ ๋ชฉ๋ก ์กฐํšŒ - * - * @param {string} projectId - Project ID or URL-encoded path - * @param {Omit} options - Options for filtering members - * @returns {Promise} List of project members - */ -async function listProjectMembers( - projectId: string, - options: Omit = {} -): Promise { - projectId = decodeURIComponent(projectId); - const effectiveProjectId = getEffectiveProjectId(projectId); - const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/members`); - - // Add query parameters - if (options.query) url.searchParams.append("query", options.query); - if (options.user_ids) { - options.user_ids.forEach(id => url.searchParams.append("user_ids[]", id.toString())); - } - if (options.skip_users) { - options.skip_users.forEach(id => url.searchParams.append("skip_users[]", id.toString())); - } - if (options.per_page) url.searchParams.append("per_page", options.per_page.toString()); - if (options.page) url.searchParams.append("page", options.page.toString()); - - const response = await fetch(url.toString(), DEFAULT_FETCH_CONFIG); - - await handleGitLabError(response); - const data = await response.json(); - return z.array(GitLabProjectMemberSchema).parse(data); -} - -/** - * list group iterations - * - * @param {string} groupId - * @param {Omit} options - * @returns {Promise} - */ -async function listGroupIterations( - groupId: string, - options: Omit, "group_id"> = {} -): Promise { - groupId = decodeURIComponent(groupId); - const url = new URL(`${GITLAB_API_URL}/groups/${encodeURIComponent(groupId)}/iterations`); - - // ใ‚ฏใ‚จใƒชใƒ‘ใƒฉใƒกใƒผใ‚ฟใฎ่ฟฝๅŠ  - if (options.state) url.searchParams.append("state", options.state); - if (options.search) url.searchParams.append("search", options.search); - if (options.in) url.searchParams.append("in", options.in.join(",")); - if (options.include_ancestors !== undefined) - url.searchParams.append("include_ancestors", options.include_ancestors.toString()); - if (options.include_descendants !== undefined) - url.searchParams.append("include_descendants", options.include_descendants.toString()); - if (options.updated_before) url.searchParams.append("updated_before", options.updated_before); - if (options.updated_after) url.searchParams.append("updated_after", options.updated_after); - if (options.page) url.searchParams.append("page", options.page.toString()); - if (options.per_page) url.searchParams.append("per_page", options.per_page.toString()); - - const response = await fetch(url.toString(), DEFAULT_FETCH_CONFIG); - - if (!response.ok) { - await handleGitLabError(response); - } - - const data = await response.json(); - return z.array(GroupIteration).parse(data); -} - -/** - * Upload a file to a GitLab project for use in markdown content - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {string} filePath - Path to the local file to upload - * @returns {Promise} The upload response - */ -async function markdownUpload(projectId: string, filePath: string): Promise { - projectId = decodeURIComponent(projectId); - const effectiveProjectId = getEffectiveProjectId(projectId); - - // Check if file exists - if (!fs.existsSync(filePath)) { - throw new Error(`File not found: ${filePath}`); - } - - // Read the file - const fileBuffer = fs.readFileSync(filePath); - const fileName = path.basename(filePath); - - // Create form data - const FormData = (await import("form-data")).default; - const form = new FormData(); - form.append("file", fileBuffer, { - filename: fileName, - contentType: "application/octet-stream", - }); - - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/uploads` - ); - - const response = await fetch(url.toString(), { - method: "POST", - headers: { - ...DEFAULT_HEADERS, - // Remove Content-Type header to let form-data set it with boundary - "Content-Type": undefined as any, - }, - body: form, - }); - - if (!response.ok) { - await handleGitLabError(response); - } - - const data = await response.json(); - return GitLabMarkdownUploadSchema.parse(data); -} - -async function downloadAttachment(projectId: string, secret: string, filename: string, localPath?: string): Promise { - const effectiveProjectId = getEffectiveProjectId(projectId); - - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/uploads/${secret}/${filename}` - ); - - const response = await fetch(url.toString(), { - method: "GET", - headers: DEFAULT_HEADERS, - }); - - if (!response.ok) { - await handleGitLabError(response); - } - - // Get the file content as buffer - const buffer = await response.arrayBuffer(); - - // Determine the save path - const savePath = localPath ? path.join(localPath, filename) : filename; - - // Write the file to disk - fs.writeFileSync(savePath, Buffer.from(buffer)); - - return savePath; -} - -server.setRequestHandler(ListToolsRequestSchema, async () => { - // Apply read-only filter first - const tools0 = GITLAB_READ_ONLY_MODE - ? allTools.filter(tool => readOnlyTools.includes(tool.name)) - : allTools; - // Toggle wiki tools by USE_GITLAB_WIKI flag - const tools1 = USE_GITLAB_WIKI - ? tools0 - : tools0.filter(tool => !wikiToolNames.includes(tool.name)); - // Toggle milestone tools by USE_MILESTONE flag - const tools2 = USE_MILESTONE - ? tools1 - : tools1.filter(tool => !milestoneToolNames.includes(tool.name)); - // Toggle pipeline tools by USE_PIPELINE flag - let tools = USE_PIPELINE ? tools2 : tools2.filter(tool => !pipelineToolNames.includes(tool.name)); - - // <<< START: Gemini ํ˜ธํ™˜์„ฑ์„ ์œ„ํ•ด $schema ์ œ๊ฑฐ >>> - tools = tools.map(tool => { - // inputSchema๊ฐ€ ์กด์žฌํ•˜๊ณ  ๊ฐ์ฒด์ธ์ง€ ํ™•์ธ - if (tool.inputSchema && typeof tool.inputSchema === "object" && tool.inputSchema !== null) { - // $schema ํ‚ค๊ฐ€ ์กด์žฌํ•˜๋ฉด ์‚ญ์ œ - if ("$schema" in tool.inputSchema) { - // ๋ถˆ๋ณ€์„ฑ์„ ์œ„ํ•ด ์ƒˆ๋กœ์šด ๊ฐ์ฒด ์ƒ์„ฑ (์„ ํƒ์ ์ด์ง€๋งŒ ๊ถŒ์žฅ) - const modifiedSchema = { ...tool.inputSchema }; - delete modifiedSchema.$schema; - return { ...tool, inputSchema: modifiedSchema }; - } - } - // ๋ณ€๊ฒฝ์ด ํ•„์š” ์—†์œผ๋ฉด ๊ทธ๋Œ€๋กœ ๋ฐ˜ํ™˜ - return tool; - }); - // <<< END: Gemini ํ˜ธํ™˜์„ฑ์„ ์œ„ํ•ด $schema ์ œ๊ฑฐ >>> - - return { - tools, // $schema๊ฐ€ ์ œ๊ฑฐ๋œ ๋„๊ตฌ ๋ชฉ๋ก ๋ฐ˜ํ™˜ - }; -}); - -server.setRequestHandler(CallToolRequestSchema, async request => { - try { - if (!request.params.arguments) { - throw new Error("Arguments are required"); - } - - // Ensure session is established for every request if cookie authentication is enabled - if (GITLAB_AUTH_COOKIE_PATH) { - await ensureSessionForRequest(); - } - logger.info(request.params.name); - switch (request.params.name) { - case "fork_repository": { - if (GITLAB_PROJECT_ID) { - throw new Error("Direct project ID is set. So fork_repository is not allowed"); - } - const forkArgs = ForkRepositorySchema.parse(request.params.arguments); - try { - const forkedProject = await forkProject(forkArgs.project_id, forkArgs.namespace); - return { - content: [{ type: "text", text: JSON.stringify(forkedProject, null, 2) }], - }; - } catch (forkError) { - logger.error("Error forking repository:", forkError); - let forkErrorMessage = "Failed to fork repository"; - if (forkError instanceof Error) { - forkErrorMessage = `${forkErrorMessage}: ${forkError.message}`; - } - return { - content: [ - { - type: "text", - text: JSON.stringify({ error: forkErrorMessage }, null, 2), - }, - ], - }; - } - } - - case "create_branch": { - const args = CreateBranchSchema.parse(request.params.arguments); - let ref = args.ref; - if (!ref) { - ref = await getDefaultBranchRef(args.project_id); - } - - const branch = await createBranch(args.project_id, { - name: args.branch, - ref, - }); - - return { - content: [{ type: "text", text: JSON.stringify(branch, null, 2) }], - }; - } - - case "get_branch_diffs": { - const args = GetBranchDiffsSchema.parse(request.params.arguments); - const diffResp = await getBranchDiffs(args.project_id, args.from, args.to, args.straight); - - if (args.excluded_file_patterns?.length) { - const regexPatterns = args.excluded_file_patterns.map(pattern => new RegExp(pattern)); - - // Helper function to check if a path matches any regex pattern - const matchesAnyPattern = (path: string): boolean => { - if (!path) return false; - return regexPatterns.some(regex => regex.test(path)); - }; - - // Filter out files that match any of the regex patterns on new files - diffResp.diffs = diffResp.diffs.filter(diff => !matchesAnyPattern(diff.new_path)); - } - return { - content: [{ type: "text", text: JSON.stringify(diffResp, null, 2) }], - }; - } - - case "search_repositories": { - const args = SearchRepositoriesSchema.parse(request.params.arguments); - const results = await searchProjects(args.search, args.page, args.per_page); - return { - content: [{ type: "text", text: JSON.stringify(results, null, 2) }], - }; - } - - case "create_repository": { - if (GITLAB_PROJECT_ID) { - throw new Error("Direct project ID is set. So fork_repository is not allowed"); - } - const args = CreateRepositorySchema.parse(request.params.arguments); - const repository = await createRepository(args); - return { - content: [{ type: "text", text: JSON.stringify(repository, null, 2) }], - }; - } - - case "get_file_contents": { - const args = GetFileContentsSchema.parse(request.params.arguments); - const contents = await getFileContents(args.project_id, args.file_path, args.ref); - return { - content: [{ type: "text", text: JSON.stringify(contents, null, 2) }], - }; - } - - case "create_or_update_file": { - const args = CreateOrUpdateFileSchema.parse(request.params.arguments); - const result = await createOrUpdateFile( - args.project_id, - args.file_path, - args.content, - args.commit_message, - args.branch, - args.previous_path, - args.last_commit_id, - args.commit_id - ); - return { - content: [{ type: "text", text: JSON.stringify(result, null, 2) }], - }; - } - - case "push_files": { - const args = PushFilesSchema.parse(request.params.arguments); - const result = await createCommit( - args.project_id, - args.commit_message, - args.branch, - args.files.map(f => ({ path: f.file_path, content: f.content })) - ); - return { - content: [{ type: "text", text: JSON.stringify(result, null, 2) }], - }; - } +import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; +import express, { Request, Response } from "express"; +import {mcpserver } from "./src/mcpserver.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { config, validateConfiguration} from "./src/config.js"; +import { configureAuthentication } from "./src/authentication.js"; +import { logger } from "./src/logger.js"; +import argon2 from "./src/argon2wrapper.js"; +import { randomUUID } from "crypto"; - case "create_issue": { - const args = CreateIssueSchema.parse(request.params.arguments); - const { project_id, ...options } = args; - const issue = await createIssue(project_id, options); - return { - content: [{ type: "text", text: JSON.stringify(issue, null, 2) }], - }; - } - case "create_merge_request": { - const args = CreateMergeRequestSchema.parse(request.params.arguments); - const { project_id, ...options } = args; - const mergeRequest = await createMergeRequest(project_id, options); - return { - content: [{ type: "text", text: JSON.stringify(mergeRequest, null, 2) }], - }; - } +validateConfiguration() - case "update_merge_request_note": { - const args = UpdateMergeRequestNoteSchema.parse(request.params.arguments); - const note = await updateMergeRequestNote( - args.project_id, - args.merge_request_iid, - args.discussion_id, - args.note_id, - args.body, // Now optional - args.resolved // Now one of body or resolved must be provided, not both - ); - return { - content: [{ type: "text", text: JSON.stringify(note, null, 2) }], - }; - } +interface TransportModes { + stdio: boolean; + sse: boolean; + streamableHttp: boolean; +} - case "create_merge_request_note": { - const args = CreateMergeRequestNoteSchema.parse(request.params.arguments); - const note = await createMergeRequestNote( - args.project_id, - args.merge_request_iid, - args.discussion_id, - args.body, - args.created_at - ); - return { - content: [{ type: "text", text: JSON.stringify(note, null, 2) }], - }; - } +/** + * Determine which transport modes are enabled based on environment variables + * If both SSE and STREAMABLE_HTTP are disabled, defaults to STDIO + */ +function determineTransportModes(): TransportModes { + const sseEnabled = config.SSE; + const streamableHttpEnabled = config.STREAMABLE_HTTP; - case "update_issue_note": { - const args = UpdateIssueNoteSchema.parse(request.params.arguments); - const note = await updateIssueNote( - args.project_id, - args.issue_iid, - args.discussion_id, - args.note_id, - args.body - ); - return { - content: [{ type: "text", text: JSON.stringify(note, null, 2) }], - }; - } + // If neither SSE nor STREAMABLE_HTTP are enabled, use STDIO + const stdioEnabled = !sseEnabled && !streamableHttpEnabled; - case "create_issue_note": { - const args = CreateIssueNoteSchema.parse(request.params.arguments); - const note = await createIssueNote( - args.project_id, - args.issue_iid, - args.discussion_id, - args.body, - args.created_at - ); - return { - content: [{ type: "text", text: JSON.stringify(note, null, 2) }], - }; - } + return { + stdio: stdioEnabled, + sse: sseEnabled, + streamableHttp: streamableHttpEnabled + }; +} - case "get_merge_request": { - const args = GetMergeRequestSchema.parse(request.params.arguments); - const mergeRequest = await getMergeRequest( - args.project_id, - args.merge_request_iid, - args.source_branch - ); - return { - content: [{ type: "text", text: JSON.stringify(mergeRequest, null, 2) }], - }; - } +/** + * Start server with stdio transport + */ +async function startStdioServer(): Promise { + const transport = new StdioServerTransport(); + await mcpserver.connect(transport); +} - case "get_merge_request_diffs": { - const args = GetMergeRequestDiffsSchema.parse(request.params.arguments); - const diffs = await getMergeRequestDiffs( - args.project_id, - args.merge_request_iid, - args.source_branch, - args.view - ); - return { - content: [{ type: "text", text: JSON.stringify(diffs, null, 2) }], - }; - } +interface ExpressServerOptions { + sseEnabled: boolean; + streamableHttpEnabled: boolean; +} - case "list_merge_request_diffs": { - const args = ListMergeRequestDiffsSchema.parse(request.params.arguments); - const changes = await listMergeRequestDiffs( - args.project_id, - args.merge_request_iid, - args.source_branch, - args.page, - args.per_page, - args.unidiff - ); - return { - content: [{ type: "text", text: JSON.stringify(changes, null, 2) }], - }; - } +// used for the sse and streamable http transports to share auth +async function startExpressServer(options: ExpressServerOptions): Promise { + const { sseEnabled, streamableHttpEnabled } = options; + const app = express(); - case "update_merge_request": { - const args = UpdateMergeRequestSchema.parse(request.params.arguments); - const { project_id, merge_request_iid, source_branch, ...options } = args; - const mergeRequest = await updateMergeRequest( - project_id, - options, - merge_request_iid, - source_branch - ); - return { - content: [{ type: "text", text: JSON.stringify(mergeRequest, null, 2) }], - }; + const authMiddleware = await configureAuthentication(app); + const argon2Salt = new TextEncoder().encode(config.ARGON2_SALT) + + if(sseEnabled) { + const transports: { + [sessionId: string]: { + transport: SSEServerTransport + tokenHash?: string + } + } = {}; + app.get("/sse", authMiddleware, async (req: Request, res: Response) => { + const transport = new SSEServerTransport("/messages", res); + transports[transport.sessionId] = { + transport, + }; + // if we have a valid auth info here, either obtained from the passthrough token or oauth, we tie it to a session. + if(req.auth) { + transports[transport.sessionId].tokenHash = await argon2.hash(req.auth.token, { + salt: argon2Salt, + }); + logger.debug({ + tokenHash: transports[transport.sessionId].tokenHash?.slice(-8), + }, "created new auth session") } - - case "merge_merge_request": { - const args = MergeMergeRequestSchema.parse(request.params.arguments); - const { project_id, merge_request_iid, ...options } = args; - const mergeRequest = await mergeMergeRequest(project_id, options, merge_request_iid); - return { - content: [{ type: "text", text: JSON.stringify(mergeRequest, null, 2) }], - }; + res.on("close", () => { + delete transports[transport.sessionId]; + }); + try { + await mcpserver.connect(transport); + }catch(e) { + logger.error({e}, "Transport error connecting to MCP server:"); + res.status(500).send("Internal server error"); } + }); - case "mr_discussions": { - const args = ListMergeRequestDiscussionsSchema.parse(request.params.arguments); - const { project_id, merge_request_iid, ...options } = args; - const discussions = await listMergeRequestDiscussions( - project_id, - merge_request_iid, - options - ); - return { - content: [{ type: "text", text: JSON.stringify(discussions, null, 2) }], - }; + app.post("/messages",authMiddleware, async (req: Request, res: Response) => { + const sessionId = req.query.sessionId as string; + const transportDetails = transports[sessionId]; + if(!transportDetails) { + res.status(400).send("No transport found for sessionId"); + return; } - - case "list_namespaces": { - const args = ListNamespacesSchema.parse(request.params.arguments); - const url = new URL(`${GITLAB_API_URL}/namespaces`); - - if (args.search) { - url.searchParams.append("search", args.search); - } - if (args.page) { - url.searchParams.append("page", args.page.toString()); - } - if (args.per_page) { - url.searchParams.append("per_page", args.per_page.toString()); + const {transport, tokenHash} = transportDetails + // means we have a token hash to verify. + if(tokenHash) { + // NOTE: at this point, we assume that this req.auth is a "valid" AuthInfo + // TODO: consider the security implications of this when verifying dcr clients. + if(!req.auth) { + res.status(401).send("No authorization information sent"); + return; } - if (args.owned) { - url.searchParams.append("owned", args.owned.toString()); + const gitlabToken = req.auth.token; + if(!gitlabToken) { + res.status(401).send("No valid token info found in request"); + return; } - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - await handleGitLabError(response); - const data = await response.json(); - const namespaces = z.array(GitLabNamespaceSchema).parse(data); - - return { - content: [{ type: "text", text: JSON.stringify(namespaces, null, 2) }], - }; - } - - case "get_namespace": { - const args = GetNamespaceSchema.parse(request.params.arguments); - const url = new URL( - `${GITLAB_API_URL}/namespaces/${encodeURIComponent(args.namespace_id)}` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - await handleGitLabError(response); - const data = await response.json(); - const namespace = GitLabNamespaceSchema.parse(data); - - return { - content: [{ type: "text", text: JSON.stringify(namespace, null, 2) }], - }; - } - - case "verify_namespace": { - const args = VerifyNamespaceSchema.parse(request.params.arguments); - const url = new URL(`${GITLAB_API_URL}/namespaces/${encodeURIComponent(args.path)}/exists`); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, + const verified = await argon2.verify(tokenHash, gitlabToken, { + salt: argon2Salt, }); - - await handleGitLabError(response); - const data = await response.json(); - const namespaceExists = GitLabNamespaceExistsResponseSchema.parse(data); - - return { - content: [{ type: "text", text: JSON.stringify(namespaceExists, null, 2) }], - }; - } - - case "get_project": { - const args = GetProjectSchema.parse(request.params.arguments); - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(args.project_id))}` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - await handleGitLabError(response); - const data = await response.json(); - const project = GitLabProjectSchema.parse(data); - - return { - content: [{ type: "text", text: JSON.stringify(project, null, 2) }], - }; - } - - case "list_projects": { - const args = ListProjectsSchema.parse(request.params.arguments); - const projects = await listProjects(args); - - return { - content: [{ type: "text", text: JSON.stringify(projects, null, 2) }], - }; - } - - case "list_project_members": { - const args = ListProjectMembersSchema.parse(request.params.arguments); - const { project_id, ...options } = args; - const members = await listProjectMembers(project_id, options); - return { - content: [{ type: "text", text: JSON.stringify(members, null, 2) }], - }; - } - - case "get_users": { - const args = GetUsersSchema.parse(request.params.arguments); - const usersMap = await getUsers(args.usernames); - - return { - content: [{ type: "text", text: JSON.stringify(usersMap, null, 2) }], - }; - } - - case "create_note": { - const args = CreateNoteSchema.parse(request.params.arguments); - const { project_id, noteable_type, noteable_iid, body } = args; - - const note = await createNote(project_id, noteable_type, noteable_iid, body); - return { - content: [{ type: "text", text: JSON.stringify(note, null, 2) }], - }; - } - - case "get_draft_note": { - const args = GetDraftNoteSchema.parse(request.params.arguments); - const { project_id, merge_request_iid, draft_note_id } = args; - - const draftNote = await getDraftNote(project_id, merge_request_iid, draft_note_id); - return { - content: [{ type: "text", text: JSON.stringify(draftNote, null, 2) }], - }; - } - - case "list_draft_notes": { - const args = ListDraftNotesSchema.parse(request.params.arguments); - const { project_id, merge_request_iid } = args; - - const draftNotes = await listDraftNotes(project_id, merge_request_iid); - return { - content: [{ type: "text", text: JSON.stringify(draftNotes, null, 2) }], - }; - } - - case "create_draft_note": { - const args = CreateDraftNoteSchema.parse(request.params.arguments); - const { project_id, merge_request_iid, body, position, resolve_discussion } = args; - - const draftNote = await createDraftNote(project_id, merge_request_iid, body, position, resolve_discussion); - return { - content: [{ type: "text", text: JSON.stringify(draftNote, null, 2) }], - }; - } - - case "update_draft_note": { - const args = UpdateDraftNoteSchema.parse(request.params.arguments); - const { project_id, merge_request_iid, draft_note_id, body, position, resolve_discussion } = args; - - const draftNote = await updateDraftNote(project_id, merge_request_iid, draft_note_id, body, position, resolve_discussion); - return { - content: [{ type: "text", text: JSON.stringify(draftNote, null, 2) }], - }; - } - - case "delete_draft_note": { - const args = DeleteDraftNoteSchema.parse(request.params.arguments); - const { project_id, merge_request_iid, draft_note_id } = args; - - await deleteDraftNote(project_id, merge_request_iid, draft_note_id); - return { - content: [{ type: "text", text: "Draft note deleted successfully" }], - }; - } - - case "publish_draft_note": { - const args = PublishDraftNoteSchema.parse(request.params.arguments); - const { project_id, merge_request_iid, draft_note_id } = args; - - const publishedNote = await publishDraftNote(project_id, merge_request_iid, draft_note_id); - return { - content: [{ type: "text", text: JSON.stringify(publishedNote, null, 2) }], - }; - } - - case "bulk_publish_draft_notes": { - const args = BulkPublishDraftNotesSchema.parse(request.params.arguments); - const { project_id, merge_request_iid } = args; - - const publishedNotes = await bulkPublishDraftNotes(project_id, merge_request_iid); - return { - content: [{ type: "text", text: JSON.stringify(publishedNotes, null, 2) }], - }; - } - - case "create_merge_request_thread": { - const args = CreateMergeRequestThreadSchema.parse(request.params.arguments); - const { project_id, merge_request_iid, body, position, created_at } = args; - - const thread = await createMergeRequestThread( - project_id, - merge_request_iid, - body, - position, - created_at - ); - return { - content: [{ type: "text", text: JSON.stringify(thread, null, 2) }], - }; - } - - case "list_issues": { - const args = ListIssuesSchema.parse(request.params.arguments); - const { project_id, ...options } = args; - const issues = await listIssues(project_id, options); - return { - content: [{ type: "text", text: JSON.stringify(issues, null, 2) }], - }; - } - - case "my_issues": { - const args = MyIssuesSchema.parse(request.params.arguments); - const issues = await myIssues(args); - return { - content: [{ type: "text", text: JSON.stringify(issues, null, 2) }], - }; - } - - case "get_issue": { - const args = GetIssueSchema.parse(request.params.arguments); - const issue = await getIssue(args.project_id, args.issue_iid); - return { - content: [{ type: "text", text: JSON.stringify(issue, null, 2) }], - }; - } - - case "update_issue": { - const args = UpdateIssueSchema.parse(request.params.arguments); - const { project_id, issue_iid, ...options } = args; - const issue = await updateIssue(project_id, issue_iid, options); - return { - content: [{ type: "text", text: JSON.stringify(issue, null, 2) }], - }; - } - - case "delete_issue": { - const args = DeleteIssueSchema.parse(request.params.arguments); - await deleteIssue(args.project_id, args.issue_iid); - return { - content: [ - { - type: "text", - text: JSON.stringify( - { status: "success", message: "Issue deleted successfully" }, - null, - 2 - ), - }, - ], - }; - } - - case "list_issue_links": { - const args = ListIssueLinksSchema.parse(request.params.arguments); - const links = await listIssueLinks(args.project_id, args.issue_iid); - return { - content: [{ type: "text", text: JSON.stringify(links, null, 2) }], - }; - } - - case "list_issue_discussions": { - const args = ListIssueDiscussionsSchema.parse(request.params.arguments); - const { project_id, issue_iid, ...options } = args; - - const discussions = await listIssueDiscussions(project_id, issue_iid, options); - return { - content: [{ type: "text", text: JSON.stringify(discussions, null, 2) }], - }; - } - - case "get_issue_link": { - const args = GetIssueLinkSchema.parse(request.params.arguments); - const link = await getIssueLink(args.project_id, args.issue_iid, args.issue_link_id); - return { - content: [{ type: "text", text: JSON.stringify(link, null, 2) }], - }; - } - - case "create_issue_link": { - const args = CreateIssueLinkSchema.parse(request.params.arguments); - const link = await createIssueLink( - args.project_id, - args.issue_iid, - args.target_project_id, - args.target_issue_iid, - args.link_type - ); - return { - content: [{ type: "text", text: JSON.stringify(link, null, 2) }], - }; - } - - case "delete_issue_link": { - const args = DeleteIssueLinkSchema.parse(request.params.arguments); - await deleteIssueLink(args.project_id, args.issue_iid, args.issue_link_id); - return { - content: [ - { - type: "text", - text: JSON.stringify( - { - status: "success", - message: "Issue link deleted successfully", - }, - null, - 2 - ), - }, - ], - }; - } - - case "list_labels": { - const args = ListLabelsSchema.parse(request.params.arguments); - const labels = await listLabels(args.project_id, args); - return { - content: [{ type: "text", text: JSON.stringify(labels, null, 2) }], - }; - } - - case "get_label": { - const args = GetLabelSchema.parse(request.params.arguments); - const label = await getLabel(args.project_id, args.label_id, args.include_ancestor_groups); - return { - content: [{ type: "text", text: JSON.stringify(label, null, 2) }], - }; + if(!verified) { + res.status(401).send("Token does not match session"); + return; + } + logger.debug({ + tokenHash: tokenHash.slice(-8), + }, "auth token verified") } - - case "create_label": { - const args = CreateLabelSchema.parse(request.params.arguments); - const label = await createLabel(args.project_id, args); - return { - content: [{ type: "text", text: JSON.stringify(label, null, 2) }], - }; + if (transport) { + try { + await transport.handlePostMessage(req, res); + } catch (e) { + logger.error({e}, "Transport error handling message"); + res.status(500).send("Internal server error"); + } } + }); - case "update_label": { - const args = UpdateLabelSchema.parse(request.params.arguments); - const { project_id, label_id, ...options } = args; - const label = await updateLabel(project_id, label_id, options); - return { - content: [{ type: "text", text: JSON.stringify(label, null, 2) }], - }; - } + } - case "delete_label": { - const args = DeleteLabelSchema.parse(request.params.arguments); - await deleteLabel(args.project_id, args.label_id); - return { - content: [ - { - type: "text", - text: JSON.stringify( - { status: "success", message: "Label deleted successfully" }, - null, - 2 - ), - }, - ], - }; - } + if (streamableHttpEnabled) { + const transports : { [sessionId: string]: { + transport: StreamableHTTPServerTransport + tokenHash?: string + }} = {}; + // Streamable HTTP endpoint - handles both session creation and message handling + app.post('/mcp', authMiddleware, async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string; + try { + let transport: StreamableHTTPServerTransport + if (sessionId && transports[sessionId]) { + // Reuse existing transport for ongoing session + const session = transports[sessionId]; + if(session.tokenHash) { + if(!req.auth) { + res.status(401).send("No authorization information sent"); + return; + } + const gitlabToken = req.auth.token; + if(!gitlabToken) { + res.status(401).send("No valid token info found in request"); + return; + } + const verified = await argon2.verify(session.tokenHash, gitlabToken, { + salt: argon2Salt, + }); + if(!verified) { + res.status(401).send("Token does not match session"); + return; + } + logger.debug({ + tokenHash: session.tokenHash.slice(-8), + }, "auth token verified") + } + transport = session.transport; + try { + await transport.handleRequest(req, res, req.body); + } catch (e) { + logger.error("Transport error handling request:", e); + res.status(500).send("Internal server error"); + return + } + } else { + // Create new transport for new session + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (newSessionId: string) => { + transports[newSessionId] = { + transport, + }; + // if we have a valid auth info here, either obtained from the passthrough token or oauth, we tie it to a session. + if(req.auth) { + transports[newSessionId].tokenHash = argon2.hashSync(req.auth.token, { + salt: argon2Salt, + }); + logger.debug({ + tokenHash: transports[newSessionId].tokenHash.slice(-8), + }, "auth session created for token") + } + logger.warn(`Streamable HTTP session initialized: ${newSessionId}`); + } + }); - case "list_group_projects": { - const args = ListGroupProjectsSchema.parse(request.params.arguments); - const projects = await listGroupProjects(args); - return { - content: [{ type: "text", text: JSON.stringify(projects, null, 2) }], - }; - } + // Set up cleanup handler when transport closes + transport.onclose = () => { + const sid = transport.sessionId; + if (sid && transports[sid]) { + logger.warn(`Streamable HTTP transport closed for session ${sid}, cleaning up`); + delete transports[sid]; + } + }; - case "list_wiki_pages": { - const { project_id, page, per_page, with_content } = ListWikiPagesSchema.parse( - request.params.arguments - ); - const wikiPages = await listWikiPages(project_id, { - page, - per_page, - with_content, + // Connect transport to MCP server before handling the request + try { + await mcpserver.connect(transport); + } catch (e) { + logger.error({e}, "Transport error connecting to MCP server:"); + res.status(500).send("Internal server error"); + return + } + try { + await transport.handleRequest(req, res, req.body); + } catch (e) { + logger.error({e},"Transport error handling request:"); + res.status(500).send("Internal server error"); + return + } + } + } catch (error) { + logger.error('Streamable HTTP error:', error); + res.status(500).json({ + error: 'Internal server error', + message: error instanceof Error ? error.message : 'Unknown error' }); - return { - content: [{ type: "text", text: JSON.stringify(wikiPages, null, 2) }], - }; - } - - case "get_wiki_page": { - const { project_id, slug } = GetWikiPageSchema.parse(request.params.arguments); - const wikiPage = await getWikiPage(project_id, slug); - return { - content: [{ type: "text", text: JSON.stringify(wikiPage, null, 2) }], - }; - } - - case "create_wiki_page": { - const { project_id, title, content, format } = CreateWikiPageSchema.parse( - request.params.arguments - ); - const wikiPage = await createWikiPage(project_id, title, content, format); - return { - content: [{ type: "text", text: JSON.stringify(wikiPage, null, 2) }], - }; - } - - case "update_wiki_page": { - const { project_id, slug, title, content, format } = UpdateWikiPageSchema.parse( - request.params.arguments - ); - const wikiPage = await updateWikiPage(project_id, slug, title, content, format); - return { - content: [{ type: "text", text: JSON.stringify(wikiPage, null, 2) }], - }; - } - - case "delete_wiki_page": { - const { project_id, slug } = DeleteWikiPageSchema.parse(request.params.arguments); - await deleteWikiPage(project_id, slug); - return { - content: [ - { - type: "text", - text: JSON.stringify( - { - status: "success", - message: "Wiki page deleted successfully", - }, - null, - 2 - ), - }, - ], - }; - } - - case "get_repository_tree": { - const args = GetRepositoryTreeSchema.parse(request.params.arguments); - const tree = await getRepositoryTree(args); - return { - content: [{ type: "text", text: JSON.stringify(tree, null, 2) }], - }; - } - - case "list_pipelines": { - const args = ListPipelinesSchema.parse(request.params.arguments); - const { project_id, ...options } = args; - const pipelines = await listPipelines(project_id, options); - return { - content: [{ type: "text", text: JSON.stringify(pipelines, null, 2) }], - }; - } - - case "get_pipeline": { - const { project_id, pipeline_id } = GetPipelineSchema.parse(request.params.arguments); - const pipeline = await getPipeline(project_id, pipeline_id); - return { - content: [ - { - type: "text", - text: JSON.stringify(pipeline, null, 2), - }, - ], - }; - } - - case "list_pipeline_jobs": { - const { project_id, pipeline_id, ...options } = ListPipelineJobsSchema.parse( - request.params.arguments - ); - const jobs = await listPipelineJobs(project_id, pipeline_id, options); - return { - content: [ - { - type: "text", - text: JSON.stringify(jobs, null, 2), - }, - ], - }; - } - - case "list_pipeline_trigger_jobs": { - const { project_id, pipeline_id, ...options } = ListPipelineTriggerJobsSchema.parse( - request.params.arguments - ); - const triggerJobs = await listPipelineTriggerJobs(project_id, pipeline_id, options); - return { - content: [ - { - type: "text", - text: JSON.stringify(triggerJobs, null, 2), - }, - ], - }; - } - - case "get_pipeline_job": { - const { project_id, job_id } = GetPipelineJobOutputSchema.parse(request.params.arguments); - const jobDetails = await getPipelineJob(project_id, job_id); - return { - content: [ - { - type: "text", - text: JSON.stringify(jobDetails, null, 2), - }, - ], - }; - } - - case "get_pipeline_job_output": { - const { project_id, job_id, limit, offset } = GetPipelineJobOutputSchema.parse( - request.params.arguments - ); - const jobOutput = await getPipelineJobOutput(project_id, job_id, limit, offset); - return { - content: [ - { - type: "text", - text: jobOutput, - }, - ], - }; - } - - case "create_pipeline": { - const { project_id, ref, variables } = CreatePipelineSchema.parse(request.params.arguments); - const pipeline = await createPipeline(project_id, ref, variables); - return { - content: [ - { - type: "text", - text: `Created pipeline #${pipeline.id} for ${ref}. Status: ${pipeline.status}\nWeb URL: ${pipeline.web_url}`, - }, - ], - }; - } - - case "retry_pipeline": { - const { project_id, pipeline_id } = RetryPipelineSchema.parse(request.params.arguments); - const pipeline = await retryPipeline(project_id, pipeline_id); - return { - content: [ - { - type: "text", - text: `Retried pipeline #${pipeline.id}. Status: ${pipeline.status}\nWeb URL: ${pipeline.web_url}`, - }, - ], - }; - } - - case "cancel_pipeline": { - const { project_id, pipeline_id } = CancelPipelineSchema.parse(request.params.arguments); - const pipeline = await cancelPipeline(project_id, pipeline_id); - return { - content: [ - { - type: "text", - text: `Canceled pipeline #${pipeline.id}. Status: ${pipeline.status}\nWeb URL: ${pipeline.web_url}`, - }, - ], - }; - } - - case "list_merge_requests": { - const args = ListMergeRequestsSchema.parse(request.params.arguments); - const mergeRequests = await listMergeRequests(args.project_id, args); - return { - content: [{ type: "text", text: JSON.stringify(mergeRequests, null, 2) }], - }; - } - - case "list_milestones": { - const { project_id, ...options } = ListProjectMilestonesSchema.parse( - request.params.arguments - ); - const milestones = await listProjectMilestones(project_id, options); - return { - content: [ - { - type: "text", - text: JSON.stringify(milestones, null, 2), - }, - ], - }; - } - - case "get_milestone": { - const { project_id, milestone_id } = GetProjectMilestoneSchema.parse( - request.params.arguments - ); - const milestone = await getProjectMilestone(project_id, milestone_id); - return { - content: [ - { - type: "text", - text: JSON.stringify(milestone, null, 2), - }, - ], - }; - } - - case "create_milestone": { - const { project_id, ...options } = CreateProjectMilestoneSchema.parse( - request.params.arguments - ); - const milestone = await createProjectMilestone(project_id, options); - return { - content: [ - { - type: "text", - text: JSON.stringify(milestone, null, 2), - }, - ], - }; - } - - case "edit_milestone": { - const { project_id, milestone_id, ...options } = EditProjectMilestoneSchema.parse( - request.params.arguments - ); - const milestone = await editProjectMilestone(project_id, milestone_id, options); - return { - content: [ - { - type: "text", - text: JSON.stringify(milestone, null, 2), - }, - ], - }; - } - - case "delete_milestone": { - const { project_id, milestone_id } = DeleteProjectMilestoneSchema.parse( - request.params.arguments - ); - await deleteProjectMilestone(project_id, milestone_id); - return { - content: [ - { - type: "text", - text: JSON.stringify( - { - status: "success", - message: "Milestone deleted successfully", - }, - null, - 2 - ), - }, - ], - }; - } - - case "get_milestone_issue": { - const { project_id, milestone_id } = GetMilestoneIssuesSchema.parse( - request.params.arguments - ); - const issues = await getMilestoneIssues(project_id, milestone_id); - return { - content: [ - { - type: "text", - text: JSON.stringify(issues, null, 2), - }, - ], - }; - } - - case "get_milestone_merge_requests": { - const { project_id, milestone_id } = GetMilestoneMergeRequestsSchema.parse( - request.params.arguments - ); - const mergeRequests = await getMilestoneMergeRequests(project_id, milestone_id); - return { - content: [ - { - type: "text", - text: JSON.stringify(mergeRequests, null, 2), - }, - ], - }; - } - - case "promote_milestone": { - const { project_id, milestone_id } = PromoteProjectMilestoneSchema.parse( - request.params.arguments - ); - const milestone = await promoteProjectMilestone(project_id, milestone_id); - return { - content: [ - { - type: "text", - text: JSON.stringify(milestone, null, 2), - }, - ], - }; - } - - case "get_milestone_burndown_events": { - const { project_id, milestone_id } = GetMilestoneBurndownEventsSchema.parse( - request.params.arguments - ); - const events = await getMilestoneBurndownEvents(project_id, milestone_id); - return { - content: [ - { - type: "text", - text: JSON.stringify(events, null, 2), - }, - ], - }; - } - - case "list_commits": { - const args = ListCommitsSchema.parse(request.params.arguments); - const commits = await listCommits(args.project_id, args); - return { - content: [{ type: "text", text: JSON.stringify(commits, null, 2) }], - }; - } - - case "get_commit": { - const args = GetCommitSchema.parse(request.params.arguments); - const commit = await getCommit(args.project_id, args.sha, args.stats); - return { - content: [{ type: "text", text: JSON.stringify(commit, null, 2) }], - }; - } - - case "get_commit_diff": { - const args = GetCommitDiffSchema.parse(request.params.arguments); - const diff = await getCommitDiff(args.project_id, args.sha); - return { - content: [{ type: "text", text: JSON.stringify(diff, null, 2) }], - }; - } - - case "list_group_iterations": { - const args = ListGroupIterationsSchema.parse(request.params.arguments); - const iterations = await listGroupIterations(args.group_id, args); - return { - content: [{ type: "text", text: JSON.stringify(iterations, null, 2) }], - }; - } - - case "upload_markdown": { - const args = MarkdownUploadSchema.parse(request.params.arguments); - const upload = await markdownUpload(args.project_id, args.file_path); - return { - content: [{ type: "text", text: JSON.stringify(upload, null, 2) }], - }; - } - - case "download_attachment": { - const args = DownloadAttachmentSchema.parse(request.params.arguments); - const filePath = await downloadAttachment(args.project_id, args.secret, args.filename, args.local_path); - return { - content: [{ type: "text", text: JSON.stringify({ success: true, file_path: filePath }, null, 2) }], - }; } - - default: - throw new Error(`Unknown tool: ${request.params.name}`); - } - } catch (error) { - logger.debug(request.params); - if (error instanceof z.ZodError) { - throw new Error( - `Invalid arguments: ${error.errors - .map(e => `${e.path.join(".")}: ${e.message}`) - .join(", ")}` - ); - } - throw error; - } -}); - -/** - * Color constants for terminal output - */ -const colorGreen = "\x1b[32m"; -const colorReset = "\x1b[0m"; - -/** - * Determine the transport mode based on environment variables and availability - * - * Transport mode priority (highest to lowest): - * 1. STREAMABLE_HTTP - * 2. SSE - * 3. STDIO - */ -function determineTransportMode(): TransportMode { - // Check for streamable-http support (highest priority) - if (STREAMABLE_HTTP) { - return TransportMode.STREAMABLE_HTTP; - } - - // Check for SSE support (medium priority) - if (SSE) { - return TransportMode.SSE; - } - - // Default to stdio (lowest priority) - return TransportMode.STDIO; -} - -/** - * Start server with stdio transport - */ -async function startStdioServer(): Promise { - const transport = new StdioServerTransport(); - await server.connect(transport); -} - -/** - * Start server with traditional SSE transport - */ -async function startSSEServer(): Promise { - const app = express(); - const transports: { [sessionId: string]: SSEServerTransport } = {}; - - app.get("/sse", async (_: Request, res: Response) => { - const transport = new SSEServerTransport("/messages", res); - transports[transport.sessionId] = transport; - res.on("close", () => { - delete transports[transport.sessionId]; }); - await server.connect(transport); - }); - app.post("/messages", async (req: Request, res: Response) => { - const sessionId = req.query.sessionId as string; - const transport = transports[sessionId]; - if (transport) { - await transport.handlePostMessage(req, res); - } else { - res.status(400).send("No transport found for sessionId"); - } - }); + } app.get("/health", (_: Request, res: Response) => { res.status(200).json({ status: "healthy", - version: SERVER_VERSION, - transport: TransportMode.SSE, + version: process.env.npm_package_version || "unknown", }); }); - app.listen(Number(PORT), HOST, () => { - logger.info(`GitLab MCP Server running with SSE transport`); + + app.listen(Number(config.PORT), config.HOST, () => { + const enabledModes = []; + if (sseEnabled) enabledModes.push('SSE'); + if (streamableHttpEnabled) enabledModes.push('Streamable HTTP'); + logger.info(`GitLab MCP Server running with ${enabledModes.join(' and ')} transport(s)`); const colorGreen = "\x1b[32m"; const colorReset = "\x1b[0m"; - logger.info(`${colorGreen}Endpoint: http://${HOST}:${PORT}/sse${colorReset}`); - }); -} - -/** - * Start server with Streamable HTTP transport - */ -async function startStreamableHTTPServer(): Promise { - const app = express(); - const streamableTransports: { - [sessionId: string]: StreamableHTTPServerTransport; - } = {}; - - // Configure Express middleware - app.use(express.json()); - - // Streamable HTTP endpoint - handles both session creation and message handling - app.post("/mcp", async (req: Request, res: Response) => { - const sessionId = req.headers["mcp-session-id"] as string; - - try { - let transport: StreamableHTTPServerTransport; - - if (sessionId && streamableTransports[sessionId]) { - // Reuse existing transport for ongoing session - transport = streamableTransports[sessionId]; - await transport.handleRequest(req, res, req.body); - } else { - // Create new transport for new session - transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - onsessioninitialized: (newSessionId: string) => { - streamableTransports[newSessionId] = transport; - logger.warn(`Streamable HTTP session initialized: ${newSessionId}`); - }, - }); - - // Set up cleanup handler when transport closes - transport.onclose = () => { - const sid = transport.sessionId; - if (sid && streamableTransports[sid]) { - logger.warn(`Streamable HTTP transport closed for session ${sid}, cleaning up`); - delete streamableTransports[sid]; - } - }; - - // Connect transport to MCP server before handling the request - await server.connect(transport); - await transport.handleRequest(req, res, req.body); - } - } catch (error) { - logger.error("Streamable HTTP error:", error); - res.status(500).json({ - error: "Internal server error", - message: error instanceof Error ? error.message : "Unknown error", - }); + if (sseEnabled) { + logger.info(`${colorGreen}SSE Endpoint: http://${config.HOST}:${config.PORT}/sse${colorReset}`); + } + if (streamableHttpEnabled) { + logger.info(`${colorGreen}Streamable HTTP Endpoint: http://${config.HOST}:${config.PORT}/mcp${colorReset}`); } - }); - - // Health check endpoint - app.get("/health", (_: Request, res: Response) => { - res.status(200).json({ - status: "healthy", - version: SERVER_VERSION, - transport: TransportMode.STREAMABLE_HTTP, - activeSessions: Object.keys(streamableTransports).length, - }); - }); - - // Start server - app.listen(Number(PORT), HOST, () => { - logger.info(`GitLab MCP Server running with Streamable HTTP transport`); - logger.info(`${colorGreen}Endpoint: http://${HOST}:${PORT}/mcp${colorReset}`); }); } /** - * Initialize server with specific transport mode - * Handle transport-specific initialization logic + * Initialize server based on enabled transport modes */ -async function initializeServerByTransportMode(mode: TransportMode): Promise { - logger.info("Initializing server with transport mode:", mode); - switch (mode) { - case TransportMode.STDIO: - logger.warn("Starting GitLab MCP Server with stdio transport"); - await startStdioServer(); - break; - - case TransportMode.SSE: - logger.warn("Starting GitLab MCP Server with SSE transport"); - await startSSEServer(); - break; - - case TransportMode.STREAMABLE_HTTP: - logger.warn("Starting GitLab MCP Server with Streamable HTTP transport"); - await startStreamableHTTPServer(); - break; - - default: - // This should never happen with proper enum usage, but TypeScript requires it - const exhaustiveCheck: never = mode; - throw new Error(`Unknown transport mode: ${exhaustiveCheck}`); +async function initializeServer(modes: TransportModes): Promise { + if (modes.stdio) { + logger.warn('Starting GitLab MCP Server with stdio transport'); + await startStdioServer(); + } else if (modes.sse || modes.streamableHttp) { + logger.warn('Starting GitLab MCP Server with HTTP transport(s)'); + await startExpressServer({ + sseEnabled: modes.sse, + streamableHttpEnabled: modes.streamableHttp + }); + } else { + throw new Error('No transport mode enabled'); } } @@ -5378,15 +283,14 @@ async function initializeServerByTransportMode(mode: TransportMode): Promise { logger.error("Fatal error in main():", error); process.exit(1); diff --git a/package-lock.json b/package-lock.json index 34b24ad..f8a4f72 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,11 @@ "version": "1.0.76", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.10.0", + "@modelcontextprotocol/sdk": "1.13.3", + "@noble/hashes": "^1.8.0", + "@types/better-sqlite3": "^7.6.13", "@types/node-fetch": "^2.6.12", + "better-sqlite3": "^12.2.0", "express": "^5.1.0", "fetch-cookie": "^3.1.0", "form-data": "^4.0.0", @@ -20,6 +23,7 @@ "pino": "^9.7.0", "pino-pretty": "^13.0.0", "socks-proxy-agent": "^8.0.5", + "sqlite3": "^5.1.7", "tough-cookie": "^5.1.2", "zod-to-json-schema": "^3.23.5" }, @@ -252,6 +256,13 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@gar/promisify": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", + "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", + "license": "MIT", + "optional": true + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -358,16 +369,16 @@ "node": ">=18" } }, - "node_modules/@modelcontextprotocol/sdk/node_modules/eventsource": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", - "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", "license": "MIT", - "dependencies": { - "eventsource-parser": "^3.0.1" - }, "engines": { - "node": ">=18.0.0" + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" } }, "node_modules/@nodelib/fs.scandir": { @@ -408,6 +419,42 @@ "node": ">= 8" } }, + "node_modules/@npmcli/fs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", + "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "@gar/promisify": "^1.0.1", + "semver": "^7.3.5" + } + }, + "node_modules/@npmcli/move-file": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz", + "integrity": "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==", + "deprecated": "This functionality has been moved to @npmcli/fs", + "license": "MIT", + "optional": true, + "dependencies": { + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@tootallnate/once": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", + "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6" + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", @@ -436,6 +483,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/body-parser": { "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", @@ -797,6 +853,13 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC", + "optional": true + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -855,6 +918,33 @@ "node": ">= 14" } }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "license": "MIT", + "optional": true, + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -871,6 +961,16 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -887,6 +987,28 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/aproba": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", + "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==", + "license": "ISC", + "optional": true + }, + "node_modules/are-we-there-yet": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", + "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -962,9 +1084,63 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, + "devOptional": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], "license": "MIT" }, + "node_modules/better-sqlite3": { + "version": "12.2.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.2.0.tgz", + "integrity": "sha512-eGbYq2CT+tos1fBwLQ/tkBt9J5M3JEHjku4hbvQUePCckkvVf14xWj+1m7dGoK81M/fOjFT7yM9UMeKT/+vFLQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/body-parser": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", @@ -1008,6 +1184,30 @@ "node": ">=8" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1017,6 +1217,36 @@ "node": ">= 0.8" } }, + "node_modules/cacache": { + "version": "15.3.0", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz", + "integrity": "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "@npmcli/fs": "^1.0.0", + "@npmcli/move-file": "^1.0.1", + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "glob": "^7.1.4", + "infer-owner": "^1.0.4", + "lru-cache": "^6.0.0", + "minipass": "^3.1.1", + "minipass-collect": "^1.0.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.2", + "mkdirp": "^1.0.3", + "p-map": "^4.0.0", + "promise-inflight": "^1.0.1", + "rimraf": "^3.0.2", + "ssri": "^8.0.1", + "tar": "^6.0.2", + "unique-filename": "^1.1.1" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -1073,6 +1303,25 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1093,6 +1342,16 @@ "dev": true, "license": "MIT" }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "license": "ISC", + "optional": true, + "bin": { + "color-support": "bin.js" + } + }, "node_modules/colorette": { "version": "2.0.20", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", @@ -1125,9 +1384,16 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC", + "optional": true + }, "node_modules/content-disposition": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", @@ -1236,6 +1502,30 @@ } } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -1252,6 +1542,13 @@ "node": ">=0.4.0" } }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT", + "optional": true + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -1261,6 +1558,15 @@ "node": ">= 0.8" } }, + "node_modules/detect-libc": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/diff": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", @@ -1291,6 +1597,13 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT", + "optional": true + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -1300,6 +1613,16 @@ "node": ">= 0.8" } }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -1309,6 +1632,23 @@ "once": "^1.4.0" } }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "license": "MIT", + "optional": true + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -1597,6 +1937,18 @@ "node": ">= 0.6" } }, + "node_modules/eventsource": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.6.tgz", + "integrity": "sha512-l19WpE2m9hSuyP06+FbuUUf1G+R0SFLrtQfbRb9PRr+oimOfxQhgGCbVaXg5IvZyyTThJsxh6L/srkMiCeBPDA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/eventsource-parser": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.1.tgz", @@ -1606,6 +1958,15 @@ "node": ">=18.0.0" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/express": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", @@ -1789,6 +2150,12 @@ "node": ">=16.0.0" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -1923,6 +2290,31 @@ "node": ">= 0.8" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC", + "optional": true + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -1932,6 +2324,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gauge": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", + "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.3", + "console-control-strings": "^1.1.0", + "has-unicode": "^2.0.1", + "signal-exit": "^3.0.7", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -1969,6 +2382,34 @@ "node": ">= 0.4" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "optional": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -1982,14 +2423,38 @@ "node": ">=10.13.0" } }, - "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "license": "MIT", - "engines": { - "node": ">=18" + "optional": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "optional": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -2007,6 +2472,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC", + "optional": true + }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", @@ -2073,6 +2545,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC", + "optional": true + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -2091,6 +2570,13 @@ "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", "license": "MIT" }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "license": "BSD-2-Clause", + "optional": true + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -2133,6 +2619,16 @@ "node": ">= 14" } }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -2145,6 +2641,26 @@ "node": ">=0.10.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.4.tgz", @@ -2212,18 +2728,53 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/infer-owner": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", + "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", + "license": "ISC", + "optional": true + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "optional": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/ip-address": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", @@ -2256,6 +2807,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -2269,6 +2830,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-lambda": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", + "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", + "license": "MIT", + "optional": true + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -2386,6 +2954,19 @@ "dev": true, "license": "MIT" }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", @@ -2393,6 +2974,101 @@ "dev": true, "license": "ISC" }, + "node_modules/make-fetch-happen": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz", + "integrity": "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==", + "license": "ISC", + "optional": true, + "dependencies": { + "agentkeepalive": "^4.1.3", + "cacache": "^15.2.0", + "http-cache-semantics": "^4.1.0", + "http-proxy-agent": "^4.0.1", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^6.0.0", + "minipass": "^3.1.3", + "minipass-collect": "^1.0.2", + "minipass-fetch": "^1.3.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.2", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^6.0.0", + "ssri": "^8.0.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/make-fetch-happen/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/make-fetch-happen/node_modules/http-proxy-agent": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", + "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tootallnate/once": "1", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/make-fetch-happen/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/make-fetch-happen/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/make-fetch-happen/node_modules/socks-proxy-agent": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-6.2.1.tgz", + "integrity": "sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "^6.0.2", + "debug": "^4.3.3", + "socks": "^2.6.2" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -2468,6 +3144,18 @@ "node": ">= 0.6" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -2493,12 +3181,131 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-collect": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", + "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-fetch": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-1.4.1.tgz", + "integrity": "sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw==", + "license": "MIT", + "optional": true, + "dependencies": { + "minipass": "^3.1.0", + "minipass-sized": "^1.0.3", + "minizlib": "^2.0.0" + }, + "engines": { + "node": ">=8" + }, + "optionalDependencies": { + "encoding": "^0.1.12" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", + "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", + "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -2522,6 +3329,24 @@ "dev": true, "license": "MIT" }, + "node_modules/node-abi": { + "version": "3.75.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.75.0.tgz", + "integrity": "sha512-OhYaY5sDsIka7H7AtijtI9jwGYLyl29eQn/W623DiN/MIv5sUqc4g7BIDThX+gb7di9f6xK02nkp8sdfFWZLTg==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -2559,6 +3384,64 @@ "url": "https://opencollective.com/node-fetch" } }, + "node_modules/node-gyp": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz", + "integrity": "sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==", + "license": "MIT", + "optional": true, + "dependencies": { + "env-paths": "^2.2.0", + "glob": "^7.1.4", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^9.1.0", + "nopt": "^5.0.0", + "npmlog": "^6.0.0", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.2", + "which": "^2.0.2" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": ">= 10.12.0" + } + }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/npmlog": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", + "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "are-we-there-yet": "^3.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^4.0.3", + "set-blocking": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -2660,6 +3543,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -2705,6 +3604,16 @@ "node": ">=8" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -2806,6 +3715,32 @@ "node": ">=16.20.0" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -2848,6 +3783,27 @@ ], "license": "MIT" }, + "node_modules/promise-inflight": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", + "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", + "license": "ISC", + "optional": true + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "license": "MIT", + "optional": true, + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -2946,6 +3902,44 @@ "node": ">= 0.8" } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/real-require": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", @@ -2965,6 +3959,16 @@ "node": ">=4" } }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 4" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -2976,6 +3980,23 @@ "node": ">=0.10.0" } }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "optional": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -3061,7 +4082,6 @@ "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -3107,6 +4127,13 @@ "node": ">= 18" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC", + "optional": true + }, "node_modules/set-cookie-parser": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", @@ -3212,6 +4239,58 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC", + "optional": true + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -3284,6 +4363,43 @@ "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", "license": "BSD-3-Clause" }, + "node_modules/sqlite3": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.7.tgz", + "integrity": "sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "bindings": "^1.5.0", + "node-addon-api": "^7.0.0", + "prebuild-install": "^7.1.1", + "tar": "^6.1.11" + }, + "optionalDependencies": { + "node-gyp": "8.x" + }, + "peerDependencies": { + "node-gyp": "8.x" + }, + "peerDependenciesMeta": { + "node-gyp": { + "optional": true + } + } + }, + "node_modules/ssri": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", + "integrity": "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.1.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -3293,6 +4409,43 @@ "node": ">= 0.8" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "optional": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -3318,6 +4471,66 @@ "node": ">=8" } }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar-fs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz", + "integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-fs/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "engines": { + "node": ">=8" + } + }, "node_modules/thread-stream": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", @@ -3443,6 +4656,18 @@ } } }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -3504,6 +4729,26 @@ "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", "license": "MIT" }, + "node_modules/unique-filename": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", + "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "unique-slug": "^2.0.0" + } + }, + "node_modules/unique-slug": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz", + "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==", + "license": "ISC", + "optional": true, + "dependencies": { + "imurmurhash": "^0.1.4" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -3522,6 +4767,12 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -3580,6 +4831,16 @@ "node": ">= 8" } }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -3603,6 +4864,12 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", diff --git a/package.json b/package.json index b69bac7..3835b61 100644 --- a/package.json +++ b/package.json @@ -32,8 +32,11 @@ "format:check": "prettier --check \"**/*.{js,ts,json,md}\"" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.10.0", + "@modelcontextprotocol/sdk": "1.13.3", + "@noble/hashes": "^1.8.0", + "@types/better-sqlite3": "^7.6.13", "@types/node-fetch": "^2.6.12", + "better-sqlite3": "^12.2.0", "express": "^5.1.0", "fetch-cookie": "^3.1.0", "form-data": "^4.0.0", @@ -43,6 +46,7 @@ "pino": "^9.7.0", "pino-pretty": "^13.0.0", "socks-proxy-agent": "^8.0.5", + "sqlite3": "^5.1.7", "tough-cookie": "^5.1.2", "zod-to-json-schema": "^3.23.5" }, diff --git a/src/argon2wrapper.ts b/src/argon2wrapper.ts new file mode 100644 index 0000000..60dde94 --- /dev/null +++ b/src/argon2wrapper.ts @@ -0,0 +1,85 @@ +import { argon2id } from '@noble/hashes/argon2'; +import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils'; +import { randomBytes } from 'crypto'; + +// Default options similar to @node-rs/argon2 +const DEFAULT_OPTIONS = { + t: 3, // iterations + m: 65536, // 64MB memory + p: 4, // parallelism + maxmem: 2 ** 32 - 1 +}; + +interface Argon2Options { + salt?: Uint8Array; +} + +/** + * Hash a password using argon2id + */ +export async function hash(password: string, options?: Argon2Options): Promise { + const salt = options?.salt || randomBytes(16); + const passwordBytes = utf8ToBytes(password); + + const hashBytes = argon2id(passwordBytes, salt, DEFAULT_OPTIONS); + + // Store salt and hash together for verification later + // Format: salt:hash (both as hex) + return `${bytesToHex(salt)}:${bytesToHex(hashBytes)}`; +} + +/** + * Synchronous version of hash + */ +export function hashSync(password: string, options?: Argon2Options): string { + const salt = options?.salt || randomBytes(16); + const passwordBytes = utf8ToBytes(password); + + const hashBytes = argon2id(passwordBytes, salt, DEFAULT_OPTIONS); + + // Store salt and hash together for verification later + // Format: salt:hash (both as hex) + return `${bytesToHex(salt)}:${bytesToHex(hashBytes)}`; +} + +/** + * Verify a password against a hash + */ +export async function verify(storedHash: string, password: string, options?: Argon2Options): Promise { + try { + // If options.salt is provided, it means we're using the old format + // where salt was provided separately + if (options?.salt) { + const passwordBytes = utf8ToBytes(password); + const hashBytes = argon2id(passwordBytes, options.salt, DEFAULT_OPTIONS); + const newHash = bytesToHex(hashBytes); + + // storedHash might be just the hash part without salt prefix + const hashPart = storedHash.includes(':') ? storedHash.split(':')[1] : storedHash; + return newHash === hashPart; + } + + // New format: salt:hash + const [saltHex, hashHex] = storedHash.split(':'); + if (!saltHex || !hashHex) { + return false; + } + + const salt = hexToBytes(saltHex); + const passwordBytes = utf8ToBytes(password); + + const computedHashBytes = argon2id(passwordBytes, salt, DEFAULT_OPTIONS); + const computedHashHex = bytesToHex(computedHashBytes); + + return computedHashHex === hashHex; + } catch (error) { + return false; + } +} + +// Export as default object to match @node-rs/argon2 interface +export default { + hash, + hashSync, + verify +}; \ No newline at end of file diff --git a/src/authentication.ts b/src/authentication.ts new file mode 100644 index 0000000..d89631a --- /dev/null +++ b/src/authentication.ts @@ -0,0 +1,100 @@ +import { Request, Response, NextFunction, RequestHandler, Express } from 'express'; +import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js'; +import { config } from './config.js'; +import { createGitLabOAuthProvider } from './oauth.js'; +import { logger } from './logger.js'; +import { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js'; + +// Extend Express Request type to include auth property +declare global { + namespace Express { + interface Request { + auth?: AuthInfo; + } + } +} + +/** + * Configure authentication middleware based on the configuration + * Supports OAuth2, PAT passthrough, and static PAT modes + */ +export async function configureAuthentication(app: Express): Promise { + // Default middleware that does nothing + let authMiddleware: RequestHandler| undefined = undefined + // OAuth2 mode + if (config.GITLAB_OAUTH2_CLIENT_ID) { + logger.warn("Configuring GitLab OAuth2 proxy authentication"); + logger.warn("Please note that GitLab OAuth2 proxy authentication is not yet fully supported. Use this feature at your own risk"); + + // Create the provider + const provider = await createGitLabOAuthProvider(); + + // Add the callback handler route BEFORE the OAuth router + app.get("/callback", (req, res) => provider.handleOAuthCallback(req, res)); + + // Set up OAuth2 proxy router + const oauth2Router = provider.createOAuth2Router(); + app.use(oauth2Router); + + // Create token verifier and bearer auth middleware + const tokenVerifier = provider.createTokenVerifier(); + const bearerAuthMiddleware = requireBearerAuth({ + verifier: tokenVerifier, + resourceMetadataUrl: `${config.GITLAB_OAUTH2_BASE_URL}/.well-known/oauth-protected-resource` + }); + + authMiddleware = (req: Request, res: Response, next: NextFunction) => { + // Ensure GitLab-Token header is not set in OAuth2 mode + const gitlabToken = req.headers["gitlab-token"]; + if (gitlabToken) { + res.status(401).send("Gitlab-Token header must not be set when MCP is running in OAuth2 mode"); + return; + } + bearerAuthMiddleware(req, res, next); + }; + } + // PAT passthrough mode + else if (config.GITLAB_PAT_PASSTHROUGH) { + logger.info("Configuring GitLab PAT passthrough authentication. Users must set the Gitlab-Token header in their requests"); + + authMiddleware = (req: Request, res: Response, next: NextFunction) => { + // Check the Gitlab-Token header + const token = req.headers["gitlab-token"]; + if (!token) { + res.status(401).send("Please set a Gitlab-Token header in your request"); + return; + } + if (typeof token !== "string") { + res.status(401).send("Gitlab-Token must only be set once"); + return; + } + + req.auth = { + token: token, + clientId: "!passthrough", + scopes: [], + }; + next(); + }; + } + // Static PAT mode + else if (config.GITLAB_PERSONAL_ACCESS_TOKEN) { + logger.info("Configuring static GitLab Personal Access Token authentication"); + + const accessToken = config.GITLAB_PERSONAL_ACCESS_TOKEN; + authMiddleware = (req: Request, res: Response, next: NextFunction) => { + req.auth = { + token: accessToken, + clientId: "!global", + scopes: [], + }; + next(); + }; + } + + if(authMiddleware === undefined) { + throw new Error("No authMiddleware configured. This is a bug. Please report it."); + } + + return authMiddleware; +} diff --git a/src/authhelpers.ts b/src/authhelpers.ts new file mode 100644 index 0000000..0089b68 --- /dev/null +++ b/src/authhelpers.ts @@ -0,0 +1,50 @@ +import { config } from "./config.js"; +import { CookieJar, parse as parseCookie } from "tough-cookie"; +import fs from "fs"; +import path from "path"; + +// Create cookie jar with clean Netscape file parsing +export const createCookieJar = (): CookieJar | undefined=> { + if (!config.GITLAB_AUTH_COOKIE_PATH) return undefined; + + try { + const cookiePath = config.GITLAB_AUTH_COOKIE_PATH.startsWith("~/") + ? path.join(process.env.HOME || "", config.GITLAB_AUTH_COOKIE_PATH.slice(2)) + : config.GITLAB_AUTH_COOKIE_PATH; + + const jar = new CookieJar(); + const cookieContent = fs.readFileSync(cookiePath, "utf8"); + + cookieContent.split("\n").forEach(line => { + // Handle #HttpOnly_ prefix + if (line.startsWith("#HttpOnly_")) { + line = line.slice(10); + } + // Skip comments and empty lines + if (line.startsWith("#") || !line.trim()) { + return; + } + + // Parse Netscape format: domain, flag, path, secure, expires, name, value + const parts = line.split("\t"); + if (parts.length >= 7) { + const [domain, , path, secure, expires, name, value] = parts; + + // Build cookie string in standard format + const cookieStr = `${name}=${value}; Domain=${domain}; Path=${path}${secure === "TRUE" ? "; Secure" : ""}${expires !== "0" ? `; Expires=${new Date(parseInt(expires) * 1000).toUTCString()}` : ""}`; + + // Use tough-cookie's parse function for robust parsing + const cookie = parseCookie(cookieStr); + if (cookie) { + const url = `${secure === "TRUE" ? "https" : "http"}://${domain.startsWith(".") ? domain.slice(1) : domain}`; + jar.setCookieSync(cookie, url); + } + } + }); + + return jar; + } catch (error) { + console.error("Error loading cookie file:", error); + return undefined; + } +}; diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..f03cc79 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,118 @@ +export const unsafeDefaultArgon2Salt = "change-me-in-production"; + +export const config = { + HOST: process.env.HOST || '127.0.0.1', + PORT: process.env.PORT || 3002, + SSE: process.env.SSE === "true", + STREAMABLE_HTTP: process.env.STREAMABLE_HTTP === "true", + IS_OLD : process.env.GITLAB_IS_OLD === "true", + GITLAB_READ_ONLY_MODE : process.env.GITLAB_READ_ONLY_MODE === "true", + USE_GITLAB_WIKI : process.env.USE_GITLAB_WIKI === "true", + USE_MILESTONE : process.env.USE_MILESTONE === "true", + USE_PIPELINE : process.env.USE_PIPELINE === "true", + // Add proxy configuration + HTTP_PROXY : process.env.HTTP_PROXY, + HTTPS_PROXY : process.env.HTTPS_PROXY, + NODE_TLS_REJECT_UNAUTHORIZED : process.env.NODE_TLS_REJECT_UNAUTHORIZED, + GITLAB_CA_CERT_PATH : process.env.GITLAB_CA_CERT_PATH, + // Use the normalizeGitLabApiUrl function to handle various URL formats + GITLAB_API_URL: normalizeGitLabApiUrl(process.env.GITLAB_API_URL || undefined), + GITLAB_PROJECT_ID: process.env.GITLAB_PROJECT_ID, + + + ARGON2_SALT: process.env.ARGON2_SALT || unsafeDefaultArgon2Salt, + // Configure cookie auth path, for gitlab instances which require it + // TODO: investigate the consequences of this with oauth2 and pat passthrough. should it only be used in PAT mode and not passthrough? + GITLAB_AUTH_COOKIE_PATH : process.env.GITLAB_AUTH_COOKIE_PATH, + + // only one of GITLAB_PERSONAL_ACCESS_TOKEN, GITLAB_OAUTH2_CLIENT_ID, GITLAB_PAT_PASSTHROUGH + + // Gitlab PAT configuration. use this PAT to authenticate all requests + GITLAB_PERSONAL_ACCESS_TOKEN : process.env.GITLAB_PERSONAL_ACCESS_TOKEN, + + // Gitlab PAT passthrough. pass through the PRIVATE-TOKEN header to make the request to the Gitlab API + // should be "true" to enable + GITLAB_PAT_PASSTHROUGH : process.env.GITLAB_PAT_PASSTHROUGH === "true", + + // GitLab OAuth2 configuration + GITLAB_OAUTH2_CLIENT_ID: process.env.GITLAB_OAUTH2_CLIENT_ID, + GITLAB_OAUTH2_CLIENT_SECRET: process.env.GITLAB_OAUTH2_CLIENT_SECRET, + GITLAB_OAUTH2_REDIRECT_URL: process.env.GITLAB_OAUTH2_REDIRECT_URL, + + // we need a database in order for the oauth2 provider to persist clients across restarts. + GITLAB_OAUTH2_DB_PATH: process.env.GITLAB_OAUTH2_DB_PATH || ":memory:", + + // base url matters for the redirect url, i think? + GITLAB_OAUTH2_BASE_URL: process.env.GITLAB_OAUTH2_BASE_URL, // http://localhost:3002 + + // TODO: maybe thse can be formed based off of the ISSUER_URL? im not sure... (could introduce problems if gitlab ever changes these endpoints, though i doubt they will) + GITLAB_OAUTH2_TOKEN_URL: process.env.GITLAB_OAUTH2_TOKEN_URL, // https://gitlab.com/oauth/token + GITLAB_OAUTH2_AUTHORIZATION_URL: process.env.GITLAB_OAUTH2_AUTHORIZATION_URL, // https://gitlab.com/oauth/authorize + GITLAB_OAUTH2_REVOCATION_URL: process.env.GITLAB_OAUTH2_REVOCATION_URL, // https://gitlab.com/oauth/revoke + GITLAB_OAUTH2_INTROSPECTION_URL: process.env.GITLAB_OAUTH2_INTROSPECTION_URL, // https://gitlab.com/oauth/introspect + GITLAB_OAUTH2_REGISTRATION_URL: process.env.GITLAB_OAUTH2_REGISTRATION_URL, // ? + + GITLAB_OAUTH2_ISSUER_URL: process.env.GITLAB_OAUTH2_ISSUER_URL, // https://gitlab.com + +} + +export const validateConfiguration = ()=> { + // Check if using default ARGON2_SALT + if (config.ARGON2_SALT === unsafeDefaultArgon2Salt) { + console.error('\n' + '='.repeat(80)); + console.error('โš ๏ธ WARNING: USING DEFAULT ARGON2_SALT VALUE!'); + console.error('='.repeat(80)); + console.error(''); + console.error('You are using the default ARGON2_SALT value which is INSECURE.'); + console.error('This salt is publicly known and makes your password hashes vulnerable.'); + console.error(''); + console.error('Please set the ARGON2_SALT environment variable to a unique, random value:'); + console.error(' export ARGON2_SALT="your-unique-random-salt-here"'); + console.error(''); + console.error('You can generate a secure salt with:'); + console.error(' openssl rand -base64 32'); + console.error(''); + console.error('='.repeat(80) + '\n'); + } + + // check that only one of GITLAB_PERSONAL_ACCESS_TOKEN, GITLAB_OAUTH2_CLIENT_ID, GITLAB_PAT_PASSTHROUGH is set + const onlyOnOf = [ + config.GITLAB_PERSONAL_ACCESS_TOKEN, + config.GITLAB_OAUTH2_CLIENT_ID, + config.GITLAB_PAT_PASSTHROUGH + ] + const countOfSet = onlyOnOf.filter(x=>!!x).length + + const allVariableNames = "GITLAB_PERSONAL_ACCESS_TOKEN, GITLAB_OAUTH2_CLIENT_ID, GITLAB_PAT_PASSTHROUGH" + if (countOfSet == 0 ) { + console.error(`One of the following variables must be set: ${allVariableNames}`) + process.exit(1) + } + if (countOfSet > 1) { + console.error(`Only one of the following variables can be set: ${allVariableNames}`) + process.exit(1) + } +} + +/** + * Smart URL handling for GitLab API + * + * @param {string | undefined} url - Input GitLab API URL + * @returns {string} Normalized GitLab API URL with /api/v4 path + */ +function normalizeGitLabApiUrl(url?: string): string { + if (!url) { + return "https://gitlab.com/api/v4"; + } + + // Remove trailing slash if present + let normalizedUrl = url.endsWith("/") ? url.slice(0, -1) : url; + + // Check if URL already has /api/v4 + if (!normalizedUrl.endsWith("/api/v4") && !normalizedUrl.endsWith("/api/v4/")) { + // Append /api/v4 if not already present + normalizedUrl = `${normalizedUrl}/api/v4`; + } + + return normalizedUrl; +} diff --git a/customSchemas.ts b/src/customSchemas.ts similarity index 67% rename from customSchemas.ts rename to src/customSchemas.ts index e8546f1..cee660f 100644 --- a/customSchemas.ts +++ b/src/customSchemas.ts @@ -1,20 +1,6 @@ import { z } from "zod"; -import { pino } from 'pino'; const DEFAULT_NULL = process.env.DEFAULT_NULL === "true"; - -const logger = pino({ - level: process.env.LOG_LEVEL || 'info', - transport: { - target: 'pino-pretty', - options: { - colorize: true, - levelFirst: true, - destination: 2, - }, - }, -}); - export const flexibleBoolean = z.preprocess(val => { if (typeof val === "boolean") { return val; diff --git a/src/gitlabhandler.ts b/src/gitlabhandler.ts new file mode 100644 index 0000000..a8dc44d --- /dev/null +++ b/src/gitlabhandler.ts @@ -0,0 +1,2554 @@ +import { z } from "zod"; +import { URL } from "url"; +import { Response, RequestInit } from "node-fetch"; +import fs from "fs"; +import path from "path"; +import { config } from "./config.js"; +import { + GitLabForkSchema, + GitLabReferenceSchema, + GitLabRepositorySchema, + GitLabIssueSchema, + GitLabMergeRequestSchema, + GitLabContentSchema, + GitLabCreateUpdateFileResponseSchema, + GitLabSearchResponseSchema, + GitLabTreeSchema, + GitLabCommitSchema, + GitLabNamespaceSchema, + GitLabNamespaceExistsResponseSchema, + GitLabProjectSchema, + GitLabUserSchema, + GitLabUsersResponseSchema, + CreateRepositoryOptionsSchema, + CreateIssueOptionsSchema, + CreateBranchOptionsSchema, + GitLabDiffSchema, + GitLabIssueLinkSchema, + GitLabIssueWithLinkDetailsSchema, + GitLabDiscussionNoteSchema, + GitLabDiscussionSchema, + PaginatedDiscussionsResponseSchema, + GitLabWikiPageSchema, + GitLabTreeItemSchema, + GitLabPipelineSchema, + GitLabPipelineJobSchema, + GitLabMilestonesSchema, + GitLabCompareResultSchema, + GitLabDraftNoteSchema, + GitLabProjectMemberSchema, + GitLabMarkdownUploadSchema, + MergeMergeRequestSchema, + ListProjectMembersSchema, + MyIssuesSchema, + type GitLabDraftNote, + type MergeRequestThreadPositionCreate, + type GitLabProjectMember, + type GitLabMarkdownUpload, + type GitLabFork, + type GitLabReference, + type GitLabRepository, + type GitLabIssue, + type GitLabMergeRequest, + type GitLabContent, + type GitLabCreateUpdateFileResponse, + type GitLabSearchResponse, + type GitLabTree, + type GitLabCommit, + type FileOperation, + type GitLabMergeRequestDiff, + type GitLabIssueLink, + type GitLabIssueWithLinkDetails, + type GitLabNamespace, + type GitLabNamespaceExistsResponse, + type GitLabProject, + type GitLabLabel, + type GitLabUser, + type GitLabUsersResponse, + type GitLabPipeline, + type ListPipelinesOptions, + type GetPipelineOptions, + type ListPipelineJobsOptions, + type CreatePipelineOptions, + type RetryPipelineOptions, + type CancelPipelineOptions, + type GitLabPipelineJob, + type GitLabMilestones, + type ListProjectMilestonesOptions, + type GetProjectMilestoneOptions, + type CreateProjectMilestoneOptions, + type EditProjectMilestoneOptions, + type DeleteProjectMilestoneOptions, + type GetMilestoneIssuesOptions, + type GetMilestoneMergeRequestsOptions, + type PromoteProjectMilestoneOptions, + type GetMilestoneBurndownEventsOptions, + type GitLabDiscussionNote, + type GitLabDiscussion, + type PaginatedDiscussionsResponse, + type PaginationOptions, + type MergeRequestThreadPosition, + type GetWikiPageOptions, + type CreateWikiPageOptions, + type UpdateWikiPageOptions, + type DeleteWikiPageOptions, + type GitLabWikiPage, + type GitLabTreeItem, + type GetRepositoryTreeOptions, + type GitLabCompareResult, + type ListWikiPagesOptions, + type ListCommitsOptions, + type GetCommitOptions, + type GetCommitDiffOptions, + ListProjectMilestonesSchema, + UpdateMergeRequestSchema, + ListIssuesSchema, + UpdateIssueSchema, + ListMergeRequestsSchema, + ListLabelsSchema, + UpdateLabelSchema, + ListGroupProjectsSchema, + ListProjectsSchema, + CreateLabelSchema, + CreateProjectMilestoneSchema, + EditProjectMilestoneSchema, + CreateMergeRequestSchema, +} from "./schemas.js"; +import {GitlabSession} from "./gitlabsession.js"; + +export class GitlabHandler extends GitlabSession { + + /** + * Utility function for handling GitLab API errors + */ + async handleGitLabError(response: Response): Promise { + if (!response.ok) { + const errorBody = await response.text(); + // Check specifically for Rate Limit error + if (response.status === 403 && errorBody.includes("User API Key Rate limit exceeded")) { + console.error("GitLab API Rate Limit Exceeded:", errorBody); + console.log("User API Key Rate limit exceeded. Please try again later."); + throw new Error(`GitLab API Rate Limit Exceeded: ${errorBody}`); + } else { + // Handle other API errors + throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`); + } + } + } + + /** + * Get effective project ID based on config or provided ID + */ + getEffectiveProjectId(projectId: string): string { + return config.GITLAB_PROJECT_ID || projectId; + } + + /** + * Create a fork of a GitLab project + */ + async forkProject(projectId: string, namespace?: string): Promise { + projectId = decodeURIComponent(projectId); + const effectiveProjectId = this.getEffectiveProjectId(projectId); + const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/fork`); + + if (namespace) { + url.searchParams.append("namespace", namespace); + } + + const response = await this.fetch(url.toString(), { + + method: "POST", + }); + + if (response.status === 409) { + throw new Error("Project already exists in the target namespace"); + } + + await this.handleGitLabError(response); + const data = await response.json(); + return GitLabForkSchema.parse(data); + } + + /** + * Create a new branch in a GitLab project + */ + async createBranch( + projectId: string, + options: z.infer + ): Promise { + projectId = decodeURIComponent(projectId); + const effectiveProjectId = this.getEffectiveProjectId(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/repository/branches` + ); + + const response = await this.fetch(url.toString(), { + + method: "POST", + body: JSON.stringify({ + branch: options.name, + ref: options.ref, + }), + }); + + await this.handleGitLabError(response); + return GitLabReferenceSchema.parse(await response.json()); + } + + /** + * Get the default branch for a GitLab project + */ + async getDefaultBranchRef(projectId: string): Promise { + projectId = decodeURIComponent(projectId); + const effectiveProjectId = this.getEffectiveProjectId(projectId); + const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}`); + + const response = await this.fetch(url.toString(), {}); + + await this.handleGitLabError(response); + const project = GitLabRepositorySchema.parse(await response.json()); + return project.default_branch ?? "main"; + } + + /** + * Get the contents of a file from a GitLab project + */ + async getFileContents( + projectId: string, + filePath: string, + ref?: string + ): Promise { + projectId = decodeURIComponent(projectId); + const effectiveProjectId = this.getEffectiveProjectId(projectId); + const encodedPath = encodeURIComponent(filePath); + + if (!ref) { + ref = await this.getDefaultBranchRef(projectId); + } + + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/repository/files/${encodedPath}` + ); + + url.searchParams.append("ref", ref); + + const response = await this.fetch(url.toString(), {}); + + if (response.status === 404) { + throw new Error(`File not found: ${filePath}`); + } + + await this.handleGitLabError(response); + const data = await response.json(); + const parsedData = GitLabContentSchema.parse(data); + + // Decode Base64 content to UTF-8 + if (!Array.isArray(parsedData) && parsedData.content) { + parsedData.content = Buffer.from(parsedData.content, "base64").toString("utf8"); + parsedData.encoding = "utf8"; + } + + return parsedData; + } + + /** + * Create a new issue in a GitLab project + */ + async createIssue( + projectId: string, + options: z.infer + ): Promise { + projectId = decodeURIComponent(projectId); + const effectiveProjectId = this.getEffectiveProjectId(projectId); + const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/issues`); + + const response = await this.fetch(url.toString(), { + + method: "POST", + body: JSON.stringify({ + title: options.title, + description: options.description, + assignee_ids: options.assignee_ids, + milestone_id: options.milestone_id, + labels: options.labels?.join(","), + }), + }); + + if (response.status === 400) { + const errorBody = await response.text(); + throw new Error(`Invalid request: ${errorBody}`); + } + + await this.handleGitLabError(response); + const data = await response.json(); + return GitLabIssueSchema.parse(data); + } + + /** + * List issues in a GitLab project + */ + async listIssues( + projectId: string, + options: Omit, "project_id"> = {} + ): Promise { + projectId = decodeURIComponent(projectId); + const effectiveProjectId = this.getEffectiveProjectId(projectId); + const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/issues`); + + Object.entries(options).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + const keys = ["labels", "assignee_username"]; + if (keys.includes(key)) { + if (Array.isArray(value)) { + value.forEach(label => { + url.searchParams.append(`${key}[]`, label.toString()); + }); + } else { + url.searchParams.append(`${key}[]`, value.toString()); + } + } else { + url.searchParams.append(key, value.toString()); + } + } + }); + + const response = await this.fetch(url.toString(), {}); + + await this.handleGitLabError(response); + const data = await response.json(); + return z.array(GitLabIssueSchema).parse(data); + } + + /** + * List merge requests in a GitLab project with optional filtering + */ + async listMergeRequests( + projectId: string, + options: Omit, "project_id"> = {} + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/merge_requests`); + + Object.entries(options).forEach(([key, value]) => { + if (value !== undefined) { + if (key === "labels" && Array.isArray(value)) { + url.searchParams.append(key, value.join(",")); + } else { + url.searchParams.append(key, value.toString()); + } + } + }); + + const response = await this.fetch(url.toString(), {}); + + await this.handleGitLabError(response); + const data = await response.json(); + return z.array(GitLabMergeRequestSchema).parse(data); + } + + /** + * Get a single issue from a GitLab project + */ + async getIssue(projectId: string, issueIid: number | string): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/issues/${issueIid}` + ); + + const response = await this.fetch(url.toString(), {}); + + await this.handleGitLabError(response); + const data = await response.json(); + return GitLabIssueSchema.parse(data); + } + + /** + * Update an issue in a GitLab project + */ + async updateIssue( + projectId: string, + issueIid: number | string, + options: Omit, "project_id" | "issue_iid"> + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/issues/${issueIid}` + ); + + const body: Record = { ...options }; + if (body.labels && Array.isArray(body.labels)) { + body.labels = body.labels.join(","); + } + + const response = await this.fetch(url.toString(), { + + method: "PUT", + body: JSON.stringify(body), + }); + + await this.handleGitLabError(response); + const data = await response.json(); + return GitLabIssueSchema.parse(data); + } + + /** + * Delete an issue from a GitLab project + */ + async deleteIssue(projectId: string, issueIid: number | string): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/issues/${issueIid}` + ); + + const response = await this.fetch(url.toString(), { + + method: "DELETE", + }); + + await this.handleGitLabError(response); + } + + /** + * List all issue links for a specific issue + */ + async listIssueLinks( + projectId: string, + issueIid: number | string + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/issues/${issueIid}/links` + ); + + const response = await this.fetch(url.toString(), {}); + + await this.handleGitLabError(response); + const data = await response.json(); + return z.array(GitLabIssueWithLinkDetailsSchema).parse(data); + } + + /** + * Get a specific issue link + */ + async getIssueLink( + projectId: string, + issueIid: number | string, + issueLinkId: number | string + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( +this.getEffectiveProjectId(projectId) +)}/issues/${issueIid}/links/${issueLinkId}` + ); + + const response = await this.fetch(url.toString(), {}); + + await this.handleGitLabError(response); + const data = await response.json(); + return GitLabIssueLinkSchema.parse(data); + } + + /** + * Create an issue link between two issues + */ + async createIssueLink( + projectId: string, + issueIid: number | string, + targetProjectId: string, + targetIssueIid: number | string, + linkType: "relates_to" | "blocks" | "is_blocked_by" = "relates_to" + ): Promise { + projectId = decodeURIComponent(projectId); + targetProjectId = decodeURIComponent(targetProjectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/issues/${issueIid}/links` + ); + + const response = await this.fetch(url.toString(), { + + method: "POST", + body: JSON.stringify({ + target_project_id: targetProjectId, + target_issue_iid: targetIssueIid, + link_type: linkType, + }), + }); + + await this.handleGitLabError(response); + const data = await response.json(); + return GitLabIssueLinkSchema.parse(data); + } + + /** + * Delete an issue link + */ + async deleteIssueLink( + projectId: string, + issueIid: number | string, + issueLinkId: number | string + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( +this.getEffectiveProjectId(projectId) +)}/issues/${issueIid}/links/${issueLinkId}` + ); + + const response = await this.fetch(url.toString(), { + + method: "DELETE", + }); + + await this.handleGitLabError(response); + } + + /** + * Create a new merge request in a GitLab project + */ + async createMergeRequest( + projectId: string, + options: Omit, "project_id"> + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/merge_requests`); + + const response = await this.fetch(url.toString(), { + method: "POST", + body: JSON.stringify({ + title: options.title, + description: options.description, + source_branch: options.source_branch, + target_branch: options.target_branch, + assignee_ids: options.assignee_ids, + reviewer_ids: options.reviewer_ids, + labels: options.labels?.join(","), + allow_collaboration: options.allow_collaboration, + draft: options.draft, + remove_source_branch: options.remove_source_branch, + squash: options.squash, + }), + }); + + if (response.status === 400) { + const errorBody = await response.text(); + throw new Error(`Invalid request: ${errorBody}`); + } + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`); + } + + const data = await response.json(); + return GitLabMergeRequestSchema.parse(data); + } + + /** + * Shared helper function for listing discussions + */ + async listDiscussions( + projectId: string, + resourceType: "issues" | "merge_requests", + resourceIid: number | string, + options: PaginationOptions = {} + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( +this.getEffectiveProjectId(projectId) +)}/${resourceType}/${resourceIid}/discussions` + ); + + if (options.page) { + url.searchParams.append("page", options.page.toString()); + } + if (options.per_page) { + url.searchParams.append("per_page", options.per_page.toString()); + } + + const response = await this.fetch(url.toString(), {}); + + await this.handleGitLabError(response); + const discussions = await response.json(); + + const pagination = { + x_next_page: response.headers.get("x-next-page") + ? parseInt(response.headers.get("x-next-page")!) + : null, + x_page: response.headers.get("x-page") ? parseInt(response.headers.get("x-page")!) : undefined, + x_per_page: response.headers.get("x-per-page") + ? parseInt(response.headers.get("x-per-page")!) + : undefined, + x_prev_page: response.headers.get("x-prev-page") + ? parseInt(response.headers.get("x-prev-page")!) + : null, + x_total: response.headers.get("x-total") ? parseInt(response.headers.get("x-total")!) : null, + x_total_pages: response.headers.get("x-total-pages") + ? parseInt(response.headers.get("x-total-pages")!) + : null, + }; + + return PaginatedDiscussionsResponseSchema.parse({ + items: discussions, + pagination: pagination, + }); + } + + /** + * List merge request discussion items + */ + async listMergeRequestDiscussions( + projectId: string, + mergeRequestIid: number | string, + options: PaginationOptions = {} + ): Promise { + return this.listDiscussions(projectId, "merge_requests", mergeRequestIid, options); + } + + /** + * List discussions for an issue + */ + async listIssueDiscussions( + projectId: string, + issueIid: number | string, + options: PaginationOptions = {} + ): Promise { + return this.listDiscussions(projectId, "issues", issueIid, options); + } + + /** + * Modify an existing merge request thread note + */ + async updateMergeRequestNote( + projectId: string, + mergeRequestIid: number | string, + discussionId: string, + noteId: number | string, + body?: string, + resolved?: boolean + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( +this.getEffectiveProjectId(projectId) +)}/merge_requests/${mergeRequestIid}/discussions/${discussionId}/notes/${noteId}` + ); + + const payload: { body?: string; resolved?: boolean } = {}; + if (body !== undefined) { + payload.body = body; + } else if (resolved !== undefined) { + payload.resolved = resolved; + } + + const response = await this.fetch(url.toString(), { + + method: "PUT", + body: JSON.stringify(payload), + }); + + await this.handleGitLabError(response); + const data = await response.json(); + return GitLabDiscussionNoteSchema.parse(data); + } + + /** + * Update an issue discussion note + */ + async updateIssueNote( + projectId: string, + issueIid: number | string, + discussionId: string, + noteId: number | string, + body: string + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( +this.getEffectiveProjectId(projectId) +)}/issues/${issueIid}/discussions/${discussionId}/notes/${noteId}` + ); + + const payload = { body }; + + const response = await this.fetch(url.toString(), { + + method: "PUT", + body: JSON.stringify(payload), + }); + + await this.handleGitLabError(response); + const data = await response.json(); + return GitLabDiscussionNoteSchema.parse(data); + } + + /** + * Create a note in an issue discussion + */ + async createIssueNote( + projectId: string, + issueIid: number | string, + discussionId: string, + body: string, + createdAt?: string + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( +this.getEffectiveProjectId(projectId) +)}/issues/${issueIid}/discussions/${discussionId}/notes` + ); + + const payload: { body: string; created_at?: string } = { body }; + if (createdAt) { + payload.created_at = createdAt; + } + + const response = await this.fetch(url.toString(), { + + method: "POST", + body: JSON.stringify(payload), + }); + + await this.handleGitLabError(response); + const data = await response.json(); + return GitLabDiscussionNoteSchema.parse(data); + } + + /** + * Add a new note to an existing merge request thread + */ + async createMergeRequestNote( + projectId: string, + mergeRequestIid: number | string, + discussionId: string, + body: string, + createdAt?: string + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( +this.getEffectiveProjectId(projectId) +)}/merge_requests/${mergeRequestIid}/discussions/${discussionId}/notes` + ); + + const payload: { body: string; created_at?: string } = { body }; + if (createdAt) { + payload.created_at = createdAt; + } + + const response = await this.fetch(url.toString(), { + + method: "POST", + body: JSON.stringify(payload), + }); + + await this.handleGitLabError(response); + const data = await response.json(); + return GitLabDiscussionNoteSchema.parse(data); + } + + /** + * Create or update a file in a GitLab project + */ + async createOrUpdateFile( + projectId: string, + filePath: string, + content: string, + commitMessage: string, + branch: string, + previousPath?: string, + last_commit_id?: string, + commit_id?: string + ): Promise { + projectId = decodeURIComponent(projectId); + const encodedPath = encodeURIComponent(filePath); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/repository/files/${encodedPath}` + ); + + const body: Record = { + branch, + content, + commit_message: commitMessage, + encoding: "text", + ...(previousPath ? { previous_path: previousPath } : {}), + }; + + let method = "POST"; + try { + const fileData = await this.getFileContents(projectId, filePath, branch); + method = "PUT"; + + if (!Array.isArray(fileData)) { + if (!commit_id && fileData.commit_id) { + body.commit_id = fileData.commit_id; + } else if (commit_id) { + body.commit_id = commit_id; + } + + if (!last_commit_id && fileData.last_commit_id) { + body.last_commit_id = fileData.last_commit_id; + } else if (last_commit_id) { + body.last_commit_id = last_commit_id; + } + } + } catch (error) { + if (!(error instanceof Error && error.message.includes("File not found"))) { + throw error; + } + if (commit_id) { + body.commit_id = commit_id; + } + if (last_commit_id) { + body.last_commit_id = last_commit_id; + } + } + + const response = await this.fetch(url.toString(), { + + method, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`); + } + + const data = await response.json(); + return GitLabCreateUpdateFileResponseSchema.parse(data); + } + + /** + * Create a tree structure in a GitLab project repository + */ + async createTree( + projectId: string, + files: FileOperation[], + ref?: string + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/repository/tree` + ); + + if (ref) { + url.searchParams.append("ref", ref); + } + + const response = await this.fetch(url.toString(), { + + method: "POST", + body: JSON.stringify({ + files: files.map(file => ({ + file_path: file.path, + content: file.content, + encoding: "text", + })), + }), + }); + + if (response.status === 400) { + const errorBody = await response.text(); + throw new Error(`Invalid request: ${errorBody}`); + } + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`); + } + + const data = await response.json(); + return GitLabTreeSchema.parse(data); + } + + /** + * Create a commit in a GitLab project repository + */ + async createCommit( + projectId: string, + message: string, + branch: string, + actions: FileOperation[] + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/repository/commits` + ); + + const response = await this.fetch(url.toString(), { + + method: "POST", + body: JSON.stringify({ + branch, + commit_message: message, + actions: actions.map(action => ({ + action: "create", + file_path: action.path, + content: action.content, + encoding: "text", + })), + }), + }); + + if (response.status === 400) { + const errorBody = await response.text(); + throw new Error(`Invalid request: ${errorBody}`); + } + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`); + } + + const data = await response.json(); + return GitLabCommitSchema.parse(data); + } + + /** + * Search for GitLab projects + */ + async searchProjects( + query: string, + page: number = 1, + perPage: number = 20 + ): Promise { + const url = new URL(`${config.GITLAB_API_URL}/projects`); + url.searchParams.append("search", query); + url.searchParams.append("page", page.toString()); + url.searchParams.append("per_page", perPage.toString()); + url.searchParams.append("order_by", "id"); + url.searchParams.append("sort", "desc"); + + const response = await this.fetch(url.toString(), {}); + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`); + } + + const projects = (await response.json()) as GitLabRepository[]; + const totalCount = response.headers.get("x-total"); + const totalPages = response.headers.get("x-total-pages"); + + const count = totalCount ? parseInt(totalCount) : projects.length; + + return GitLabSearchResponseSchema.parse({ + count, + total_pages: totalPages ? parseInt(totalPages) : Math.ceil(count / perPage), + current_page: page, + items: projects, + }); + } + + /** + * Create a new GitLab repository + */ + async createRepository( + options: z.infer + ): Promise { + const response = await this.fetch(`${config.GITLAB_API_URL}/projects`, { + + method: "POST", + body: JSON.stringify({ + name: options.name, + description: options.description, + visibility: options.visibility, + initialize_with_readme: options.initialize_with_readme, + default_branch: "main", + path: options.name.toLowerCase().replace(/\s+/g, "-"), + }), + }); + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`); + } + + const data = await response.json(); + return GitLabRepositorySchema.parse(data); + } + + /** + * Get merge request details + */ + async getMergeRequest( + projectId: string, + mergeRequestIid?: number | string, + branchName?: string + ): Promise { + projectId = decodeURIComponent(projectId); + let url: URL; + + if (mergeRequestIid) { + url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( +this.getEffectiveProjectId(projectId) +)}/merge_requests/${mergeRequestIid}` + ); + } else if (branchName) { + url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( +this.getEffectiveProjectId(projectId) +)}/merge_requests?source_branch=${encodeURIComponent(branchName)}` + ); + } else { + throw new Error("Either mergeRequestIid or branchName must be provided"); + } + + const response = await this.fetch(url.toString(), {}); + + await this.handleGitLabError(response); + + const data = await response.json(); + + if (Array.isArray(data) && data.length > 0) { + return GitLabMergeRequestSchema.parse(data[0]); + } + + return GitLabMergeRequestSchema.parse(data); + } + + /** + * Get merge request changes/diffs + */ + async getMergeRequestDiffs( + projectId: string, + mergeRequestIid?: number | string, + branchName?: string, + view?: "inline" | "parallel" + ): Promise { + projectId = decodeURIComponent(projectId); + if (!mergeRequestIid && !branchName) { + throw new Error("Either mergeRequestIid or branchName must be provided"); + } + + if (branchName && !mergeRequestIid) { + const mergeRequest = await this.getMergeRequest(projectId, undefined, branchName); + mergeRequestIid = mergeRequest.iid; + } + + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( +this.getEffectiveProjectId(projectId) +)}/merge_requests/${mergeRequestIid}/changes` + ); + + if (view) { + url.searchParams.append("view", view); + } + + const response = await this.fetch(url.toString(), {}); + + await this.handleGitLabError(response); + const data = (await response.json()) as { changes: unknown }; + return z.array(GitLabDiffSchema).parse(data.changes); + } + + /** + * Get merge request changes with detailed information + */ + async listMergeRequestDiffs( + projectId: string, + mergeRequestIid?: number | string, + branchName?: string, + page?: number, + perPage?: number, + unidiff?: boolean + ): Promise { + projectId = decodeURIComponent(projectId); + if (!mergeRequestIid && !branchName) { + throw new Error("Either mergeRequestIid or branchName must be provided"); + } + + if (branchName && !mergeRequestIid) { + const mergeRequest = await this.getMergeRequest(projectId, undefined, branchName); + mergeRequestIid = mergeRequest.iid; + } + + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( +this.getEffectiveProjectId(projectId) +)}/merge_requests/${mergeRequestIid}/diffs` + ); + + if (page) { + url.searchParams.append("page", page.toString()); + } + + if (perPage) { + url.searchParams.append("per_page", perPage.toString()); + } + + if (unidiff) { + url.searchParams.append("unidiff", "true"); + } + + const response = await this.fetch(url.toString(), {}); + + await this.handleGitLabError(response); + return await response.json(); + } + + /** + * Get branch comparison diffs + */ + async getBranchDiffs( + projectId: string, + from: string, + to: string, + straight?: boolean + ): Promise { + projectId = decodeURIComponent(projectId); + + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/repository/compare` + ); + + url.searchParams.append("from", from); + url.searchParams.append("to", to); + + if (straight !== undefined) { + url.searchParams.append("straight", straight.toString()); + } + + const response = await this.fetch(url.toString(), {}); + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`); + } + + const data = await response.json(); + return GitLabCompareResultSchema.parse(data); + } + + /** + * Update a merge request + */ + async updateMergeRequest( + projectId: string, + options: Omit< + z.infer, + "project_id" | "merge_request_iid" | "source_branch" + >, + mergeRequestIid?: number | string, + branchName?: string + ): Promise { + projectId = decodeURIComponent(projectId); + if (!mergeRequestIid && !branchName) { + throw new Error("Either mergeRequestIid or branchName must be provided"); + } + + if (branchName && !mergeRequestIid) { + const mergeRequest = await this.getMergeRequest(projectId, undefined, branchName); + mergeRequestIid = mergeRequest.iid; + } + + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}` + ); + + const response = await this.fetch(url.toString(), { + + method: "PUT", + body: JSON.stringify(options), + }); + + await this.handleGitLabError(response); + return GitLabMergeRequestSchema.parse(await response.json()); + } + + /** + * Create a new note (comment) on an issue or merge request + */ + async createNote( + projectId: string, + noteableType: "issue" | "merge_request", + noteableIid: number | string, + body: string + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( +this.getEffectiveProjectId(projectId) +)}/${noteableType}s/${noteableIid}/notes` + ); + + const response = await this.fetch(url.toString(), { + + method: "POST", + body: JSON.stringify({ body }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`); + } + + return await response.json(); + } + + /** + * Create a new thread on a merge request + */ + async createMergeRequestThread( + projectId: string, + mergeRequestIid: number | string, + body: string, + position?: MergeRequestThreadPosition, + createdAt?: string + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( +this.getEffectiveProjectId(projectId) +)}/merge_requests/${mergeRequestIid}/discussions` + ); + + const payload: Record = { body }; + + if (position) { + payload.position = position; + } + + if (createdAt) { + payload.created_at = createdAt; + } + + const response = await this.fetch(url.toString(), { + + method: "POST", + body: JSON.stringify(payload), + }); + + await this.handleGitLabError(response); + const data = await response.json(); + return GitLabDiscussionSchema.parse(data); + } + + /** + * List all namespaces + */ + async listNamespaces(options: { + search?: string; + owned_only?: boolean; + top_level_only?: boolean; + }): Promise { +const url = new URL(`${config.GITLAB_API_URL}/namespaces`); + + if (options.search) { + url.searchParams.append("search", options.search); + } + + if (options.owned_only) { + url.searchParams.append("owned_only", "true"); + } + + if (options.top_level_only) { + url.searchParams.append("top_level_only", "true"); + } + + const response = await this.fetch(url.toString(), {}); + + await this.handleGitLabError(response); + const data = await response.json(); + return z.array(GitLabNamespaceSchema).parse(data); + } + + /** + * Get details on a namespace + */ + async getNamespace(id: string): Promise { + const url = new URL(`${config.GITLAB_API_URL}/namespaces/${encodeURIComponent(id)}`); + + const response = await this.fetch(url.toString(), {}); + + await this.handleGitLabError(response); + const data = await response.json(); + return GitLabNamespaceSchema.parse(data); + } + + /** + * Verify if a namespace exists + */ + async verifyNamespaceExistence( + namespacePath: string, + parentId?: number + ): Promise { + const url = new URL(`${config.GITLAB_API_URL}/namespaces/${encodeURIComponent(namespacePath)}/exists`); + + if (parentId) { + url.searchParams.append("parent_id", parentId.toString()); + } + + const response = await this.fetch(url.toString(), {}); + + await this.handleGitLabError(response); + const data = await response.json(); + return GitLabNamespaceExistsResponseSchema.parse(data); + } + + /** + * Get a single project + */ + async getProject( + projectId: string, + options: { + license?: boolean; + statistics?: boolean; + with_custom_attributes?: boolean; + } = {} + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}`); + + if (options.license) { + url.searchParams.append("license", "true"); + } + + if (options.statistics) { + url.searchParams.append("statistics", "true"); + } + + if (options.with_custom_attributes) { + url.searchParams.append("with_custom_attributes", "true"); + } + + const response = await this.fetch(url.toString(), {}); + + await this.handleGitLabError(response); + const data = await response.json(); + return GitLabRepositorySchema.parse(data); + } + + /** + * List projects + */ + async listProjects( + options: z.infer = {} + ): Promise { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(options)) { + if (value !== undefined && value !== null) { + if (typeof value === "boolean") { + params.append(key, value ? "true" : "false"); + } else { + params.append(key, String(value)); + } + } + } + + const response = await this.fetch(`${config.GITLAB_API_URL}/projects?${params.toString()}`, {}); + + await this.handleGitLabError(response); + + const data = await response.json(); + return z.array(GitLabProjectSchema).parse(data); + } + + /** + * List labels for a project + */ + async listLabels( + projectId: string, + options: Omit, "project_id"> = {} + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/labels`); + + Object.entries(options).forEach(([key, value]) => { + if (value !== undefined) { + if (typeof value === "boolean") { + url.searchParams.append(key, value ? "true" : "false"); + } else { + url.searchParams.append(key, String(value)); + } + } + }); + + const response = await this.fetch(url.toString(), {}); + + await this.handleGitLabError(response); + + const data = await response.json(); + return data as GitLabLabel[]; + } + + /** + * Get a single label from a project + */ + async getLabel( + projectId: string, + labelId: number | string, + includeAncestorGroups?: boolean + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( + this.getEffectiveProjectId(projectId) + )}/labels/${encodeURIComponent(String(labelId))}` + ); + + if (includeAncestorGroups !== undefined) { + url.searchParams.append("include_ancestor_groups", includeAncestorGroups ? "true" : "false"); + } + + const response = await this.fetch(url.toString(), {}); + + await this.handleGitLabError(response); + + const data = await response.json(); + return data as GitLabLabel; + } + + /** + * Create a new label in a project + */ + async createLabel( + projectId: string, + options: Omit, "project_id"> + ): Promise { + projectId = decodeURIComponent(projectId); + const response = await this.fetch( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/labels`, + { + + method: "POST", + body: JSON.stringify(options), + } + ); + + await this.handleGitLabError(response); + + const data = await response.json(); + return data as GitLabLabel; + } + + /** + * Update an existing label in a project + */ + async updateLabel( + projectId: string, + labelId: number | string, + options: Omit, "project_id" | "label_id"> + ): Promise { + projectId = decodeURIComponent(projectId); + const response = await this.fetch( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( + this.getEffectiveProjectId(projectId) + )}/labels/${encodeURIComponent(String(labelId))}`, + { + + method: "PUT", + body: JSON.stringify(options), + } + ); + + await this.handleGitLabError(response); + + const data = await response.json(); + return data as GitLabLabel; + } + + /** + * Delete a label from a project + */ + async deleteLabel(projectId: string, labelId: number | string): Promise { + projectId = decodeURIComponent(projectId); + const response = await this.fetch( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( + this.getEffectiveProjectId(projectId) + )}/labels/${encodeURIComponent(String(labelId))}`, + { + + method: "DELETE", + } + ); + + await this.handleGitLabError(response); + } + + /** + * List all projects in a GitLab group + */ + async listGroupProjects( + options: z.infer + ): Promise { + const url = new URL(`${config.GITLAB_API_URL}/groups/${encodeURIComponent(options.group_id)}/projects`); + + if (options.include_subgroups) url.searchParams.append("include_subgroups", "true"); + if (options.search) url.searchParams.append("search", options.search); + if (options.order_by) url.searchParams.append("order_by", options.order_by); + if (options.sort) url.searchParams.append("sort", options.sort); + if (options.page) url.searchParams.append("page", options.page.toString()); + if (options.per_page) url.searchParams.append("per_page", options.per_page.toString()); + if (options.archived !== undefined) + url.searchParams.append("archived", options.archived.toString()); + if (options.visibility) url.searchParams.append("visibility", options.visibility); + if (options.with_issues_enabled !== undefined) + url.searchParams.append("with_issues_enabled", options.with_issues_enabled.toString()); + if (options.with_merge_requests_enabled !== undefined) + url.searchParams.append( + "with_merge_requests_enabled", + options.with_merge_requests_enabled.toString() + ); + if (options.min_access_level !== undefined) + url.searchParams.append("min_access_level", options.min_access_level.toString()); + if (options.with_programming_language) + url.searchParams.append("with_programming_language", options.with_programming_language); + if (options.starred !== undefined) url.searchParams.append("starred", options.starred.toString()); + if (options.statistics !== undefined) + url.searchParams.append("statistics", options.statistics.toString()); + if (options.with_custom_attributes !== undefined) + url.searchParams.append("with_custom_attributes", options.with_custom_attributes.toString()); + if (options.with_security_reports !== undefined) + url.searchParams.append("with_security_reports", options.with_security_reports.toString()); + + const response = await this.fetch(url.toString(), {}); + + await this.handleGitLabError(response); + const projects = await response.json(); + return GitLabProjectSchema.array().parse(projects); + } + + // Wiki API helper methods + /** + * List wiki pages in a project + */ + async listWikiPages( + projectId: string, + options: Omit = {} + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/wikis`); + if (options.page) url.searchParams.append("page", options.page.toString()); + if (options.per_page) url.searchParams.append("per_page", options.per_page.toString()); + if (options.with_content) + url.searchParams.append("with_content", options.with_content.toString()); + const response = await this.fetch(url.toString(), {}); + await this.handleGitLabError(response); + const data = await response.json(); + return GitLabWikiPageSchema.array().parse(data); + } + + /** + * Get a specific wiki page + */ + async getWikiPage(projectId: string, slug: string): Promise { + projectId = decodeURIComponent(projectId); + const response = await this.fetch( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/wikis/${encodeURIComponent(slug)}`, + {}, + ); + await this.handleGitLabError(response); + const data = await response.json(); + return GitLabWikiPageSchema.parse(data); + } + + /** + * Create a new wiki page + */ + async createWikiPage( + projectId: string, + title: string, + content: string, + format?: string + ): Promise { + projectId = decodeURIComponent(projectId); + const body: Record = { title, content }; + if (format) body.format = format; + const response = await this.fetch( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/wikis`, + { + + method: "POST", + body: JSON.stringify(body), + } + ); + await this.handleGitLabError(response); + const data = await response.json(); + return GitLabWikiPageSchema.parse(data); + } + + /** + * Update an existing wiki page + */ + async updateWikiPage( + projectId: string, + slug: string, + title?: string, + content?: string, + format?: string + ): Promise { + projectId = decodeURIComponent(projectId); + const body: Record = {}; + if (title) body.title = title; + if (content) body.content = content; + if (format) body.format = format; + const response = await this.fetch( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/wikis/${encodeURIComponent(slug)}`, + { + + method: "PUT", + body: JSON.stringify(body), + } + ); + await this.handleGitLabError(response); + const data = await response.json(); + return GitLabWikiPageSchema.parse(data); + } + + /** + * Delete a wiki page + */ + async deleteWikiPage(projectId: string, slug: string): Promise { + projectId = decodeURIComponent(projectId); + const response = await this.fetch( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/wikis/${encodeURIComponent(slug)}`, + { + + method: "DELETE", + } + ); + await this.handleGitLabError(response); + } + + /** + * List pipelines in a GitLab project + */ + async listPipelines( + projectId: string, + options: Omit = {} + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/pipelines`); + + Object.entries(options).forEach(([key, value]) => { + if (value !== undefined) { + url.searchParams.append(key, value.toString()); + } + }); + + const response = await this.fetch(url.toString(), {}); + + await this.handleGitLabError(response); + const data = await response.json(); + return z.array(GitLabPipelineSchema).parse(data); + } + + /** + * Get details of a specific pipeline + */ + async getPipeline(projectId: string, pipelineId: number | string): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/pipelines/${pipelineId}` + ); + + const response = await this.fetch(url.toString(), {}); + + if (response.status === 404) { + throw new Error(`Pipeline not found`); + } + + await this.handleGitLabError(response); + const data = await response.json(); + return GitLabPipelineSchema.parse(data); + } + + /** + * List all jobs in a specific pipeline + */ + async listPipelineJobs( + projectId: string, + pipelineId: number | string, + options: Omit = {} + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/pipelines/${pipelineId}/jobs` + ); + + Object.entries(options).forEach(([key, value]) => { + if (value !== undefined) { + if (typeof value === "boolean") { + url.searchParams.append(key, value ? "true" : "false"); + } else { + url.searchParams.append(key, value.toString()); + } + } + }); + + const response = await this.fetch(url.toString(), {}); + + if (response.status === 404) { + throw new Error(`Pipeline not found`); + } + + await this.handleGitLabError(response); + const data = await response.json(); + return z.array(GitLabPipelineJobSchema).parse(data); + } + + /** + * Get a specific pipeline job + */ + async getPipelineJob(projectId: string, jobId: number | string): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/jobs/${jobId}`); + + const response = await this.fetch(url.toString(), {}); + + if (response.status === 404) { + throw new Error(`Job not found`); + } + + await this.handleGitLabError(response); + const data = await response.json(); + return GitLabPipelineJobSchema.parse(data); + } + + /** + * Get the output/trace of a pipeline job + */ + async getPipelineJobOutput(projectId: string, jobId: number | string, limit?: number, offset?: number): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/jobs/${jobId}/trace` + ); + + const response = await this.fetch(url.toString(), { + headers: { + Accept: "text/plain", + }, + }); + + if (response.status === 404) { + throw new Error(`Job trace not found or job is not finished yet`); + } + + await this.handleGitLabError(response); + const fullTrace = await response.text(); + + // Apply client-side pagination + if (limit !== undefined || offset !== undefined) { + const lines = fullTrace.split('\n'); + const startOffset = offset || 0; + const maxLines = limit || 1000; + + const startIndex = Math.max(0, lines.length - startOffset - maxLines); + const endIndex = lines.length - startOffset; + + const selectedLines = lines.slice(startIndex, endIndex); + const result = selectedLines.join('\n'); + + if (startIndex > 0 || endIndex < lines.length) { + const totalLines = lines.length; + const shownLines = selectedLines.length; + const skippedFromStart = startIndex; + const skippedFromEnd = startOffset; + + return `[Log truncated: showing ${shownLines} of ${totalLines} lines, skipped ${skippedFromStart} from start, ${skippedFromEnd} from end]\n\n${result}`; + } + + return result; + } + + return fullTrace; + } + + /** + * Create a new pipeline + */ + async createPipeline( + projectId: string, + ref: string, + variables?: Array<{ key: string; value: string }> + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/pipeline`); + + const body: any = { ref }; + if (variables && variables.length > 0) { + body.variables = variables + } + + const response = await this.fetch(url.toString(), { + method: "POST", + body: JSON.stringify(body), + }); + + await this.handleGitLabError(response); + const data = await response.json(); + return GitLabPipelineSchema.parse(data); + } + + /** + * Retry a pipeline + */ + async retryPipeline(projectId: string, pipelineId: number | string): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/pipelines/${pipelineId}/retry` + ); + + const response = await this.fetch(url.toString(), { + method: "POST", + }); + + await this.handleGitLabError(response); + const data = await response.json(); + return GitLabPipelineSchema.parse(data); + } + + /** + * Cancel a pipeline + */ + async cancelPipeline(projectId: string, pipelineId: number | string): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/pipelines/${pipelineId}/cancel` + ); + + const response = await this.fetch(url.toString(), { + method: "POST", + }); + + await this.handleGitLabError(response); + const data = await response.json(); + return GitLabPipelineSchema.parse(data); + } + + /** + * Get the repository tree for a project + */ + async getRepositoryTree(options: GetRepositoryTreeOptions): Promise { + options.project_id = decodeURIComponent(options.project_id); + const queryParams = new URLSearchParams(); + if (options.path) queryParams.append("path", options.path); + if (options.ref) queryParams.append("ref", options.ref); + if (options.recursive) queryParams.append("recursive", "true"); + if (options.per_page) queryParams.append("per_page", options.per_page.toString()); + if (options.page_token) queryParams.append("page_token", options.page_token); + if (options.pagination) queryParams.append("pagination", options.pagination); + + const headers: Record = { + "Content-Type": "application/json", + }; + if (config.IS_OLD) { + headers["Private-Token"] = `${config.GITLAB_PERSONAL_ACCESS_TOKEN}`; + } else { + headers["Authorization"] = `Bearer ${config.GITLAB_PERSONAL_ACCESS_TOKEN}`; + } + const response = await this.fetch( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( + options.project_id + )}/repository/tree?${queryParams.toString()}`, + { + headers, + } + ); + + if (response.status === 404) { + throw new Error("Repository or path not found"); + } + + if (!response.ok) { + throw new Error(`Failed to get repository tree: ${response.statusText}`); + } + + const data = await response.json(); + return z.array(GitLabTreeItemSchema).parse(data); + } + + /** + * List project milestones in a GitLab project + */ + async listProjectMilestones( + projectId: string, + options: Omit, "project_id"> + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/milestones`); + + Object.entries(options).forEach(([key, value]) => { + if (value !== undefined) { + if (key === "iids" && Array.isArray(value) && value.length > 0) { + value.forEach(iid => { + url.searchParams.append("iids[]", iid.toString()); + }); + } else if (value !== undefined) { + url.searchParams.append(key, value.toString()); + } + } + }); + + const response = await this.fetch(url.toString(), {}); + await this.handleGitLabError(response); + const data = await response.json(); + return z.array(GitLabMilestonesSchema).parse(data); + } + + /** + * Get a single milestone in a GitLab project + */ + async getProjectMilestone( + projectId: string, + milestoneId: number | string + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/milestones/${milestoneId}` + ); + + const response = await this.fetch(url.toString(), {}); + await this.handleGitLabError(response); + const data = await response.json(); + return GitLabMilestonesSchema.parse(data); + } + + /** + * Create a new milestone in a GitLab project + */ + async createProjectMilestone( + projectId: string, + options: Omit, "project_id"> + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/milestones`); + + const response = await this.fetch(url.toString(), { + + method: "POST", + body: JSON.stringify(options), + }); + await this.handleGitLabError(response); + const data = await response.json(); + return GitLabMilestonesSchema.parse(data); + } + + /** + * Edit an existing milestone in a GitLab project + */ + async editProjectMilestone( + projectId: string, + milestoneId: number | string, + options: Omit, "project_id" | "milestone_id"> + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/milestones/${milestoneId}` + ); + + const response = await this.fetch(url.toString(), { + + method: "PUT", + body: JSON.stringify(options), + }); + await this.handleGitLabError(response); + const data = await response.json(); + return GitLabMilestonesSchema.parse(data); + } + + /** + * Delete a milestone from a GitLab project + */ + async deleteProjectMilestone(projectId: string, milestoneId: number | string): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/milestones/${milestoneId}` + ); + + const response = await this.fetch(url.toString(), { + + method: "DELETE", + }); + await this.handleGitLabError(response); + } + + /** + * Get all issues assigned to a single milestone + */ + async getMilestoneIssues(projectId: string, milestoneId: number | string): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/milestones/${milestoneId}/issues` + ); + + const response = await this.fetch(url.toString(), {}); + await this.handleGitLabError(response); + const data = await response.json(); + return z.array(GitLabIssueSchema).parse(data); + } + + /** + * Get all merge requests assigned to a single milestone + */ + async getMilestoneMergeRequests( + projectId: string, + milestoneId: number | string + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( + this.getEffectiveProjectId(projectId) + )}/milestones/${milestoneId}/merge_requests` + ); + + const response = await this.fetch(url.toString(), {}); + await this.handleGitLabError(response); + const data = await response.json(); + return z.array(GitLabMergeRequestSchema).parse(data); + } + + /** + * Promote a project milestone to a group milestone + */ + async promoteProjectMilestone( + projectId: string, + milestoneId: number | string + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/milestones/${milestoneId}/promote` + ); + + const response = await this.fetch(url.toString(), { + + method: "POST", + }); + await this.handleGitLabError(response); + const data = await response.json(); + return GitLabMilestonesSchema.parse(data); + } + + /** + * Get all burndown chart events for a single milestone + */ + async getMilestoneBurndownEvents(projectId: string, milestoneId: number | string): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( + this.getEffectiveProjectId(projectId) + )}/milestones/${milestoneId}/burndown_events` + ); + + const response = await this.fetch(url.toString(), {}); + await this.handleGitLabError(response); + const data = await response.json(); + return data as any[]; + } + + /** + * Get a single user from GitLab + */ + async getUser(username: string): Promise { + try { + const url = new URL(`${config.GITLAB_API_URL}/users`); + url.searchParams.append("username", username); + + const response = await this.fetch(url.toString(), { + + }); + + await this.handleGitLabError(response); + + const users = await response.json(); + + if (Array.isArray(users) && users.length > 0) { + const exactMatch = users.find(user => user.username === username); + if (exactMatch) { + return GitLabUserSchema.parse(exactMatch); + } + } + + return null; + } catch (error) { + console.error(`Error fetching user by username '${username}':`, error); + return null; + } + } + + /** + * Get multiple users from GitLab + */ + async getUsers(usernames: string[]): Promise { + const users: Record = {}; + + for (const username of usernames) { + try { + const user = await this.getUser(username); + users[username] = user; + } catch (error) { + console.error(`Error processing username '${username}':`, error); + users[username] = null; + } + } + + return GitLabUsersResponseSchema.parse(users); + } + + /** + * List repository commits + */ + async listCommits( + projectId: string, + options: Omit = {} + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/repository/commits` + ); + + if (options.ref_name) url.searchParams.append("ref_name", options.ref_name); + if (options.since) url.searchParams.append("since", options.since); + if (options.until) url.searchParams.append("until", options.until); + if (options.path) url.searchParams.append("path", options.path); + if (options.author) url.searchParams.append("author", options.author); + if (options.all) url.searchParams.append("all", options.all.toString()); + if (options.with_stats) url.searchParams.append("with_stats", options.with_stats.toString()); + if (options.first_parent) url.searchParams.append("first_parent", options.first_parent.toString()); + if (options.order) url.searchParams.append("order", options.order); + if (options.trailers) url.searchParams.append("trailers", options.trailers.toString()); + if (options.page) url.searchParams.append("page", options.page.toString()); + if (options.per_page) url.searchParams.append("per_page", options.per_page.toString()); + + const response = await this.fetch(url.toString(), {}); + + await this.handleGitLabError(response); + + const data = await response.json(); + return z.array(GitLabCommitSchema).parse(data); + } + + /** + * Get a single commit + */ + async getCommit( + projectId: string, + sha: string, + stats?: boolean + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/repository/commits/${encodeURIComponent(sha)}` + ); + + if (stats) { + url.searchParams.append("stats", "true"); + } + + const response = await this.fetch(url.toString(), {}); + + await this.handleGitLabError(response); + + const data = await response.json(); + return GitLabCommitSchema.parse(data); + } + + /** + * Get commit diff + */ + async getCommitDiff( + projectId: string, + sha: string + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/repository/commits/${encodeURIComponent(sha)}/diff` + ); + + const response = await this.fetch(url.toString(), {}); + + await this.handleGitLabError(response); + + const data = await response.json(); + return z.array(GitLabDiffSchema).parse(data); + } + + /** + * Get details of the current authenticated User + */ + async getCurrentUser(): Promise { + const url = new URL(`${config.GITLAB_API_URL}/user`); + const response = await this.fetch(url.toString(), {}); + + await this.handleGitLabError(response); + + const data = await response.json(); + return GitLabUserSchema.parse(data); + } + + /** + * Get a draft note + */ + async getDraftNote( + projectId: string, + mergeRequestIid: string, + draftNoteId: string + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( + this.getEffectiveProjectId(projectId) + )}/merge_requests/${mergeRequestIid}/draft_notes/${draftNoteId}` + ); + + const response = await this.fetch(url.toString(), {}); + + await this.handleGitLabError(response); + + const data = await response.json(); + return GitLabDraftNoteSchema.parse(data); + } + + /** + * List draft notes + */ + async listDraftNotes( + projectId: string, + mergeRequestIid: number | string + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( + this.getEffectiveProjectId(projectId) + )}/merge_requests/${mergeRequestIid}/draft_notes` + ); + + const response = await this.fetch(url.toString(), {}); + + await this.handleGitLabError(response); + + const data = await response.json(); + return z.array(GitLabDraftNoteSchema).parse(data); + } + + /** + * Create a draft note + */ + async createDraftNote( + projectId: string, + mergeRequestIid: number | string, + body: string, + position?: MergeRequestThreadPositionCreate, + resolveDiscussion?: boolean + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( + this.getEffectiveProjectId(projectId) + )}/merge_requests/${mergeRequestIid}/draft_notes` + ); + + const requestBody: any = { note: body }; + if (position) { + requestBody.position = position; + } + if (resolveDiscussion !== undefined) { + requestBody.resolve_discussion = resolveDiscussion; + } + + const response = await this.fetch(url.toString(), { + method: "POST", + body: JSON.stringify(requestBody), + }); + + await this.handleGitLabError(response); + + const data = await response.json(); + return GitLabDraftNoteSchema.parse(data); + } + + /** + * Update a draft note + */ + async updateDraftNote( + projectId: string, + mergeRequestIid: number | string, + draftNoteId: number | string, + body?: string, + position?: MergeRequestThreadPositionCreate, + resolveDiscussion?: boolean + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( + this.getEffectiveProjectId(projectId) + )}/merge_requests/${mergeRequestIid}/draft_notes/${draftNoteId}` + ); + + const requestBody: any = {}; + if (body !== undefined) { + requestBody.note = body; + } + if (position) { + requestBody.position = position; + } + if (resolveDiscussion !== undefined) { + requestBody.resolve_discussion = resolveDiscussion; + } + + const response = await this.fetch(url.toString(), { + method: "PUT", + body: JSON.stringify(requestBody), + }); + + await this.handleGitLabError(response); + + const data = await response.json(); + return GitLabDraftNoteSchema.parse(data); + } + + /** + * Delete a draft note + */ + async deleteDraftNote( + projectId: string, + mergeRequestIid: number | string, + draftNoteId: number | string + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( + this.getEffectiveProjectId(projectId) + )}/merge_requests/${mergeRequestIid}/draft_notes/${draftNoteId}` + ); + + const response = await this.fetch(url.toString(), { + method: "DELETE", + }); + + await this.handleGitLabError(response); + } + + /** + * Publish a draft note + */ + async publishDraftNote( + projectId: string, + mergeRequestIid: number | string, + draftNoteId: number | string + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( + this.getEffectiveProjectId(projectId) + )}/merge_requests/${mergeRequestIid}/draft_notes/${draftNoteId}/publish` + ); + + const response = await this.fetch(url.toString(), { + method: "PUT", + }); + + await this.handleGitLabError(response); + + // Handle empty response (204 No Content) or successful response + const responseText = await response.text(); + if (!responseText || responseText.trim() === '') { + // Return a success indicator for empty responses + return { + id: draftNoteId.toString(), + body: "Draft note published successfully", + author: { id: "unknown", username: "unknown" }, + created_at: new Date().toISOString(), + } as any; + } + + const data = JSON.parse(responseText); + return GitLabDiscussionNoteSchema.parse(data); + } + + /** + * Bulk publish draft notes + */ + async bulkPublishDraftNotes( + projectId: string, + mergeRequestIid: number | string + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( + this.getEffectiveProjectId(projectId) + )}/merge_requests/${mergeRequestIid}/draft_notes/bulk_publish` + ); + + const response = await this.fetch(url.toString(), { + method: "POST", + body: JSON.stringify({}), + }); + + await this.handleGitLabError(response); + + // Handle empty response (204 No Content) or successful response + const responseText = await response.text(); + if (!responseText || responseText.trim() === '') { + // Return empty array for successful bulk publish with no content + return []; + } + + try { + const data = JSON.parse(responseText); + return z.array(GitLabDiscussionNoteSchema).parse(data); + } catch (error) { + return []; + } + } + + /** + * Merge a merge request + */ + async mergeMergeRequest( + projectId: string, + options: Omit, "project_id" | "merge_request_iid">, + mergeRequestIid?: number | string + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( + this.getEffectiveProjectId(projectId) + )}/merge_requests/${mergeRequestIid}/merge` + ); + + const response = await this.fetch(url.toString(), { + method: "PUT", + body: JSON.stringify(options), + }); + + await this.handleGitLabError(response); + return GitLabMergeRequestSchema.parse(await response.json()); + } + + /** + * Get issues assigned to the current user + */ + async myIssues(options: z.infer = {}): Promise { + // Get current user to find their username + const currentUser = await this.getCurrentUser(); + + // Use getEffectiveProjectId to handle project ID resolution + const effectiveProjectId = this.getEffectiveProjectId(options.project_id || ""); + + // Use listIssues with assignee_username filter + let listIssuesOptions: any = { + state: options.state || "opened", + labels: options.labels, + milestone: options.milestone, + search: options.search, + created_after: options.created_after, + created_before: options.created_before, + updated_after: options.updated_after, + updated_before: options.updated_before, + per_page: options.per_page, + page: options.page, + }; + + if (currentUser.username) { + listIssuesOptions.assignee_username = [currentUser.username]; + } else { + listIssuesOptions.assignee_id = currentUser.id; + } + return this.listIssues(effectiveProjectId, listIssuesOptions); + } + + /** + * List project members + */ + async listProjectMembers( + projectId: string, + options: Omit, "project_id"> = {} + ): Promise { + projectId = decodeURIComponent(projectId); + const effectiveProjectId = this.getEffectiveProjectId(projectId); + const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/members`); + + // Add query parameters + if (options.query) url.searchParams.append("query", options.query); + if (options.user_ids) { + options.user_ids.forEach(id => url.searchParams.append("user_ids[]", id.toString())); + } + if (options.skip_users) { + options.skip_users.forEach(id => url.searchParams.append("skip_users[]", id.toString())); + } + if (options.per_page) url.searchParams.append("per_page", options.per_page.toString()); + if (options.page) url.searchParams.append("page", options.page.toString()); + + const response = await this.fetch(url.toString(), {}); + + await this.handleGitLabError(response); + const data = await response.json(); + return z.array(GitLabProjectMemberSchema).parse(data); + } + + /** + * Upload a file for markdown usage + */ + async markdownUpload(projectId: string, filePath: string): Promise { + projectId = decodeURIComponent(projectId); + const effectiveProjectId = this.getEffectiveProjectId(projectId); + + // Check if file exists + if (!fs.existsSync(filePath)) { + throw new Error(`File not found: ${filePath}`); + } + + // Read the file + const fileBuffer = fs.readFileSync(filePath); + const fileName = path.basename(filePath); + + // Create form data + const FormData = (await import("form-data")).default; + const form = new FormData(); + form.append("file", fileBuffer, { + filename: fileName, + contentType: "application/octet-stream", + }); + + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/uploads` + ); + + // Need to handle form data specially + const headers = form.getHeaders(); + + const response = await this.fetch(url.toString(), { + method: "POST", + body: form as any, + headers: headers as any, + }); + + await this.handleGitLabError(response); + const data = await response.json(); + return GitLabMarkdownUploadSchema.parse(data); + } + + /** + * Download an attachment from a GitLab project + */ + async downloadAttachment(projectId: string, secret: string, filename: string, localPath?: string): Promise { + projectId = decodeURIComponent(projectId); + const effectiveProjectId = this.getEffectiveProjectId(projectId); + + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/uploads/${secret}/${filename}` + ); + + const response = await this.fetch(url.toString(), {}); + + await this.handleGitLabError(response); + + // Get the file content as buffer + const buffer = await response.arrayBuffer(); + + // Determine the save path + const savePath = localPath ? path.join(localPath, filename) : filename; + + // Write the file to disk + fs.writeFileSync(savePath, Buffer.from(buffer)); + + return savePath; + } +} diff --git a/src/gitlabsession.ts b/src/gitlabsession.ts new file mode 100644 index 0000000..e33f3e4 --- /dev/null +++ b/src/gitlabsession.ts @@ -0,0 +1,118 @@ +import { Request, Response, RequestInit } from "node-fetch"; +import { config } from "./config.js"; + +import nodeFetch from "node-fetch"; + +import { SocksProxyAgent } from "socks-proxy-agent"; +import { HttpsProxyAgent } from "https-proxy-agent"; +import { HttpProxyAgent } from "http-proxy-agent"; +import { Agent } from "http"; +import { Agent as HttpsAgent } from "https"; +import { readFileSync } from "fs"; +import { CookieJar } from "tough-cookie"; +import fetchCookie from "fetch-cookie"; + +export class GitlabSession { + + private defaultConfig: RequestInit + private defaultHeaders: Record + + constructor( + private readonly authToken: string, + private readonly cookieJar?: CookieJar + ) { + // Configure proxy agents if proxies are set + let httpAgent: Agent | undefined = undefined; + let httpsAgent: Agent | undefined = undefined; + + let sslOptions = undefined; + if (config.NODE_TLS_REJECT_UNAUTHORIZED === "0") { + sslOptions = { rejectUnauthorized: false }; + } else if (config.GITLAB_CA_CERT_PATH) { + const ca = readFileSync(config.GITLAB_CA_CERT_PATH); + sslOptions = { ca }; + } + + if (config.HTTP_PROXY) { + if (config.HTTP_PROXY.startsWith("socks")) { + httpAgent = new SocksProxyAgent(config.HTTP_PROXY); + } else { + httpAgent = new HttpProxyAgent(config.HTTP_PROXY); + } + } + if (config.HTTPS_PROXY) { + if (config.HTTPS_PROXY.startsWith("socks")) { + httpsAgent = new SocksProxyAgent(config.HTTPS_PROXY); + } else { + httpsAgent = new HttpsProxyAgent(config.HTTPS_PROXY, sslOptions); + } + } + httpsAgent = httpsAgent || new HttpsAgent(sslOptions); + httpAgent = httpAgent || new Agent(); + this.defaultHeaders = { + Accept: "application/json", + "Content-Type": "application/json", + }; + + if (config.IS_OLD) { + this.defaultHeaders["Private-Token"] = `${this.authToken}`; + } else { + this.defaultHeaders["Authorization"] = `Bearer ${this.authToken}`; + } + + this.defaultConfig = { + headers: this.defaultHeaders, + agent: (parsedUrl: URL) => { + if (parsedUrl.protocol === "https:") { + return httpsAgent; + } + return httpAgent; + }, + } + + } + + async ensureSessionForCookieJar(): Promise { + if (!this.cookieJar || !config.GITLAB_AUTH_COOKIE_PATH) return; + + // Extract the base URL from GITLAB_API_URL + const apiUrl = new URL(config.GITLAB_API_URL); + const baseUrl = `${apiUrl.protocol}//${apiUrl.hostname}`; + + // Check if we already have GitLab session cookies + const gitlabCookies = this.cookieJar.getCookiesSync(baseUrl); + const hasSessionCookie = gitlabCookies.some(cookie => + cookie.key === '_gitlab_session' || cookie.key === 'remember_user_token' + ); + + if (!hasSessionCookie) { + try { + // Establish session with a lightweight request + await fetch(`${config.GITLAB_API_URL}/user`, { + redirect: 'follow' + }).catch(() => { + // Ignore errors - the important thing is that cookies get set during redirects + }); + + // Small delay to ensure cookies are fully processed + await new Promise(resolve => setTimeout(resolve, 100)); + } catch (error) { + // Ignore session establishment errors + } + } + } + + + async fetch(url: URL | string | Request, init?: RequestInit): Promise { + const fullInit = {...this.defaultConfig, ...init, headers: { + ...this.defaultHeaders, + ...init?.headers + }} + let fetcher = nodeFetch; + if(this.cookieJar) { + fetcher = fetchCookie(fetcher, this.cookieJar); + } + return fetcher(url, fullInit); + } + +} diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..e805c63 --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,12 @@ +import { pino } from 'pino'; + +export const logger = pino({ + level: process.env.LOG_LEVEL || 'info', + transport: { + target: 'pino-pretty', + options: { + colorize: true, + levelFirst: true, + }, + }, +}); diff --git a/src/mcpserver.ts b/src/mcpserver.ts new file mode 100644 index 0000000..31f8a05 --- /dev/null +++ b/src/mcpserver.ts @@ -0,0 +1,1672 @@ +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; +import { logger } from "./logger.js" + +import { z } from "zod"; +import { zodToJsonSchema } from "zod-to-json-schema"; +import { fileURLToPath } from "url"; +import { dirname } from "path"; +import fs from "fs"; +import path from "path"; + +import {config} from "./config.js" +import { GitlabHandler } from "./gitlabhandler.js" + +import { + ForkRepositorySchema, + CreateBranchSchema, + CreateOrUpdateFileSchema, + SearchRepositoriesSchema, + CreateRepositorySchema, + GetFileContentsSchema, + PushFilesSchema, + CreateIssueSchema, + CreateMergeRequestSchema, + GetMergeRequestSchema, + GetMergeRequestDiffsSchema, + UpdateMergeRequestSchema, + ListIssuesSchema, + GetIssueSchema, + UpdateIssueSchema, + DeleteIssueSchema, + ListIssueLinksSchema, + ListIssueDiscussionsSchema, + GetIssueLinkSchema, + CreateIssueLinkSchema, + DeleteIssueLinkSchema, + ListNamespacesSchema, + GetNamespaceSchema, + VerifyNamespaceSchema, + GetProjectSchema, + ListProjectsSchema, + ListLabelsSchema, + GetLabelSchema, + CreateLabelSchema, + UpdateLabelSchema, + DeleteLabelSchema, + CreateNoteSchema, + CreateMergeRequestThreadSchema, + ListGroupProjectsSchema, + ListWikiPagesSchema, + GetWikiPageSchema, + CreateWikiPageSchema, + UpdateWikiPageSchema, + DeleteWikiPageSchema, + GetRepositoryTreeSchema, + GetPipelineSchema, + ListPipelinesSchema, + ListPipelineJobsSchema, + CreatePipelineSchema, + RetryPipelineSchema, + CancelPipelineSchema, + GetPipelineJobOutputSchema, + UpdateMergeRequestNoteSchema, + CreateMergeRequestNoteSchema, + ListMergeRequestDiscussionsSchema, + UpdateIssueNoteSchema, + CreateIssueNoteSchema, + ListMergeRequestsSchema, + ListProjectMilestonesSchema, + GetProjectMilestoneSchema, + CreateProjectMilestoneSchema, + EditProjectMilestoneSchema, + DeleteProjectMilestoneSchema, + GetMilestoneIssuesSchema, + GetMilestoneMergeRequestsSchema, + PromoteProjectMilestoneSchema, + GetMilestoneBurndownEventsSchema, + GetBranchDiffsSchema, + GetUsersSchema, + ListCommitsSchema, + GetCommitSchema, + GetCommitDiffSchema, + ListMergeRequestDiffsSchema, + GetCurrentUserSchema, + GetDraftNoteSchema, + ListDraftNotesSchema, + CreateDraftNoteSchema, + UpdateDraftNoteSchema, + DeleteDraftNoteSchema, + PublishDraftNoteSchema, + BulkPublishDraftNotesSchema, + MergeMergeRequestSchema, + MyIssuesSchema, + ListProjectMembersSchema, + MarkdownUploadSchema, + DownloadAttachmentSchema, +} from "./schemas.js"; +import { createCookieJar } from "./authhelpers.js"; +import { CookieJar } from "tough-cookie"; + +/** + * Read version from package.json + */ +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const packageJsonPath = path.resolve(__dirname, "../package.json"); +let SERVER_VERSION = "unknown"; +try { + if (fs.existsSync(packageJsonPath)) { + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); + SERVER_VERSION = packageJson.version || SERVER_VERSION; + } +} catch (error) { + // Warning: Could not read version from package.json - silently continue +} + +// create the underlying mcp server +const server = new Server( + { + name: "better-gitlab-mcp-server", + version: SERVER_VERSION, + }, + { + capabilities: { + tools: {}, + }, + } +); + + + +// Define all available tools +const allTools = [ + { + name: "create_or_update_file", + description: "Create or update a single file in a GitLab project", + inputSchema: zodToJsonSchema(CreateOrUpdateFileSchema), + }, + { + name: "search_repositories", + description: "Search for GitLab projects", + inputSchema: zodToJsonSchema(SearchRepositoriesSchema), + }, + { + name: "create_repository", + description: "Create a new GitLab project", + inputSchema: zodToJsonSchema(CreateRepositorySchema), + }, + { + name: "get_file_contents", + description: "Get the contents of a file or directory from a GitLab project", + inputSchema: zodToJsonSchema(GetFileContentsSchema), + }, + { + name: "push_files", + description: "Push multiple files to a GitLab project in a single commit", + inputSchema: zodToJsonSchema(PushFilesSchema), + }, + { + name: "create_issue", + description: "Create a new issue in a GitLab project", + inputSchema: zodToJsonSchema(CreateIssueSchema), + }, + { + name: "create_merge_request", + description: "Create a new merge request in a GitLab project", + inputSchema: zodToJsonSchema(CreateMergeRequestSchema), + }, + { + name: "fork_repository", + description: "Fork a GitLab project to your account or specified namespace", + inputSchema: zodToJsonSchema(ForkRepositorySchema), + }, + { + name: "create_branch", + description: "Create a new branch in a GitLab project", + inputSchema: zodToJsonSchema(CreateBranchSchema), + }, + { + name: "get_merge_request", + description: + "Get details of a merge request (Either mergeRequestIid or branchName must be provided)", + inputSchema: zodToJsonSchema(GetMergeRequestSchema), + }, + { + name: "get_merge_request_diffs", + description: + "Get the changes/diffs of a merge request (Either mergeRequestIid or branchName must be provided)", + inputSchema: zodToJsonSchema(GetMergeRequestDiffsSchema), + }, + { + name: "list_merge_request_diffs", + description: + "List merge request diffs with pagination support (Either mergeRequestIid or branchName must be provided)", + inputSchema: zodToJsonSchema(ListMergeRequestDiffsSchema), + }, + { + name: "get_branch_diffs", + description: "Get the changes/diffs between two branches or commits in a GitLab project", + inputSchema: zodToJsonSchema(GetBranchDiffsSchema), + }, + { + name: "update_merge_request", + description: "Update a merge request (Either mergeRequestIid or branchName must be provided)", + inputSchema: zodToJsonSchema(UpdateMergeRequestSchema), + }, + { + name: "create_note", + description: "Create a new note (comment) to an issue or merge request", + inputSchema: zodToJsonSchema(CreateNoteSchema), + }, + { + name: "create_merge_request_thread", + description: "Create a new thread on a merge request", + inputSchema: zodToJsonSchema(CreateMergeRequestThreadSchema), + }, + { + name: "mr_discussions", + description: "List discussion items for a merge request", + inputSchema: zodToJsonSchema(ListMergeRequestDiscussionsSchema), + }, + { + name: "update_merge_request_note", + description: "Modify an existing merge request thread note", + inputSchema: zodToJsonSchema(UpdateMergeRequestNoteSchema), + }, + { + name: "create_merge_request_note", + description: "Add a new note to an existing merge request thread", + inputSchema: zodToJsonSchema(CreateMergeRequestNoteSchema), + }, + { + name: "update_issue_note", + description: "Modify an existing issue thread note", + inputSchema: zodToJsonSchema(UpdateIssueNoteSchema), + }, + { + name: "create_issue_note", + description: "Add a new note to an existing issue thread", + inputSchema: zodToJsonSchema(CreateIssueNoteSchema), + }, + { + name: "list_issues", + description: "List issues in a GitLab project with filtering options", + inputSchema: zodToJsonSchema(ListIssuesSchema), + }, + { + name: "get_issue", + description: "Get details of a specific issue in a GitLab project", + inputSchema: zodToJsonSchema(GetIssueSchema), + }, + { + name: "update_issue", + description: "Update an issue in a GitLab project", + inputSchema: zodToJsonSchema(UpdateIssueSchema), + }, + { + name: "delete_issue", + description: "Delete an issue from a GitLab project", + inputSchema: zodToJsonSchema(DeleteIssueSchema), + }, + { + name: "list_issue_links", + description: "List all issue links for a specific issue", + inputSchema: zodToJsonSchema(ListIssueLinksSchema), + }, + { + name: "list_issue_discussions", + description: "List discussions for an issue in a GitLab project", + inputSchema: zodToJsonSchema(ListIssueDiscussionsSchema), + }, + { + name: "get_issue_link", + description: "Get a specific issue link", + inputSchema: zodToJsonSchema(GetIssueLinkSchema), + }, + { + name: "create_issue_link", + description: "Create an issue link between two issues", + inputSchema: zodToJsonSchema(CreateIssueLinkSchema), + }, + { + name: "delete_issue_link", + description: "Delete an issue link", + inputSchema: zodToJsonSchema(DeleteIssueLinkSchema), + }, + { + name: "list_namespaces", + description: "List all namespaces available to the current user", + inputSchema: zodToJsonSchema(ListNamespacesSchema), + }, + { + name: "get_namespace", + description: "Get details of a namespace by ID or path", + inputSchema: zodToJsonSchema(GetNamespaceSchema), + }, + { + name: "verify_namespace", + description: "Verify if a namespace path exists", + inputSchema: zodToJsonSchema(VerifyNamespaceSchema), + }, + { + name: "get_project", + description: "Get details of a specific project", + inputSchema: zodToJsonSchema(GetProjectSchema), + }, + { + name: "list_projects", + description: "List projects accessible by the current user", + inputSchema: zodToJsonSchema(ListProjectsSchema), + }, + { + name: "list_labels", + description: "List labels for a project", + inputSchema: zodToJsonSchema(ListLabelsSchema), + }, + { + name: "get_label", + description: "Get a single label from a project", + inputSchema: zodToJsonSchema(GetLabelSchema), + }, + { + name: "create_label", + description: "Create a new label in a project", + inputSchema: zodToJsonSchema(CreateLabelSchema), + }, + { + name: "update_label", + description: "Update an existing label in a project", + inputSchema: zodToJsonSchema(UpdateLabelSchema), + }, + { + name: "delete_label", + description: "Delete a label from a project", + inputSchema: zodToJsonSchema(DeleteLabelSchema), + }, + { + name: "list_group_projects", + description: "List projects in a GitLab group with filtering options", + inputSchema: zodToJsonSchema(ListGroupProjectsSchema), + }, + { + name: "list_wiki_pages", + description: "List wiki pages in a GitLab project", + inputSchema: zodToJsonSchema(ListWikiPagesSchema), + }, + { + name: "get_wiki_page", + description: "Get details of a specific wiki page", + inputSchema: zodToJsonSchema(GetWikiPageSchema), + }, + { + name: "create_wiki_page", + description: "Create a new wiki page in a GitLab project", + inputSchema: zodToJsonSchema(CreateWikiPageSchema), + }, + { + name: "update_wiki_page", + description: "Update an existing wiki page in a GitLab project", + inputSchema: zodToJsonSchema(UpdateWikiPageSchema), + }, + { + name: "delete_wiki_page", + description: "Delete a wiki page from a GitLab project", + inputSchema: zodToJsonSchema(DeleteWikiPageSchema), + }, + { + name: "get_repository_tree", + description: "Get the repository tree for a GitLab project (list files and directories)", + inputSchema: zodToJsonSchema(GetRepositoryTreeSchema), + }, + { + name: "list_pipelines", + description: "List pipelines in a GitLab project with filtering options", + inputSchema: zodToJsonSchema(ListPipelinesSchema), + }, + { + name: "get_pipeline", + description: "Get details of a specific pipeline in a GitLab project", + inputSchema: zodToJsonSchema(GetPipelineSchema), + }, + { + name: "list_pipeline_jobs", + description: "List all jobs in a specific pipeline", + inputSchema: zodToJsonSchema(ListPipelineJobsSchema), + }, + { + name: "get_pipeline_job", + description: "Get details of a GitLab pipeline job number", + inputSchema: zodToJsonSchema(GetPipelineJobOutputSchema), + }, + { + name: "get_pipeline_job_output", + description: "Get the output/trace of a GitLab pipeline job with optional pagination to limit context window usage", + inputSchema: zodToJsonSchema(GetPipelineJobOutputSchema), + }, + { + name: "create_pipeline", + description: "Create a new pipeline for a branch or tag", + inputSchema: zodToJsonSchema(CreatePipelineSchema), + }, + { + name: "retry_pipeline", + description: "Retry a failed or canceled pipeline", + inputSchema: zodToJsonSchema(RetryPipelineSchema), + }, + { + name: "cancel_pipeline", + description: "Cancel a running pipeline", + inputSchema: zodToJsonSchema(CancelPipelineSchema), + }, + { + name: "list_merge_requests", + description: "List merge requests in a GitLab project with filtering options", + inputSchema: zodToJsonSchema(ListMergeRequestsSchema), + }, + { + name: "list_milestones", + description: "List milestones in a GitLab project with filtering options", + inputSchema: zodToJsonSchema(ListProjectMilestonesSchema), + }, + { + name: "get_milestone", + description: "Get details of a specific milestone", + inputSchema: zodToJsonSchema(GetProjectMilestoneSchema), + }, + { + name: "create_milestone", + description: "Create a new milestone in a GitLab project", + inputSchema: zodToJsonSchema(CreateProjectMilestoneSchema), + }, + { + name: "edit_milestone", + description: "Edit an existing milestone in a GitLab project", + inputSchema: zodToJsonSchema(EditProjectMilestoneSchema), + }, + { + name: "delete_milestone", + description: "Delete a milestone from a GitLab project", + inputSchema: zodToJsonSchema(DeleteProjectMilestoneSchema), + }, + { + name: "get_milestone_issue", + description: "Get issues associated with a specific milestone", + inputSchema: zodToJsonSchema(GetMilestoneIssuesSchema), + }, + { + name: "get_milestone_merge_requests", + description: "Get merge requests associated with a specific milestone", + inputSchema: zodToJsonSchema(GetMilestoneMergeRequestsSchema), + }, + { + name: "promote_milestone", + description: "Promote a milestone to the next stage", + inputSchema: zodToJsonSchema(PromoteProjectMilestoneSchema), + }, + { + name: "get_milestone_burndown_events", + description: "Get burndown events for a specific milestone", + inputSchema: zodToJsonSchema(GetMilestoneBurndownEventsSchema), + }, + { + name: "get_users", + description: "Get GitLab user details by usernames", + inputSchema: zodToJsonSchema(GetUsersSchema), + }, + { + name: "list_commits", + description: "List repository commits with filtering options", + inputSchema: zodToJsonSchema(ListCommitsSchema), + }, + { + name: "get_commit", + description: "Get details of a specific commit", + inputSchema: zodToJsonSchema(GetCommitSchema), + }, + { + name: "get_commit_diff", + description: "Get changes/diffs of a specific commit", + inputSchema: zodToJsonSchema(GetCommitDiffSchema), + }, + { + name: "get_current_user", + description: "Get details of the current authenticated user", + inputSchema: zodToJsonSchema(GetCurrentUserSchema), + }, + { + name: "get_draft_note", + description: "Get a single draft note from a merge request", + inputSchema: zodToJsonSchema(GetDraftNoteSchema), + }, + { + name: "list_draft_notes", + description: "List all draft notes for a merge request", + inputSchema: zodToJsonSchema(ListDraftNotesSchema), + }, + { + name: "create_draft_note", + description: "Create a draft note for a merge request", + inputSchema: zodToJsonSchema(CreateDraftNoteSchema), + }, + { + name: "update_draft_note", + description: "Update an existing draft note", + inputSchema: zodToJsonSchema(UpdateDraftNoteSchema), + }, + { + name: "delete_draft_note", + description: "Delete a draft note", + inputSchema: zodToJsonSchema(DeleteDraftNoteSchema), + }, + { + name: "publish_draft_note", + description: "Publish a single draft note", + inputSchema: zodToJsonSchema(PublishDraftNoteSchema), + }, + { + name: "bulk_publish_draft_notes", + description: "Publish all draft notes for a merge request", + inputSchema: zodToJsonSchema(BulkPublishDraftNotesSchema), + }, + { + name: "merge_merge_request", + description: "Merge a merge request in a GitLab project", + inputSchema: zodToJsonSchema(MergeMergeRequestSchema), + }, + { + name: "my_issues", + description: "List issues assigned to the authenticated user", + inputSchema: zodToJsonSchema(MyIssuesSchema), + }, + { + name: "list_project_members", + description: "List members of a GitLab project", + inputSchema: zodToJsonSchema(ListProjectMembersSchema), + }, + { + name: "upload_markdown", + description: "Upload a file to a GitLab project for use in markdown content", + inputSchema: zodToJsonSchema(MarkdownUploadSchema), + }, + { + name: "download_attachment", + description: "Download an uploaded file from a GitLab project by secret and filename", + inputSchema: zodToJsonSchema(DownloadAttachmentSchema), + } +]; + +// Define which tools are read-only +const readOnlyTools = [ + "search_repositories", + "get_file_contents", + "get_merge_request", + "get_merge_request_diffs", + "get_branch_diffs", + "mr_discussions", + "list_issues", + "list_merge_requests", + "get_issue", + "list_issue_links", + "list_issue_discussions", + "get_issue_link", + "list_namespaces", + "get_namespace", + "verify_namespace", + "get_project", + "get_pipeline", + "list_pipelines", + "list_pipeline_jobs", + "get_pipeline_job", + "get_pipeline_job_output", + "list_projects", + "list_labels", + "get_label", + "list_group_projects", + "get_repository_tree", + "list_milestones", + "get_milestone", + "get_milestone_issue", + "get_milestone_merge_requests", + "get_milestone_burndown_events", + "list_wiki_pages", + "get_wiki_page", + "get_users", + "list_commits", + "get_commit", + "get_commit_diff", + "get_current_user", + "download_attachment", +]; + +// Define which tools are related to wiki and can be toggled by USE_GITLAB_WIKI +const wikiToolNames = [ + "list_wiki_pages", + "get_wiki_page", + "create_wiki_page", + "update_wiki_page", + "delete_wiki_page", + "upload_wiki_attachment", +]; + +// Define which tools are related to milestones and can be toggled by USE_MILESTONE +const milestoneToolNames = [ + "list_milestones", + "get_milestone", + "create_milestone", + "edit_milestone", + "delete_milestone", + "get_milestone_issue", + "get_milestone_merge_requests", + "promote_milestone", + "get_milestone_burndown_events", +]; + +// Define which tools are related to pipelines and can be toggled by USE_PIPELINE +const pipelineToolNames = [ + "list_pipelines", + "get_pipeline", + "list_pipeline_jobs", + "get_pipeline_job", + "get_pipeline_job_output", + "create_pipeline", + "retry_pipeline", + "cancel_pipeline", +]; + + +server.setRequestHandler(ListToolsRequestSchema, async () => { + // Apply read-only filter first + const tools0 = config.GITLAB_READ_ONLY_MODE + ? allTools.filter(tool => readOnlyTools.includes(tool.name)) + : allTools; + // Toggle wiki tools by USE_GITLAB_WIKI flag + const tools1 = config.USE_GITLAB_WIKI + ? tools0 + : tools0.filter(tool => !wikiToolNames.includes(tool.name)); + // Toggle milestone tools by USE_MILESTONE flag + const tools2 = config.USE_MILESTONE + ? tools1 + : tools1.filter(tool => !milestoneToolNames.includes(tool.name)); + // Toggle pipeline tools by USE_PIPELINE flag + let tools = config.USE_PIPELINE ? tools2 : tools2.filter(tool => !pipelineToolNames.includes(tool.name)); + + // <<< START: Gemini ํ˜ธํ™˜์„ฑ์„ ์œ„ํ•ด $schema ์ œ๊ฑฐ >>> + tools = tools.map(tool => { + // inputSchema๊ฐ€ ์กด์žฌํ•˜๊ณ  ๊ฐ์ฒด์ธ์ง€ ํ™•์ธ + if (tool.inputSchema && typeof tool.inputSchema === "object" && tool.inputSchema !== null) { + // $schema ํ‚ค๊ฐ€ ์กด์žฌํ•˜๋ฉด ์‚ญ์ œ + if ("$schema" in tool.inputSchema) { + // ๋ถˆ๋ณ€์„ฑ์„ ์œ„ํ•ด ์ƒˆ๋กœ์šด ๊ฐ์ฒด ์ƒ์„ฑ (์„ ํƒ์ ์ด์ง€๋งŒ ๊ถŒ์žฅ) + const modifiedSchema = { ...tool.inputSchema }; + delete modifiedSchema.$schema; + return { ...tool, inputSchema: modifiedSchema }; + } + } + // ๋ณ€๊ฒฝ์ด ํ•„์š” ์—†์œผ๋ฉด ๊ทธ๋Œ€๋กœ ๋ฐ˜ํ™˜ + return tool; + }); + // <<< END: Gemini ํ˜ธํ™˜์„ฑ์„ ์œ„ํ•ด $schema ์ œ๊ฑฐ >>> + + return { + tools, // $schema๊ฐ€ ์ œ๊ฑฐ๋œ ๋„๊ตฌ ๋ชฉ๋ก ๋ฐ˜ํ™˜ + }; +}); + +// TODO: im pretty sure that the cookie jar should be scoped by token? instead of being global +// but i need to look into it. just don't use the cookie jar feature with oauth or passthrough... +const globalCookieJar = createCookieJar(); + +server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { + try { + if (!request.params.arguments) { + throw new Error("Arguments are required"); + } + let cookieJar: CookieJar | undefined = undefined; + if(config.GITLAB_AUTH_COOKIE_PATH) { + cookieJar = globalCookieJar; + } + // Create GitlabSession instance + // TODO: we silently do nothing if the authInfo is not properly forwared. should we do something? + const gitlabSession = new GitlabHandler(extra.authInfo?.token || "", cookieJar); + if(cookieJar) { + await gitlabSession.ensureSessionForCookieJar(); + } + logger.info(request.params.name) + switch (request.params.name) { + case "fork_repository": { + if (config.GITLAB_PROJECT_ID) { + throw new Error("Direct project ID is set. So fork_repository is not allowed"); + } + const forkArgs = ForkRepositorySchema.parse(request.params.arguments); + try { + const forkedProject = await gitlabSession.forkProject(forkArgs.project_id, forkArgs.namespace); + return { + content: [{ type: "text", text: JSON.stringify(forkedProject, null, 2) }], + }; + } catch (forkError) { + console.error("Error forking repository:", forkError); + let forkErrorMessage = "Failed to fork repository"; + if (forkError instanceof Error) { + forkErrorMessage = `${forkErrorMessage}: ${forkError.message}`; + } + return { + content: [ + { + type: "text", + text: JSON.stringify({ error: forkErrorMessage }, null, 2), + }, + ], + }; + } + } + + case "create_branch": { + const args = CreateBranchSchema.parse(request.params.arguments); + let ref = args.ref; + if (!ref) { + ref = await gitlabSession.getDefaultBranchRef(args.project_id); + } + + const branch = await gitlabSession.createBranch(args.project_id, { + name: args.branch, + ref, + }); + + return { + content: [{ type: "text", text: JSON.stringify(branch, null, 2) }], + }; + } + + case "get_branch_diffs": { + const args = GetBranchDiffsSchema.parse(request.params.arguments); + const diffResp = await gitlabSession.getBranchDiffs(args.project_id, args.from, args.to, args.straight); + + if (args.excluded_file_patterns?.length) { + const regexPatterns = args.excluded_file_patterns.map(pattern => new RegExp(pattern)); + + // Helper function to check if a path matches any regex pattern + const matchesAnyPattern = (path: string): boolean => { + if (!path) return false; + return regexPatterns.some(regex => regex.test(path)); + }; + + // Filter out files that match any of the regex patterns on new files + diffResp.diffs = diffResp.diffs.filter(diff => !matchesAnyPattern(diff.new_path)); + } + return { + content: [{ type: "text", text: JSON.stringify(diffResp, null, 2) }], + }; + } + + case "search_repositories": { + const args = SearchRepositoriesSchema.parse(request.params.arguments); + const results = await gitlabSession.searchProjects(args.search, args.page, args.per_page); + return { + content: [{ type: "text", text: JSON.stringify(results, null, 2) }], + }; + } + + case "create_repository": { + if (config.GITLAB_PROJECT_ID) { + throw new Error("Direct project ID is set. So fork_repository is not allowed"); + } + const args = CreateRepositorySchema.parse(request.params.arguments); + const repository = await gitlabSession.createRepository(args); + return { + content: [{ type: "text", text: JSON.stringify(repository, null, 2) }], + }; + } + + case "get_file_contents": { + const args = GetFileContentsSchema.parse(request.params.arguments); + const contents = await gitlabSession.getFileContents(args.project_id, args.file_path, args.ref); + return { + content: [{ type: "text", text: JSON.stringify(contents, null, 2) }], + }; + } + + case "create_or_update_file": { + const args = CreateOrUpdateFileSchema.parse(request.params.arguments); + const result = await gitlabSession.createOrUpdateFile( + args.project_id, + args.file_path, + args.content, + args.commit_message, + args.branch, + args.previous_path, + args.last_commit_id, + args.commit_id + ); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + } + + case "push_files": { + const args = PushFilesSchema.parse(request.params.arguments); + const result = await gitlabSession.createCommit( + args.project_id, + args.commit_message, + args.branch, + args.files.map(f => ({ path: f.file_path, content: f.content })) + ); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + } + + case "create_issue": { + const args = CreateIssueSchema.parse(request.params.arguments); + const { project_id, ...options } = args; + const issue = await gitlabSession.createIssue(project_id, options); + return { + content: [{ type: "text", text: JSON.stringify(issue, null, 2) }], + }; + } + + case "create_merge_request": { + const args = CreateMergeRequestSchema.parse(request.params.arguments); + const { project_id, ...options } = args; + const mergeRequest = await gitlabSession.createMergeRequest(project_id, options); + return { + content: [{ type: "text", text: JSON.stringify(mergeRequest, null, 2) }], + }; + } + + case "update_merge_request_note": { + const args = UpdateMergeRequestNoteSchema.parse(request.params.arguments); + const note = await gitlabSession.updateMergeRequestNote( + args.project_id, + args.merge_request_iid, + args.discussion_id, + args.note_id, + args.body, // Now optional + args.resolved // Now one of body or resolved must be provided, not both + ); + return { + content: [{ type: "text", text: JSON.stringify(note, null, 2) }], + }; + } + + case "create_merge_request_note": { + const args = CreateMergeRequestNoteSchema.parse(request.params.arguments); + const note = await gitlabSession.createMergeRequestNote( + args.project_id, + args.merge_request_iid, + args.discussion_id, + args.body, + args.created_at + ); + return { + content: [{ type: "text", text: JSON.stringify(note, null, 2) }], + }; + } + + case "update_issue_note": { + const args = UpdateIssueNoteSchema.parse(request.params.arguments); + const note = await gitlabSession.updateIssueNote( + args.project_id, + args.issue_iid, + args.discussion_id, + args.note_id, + args.body + ); + return { + content: [{ type: "text", text: JSON.stringify(note, null, 2) }], + }; + } + + case "create_issue_note": { + const args = CreateIssueNoteSchema.parse(request.params.arguments); + const note = await gitlabSession.createIssueNote( + args.project_id, + args.issue_iid, + args.discussion_id, + args.body, + args.created_at + ); + return { + content: [{ type: "text", text: JSON.stringify(note, null, 2) }], + }; + } + + case "get_merge_request": { + const args = GetMergeRequestSchema.parse(request.params.arguments); + const mergeRequest = await gitlabSession.getMergeRequest( + args.project_id, + args.merge_request_iid, + args.source_branch + ); + return { + content: [{ type: "text", text: JSON.stringify(mergeRequest, null, 2) }], + }; + } + + case "get_merge_request_diffs": { + const args = GetMergeRequestDiffsSchema.parse(request.params.arguments); + const diffs = await gitlabSession.getMergeRequestDiffs( + args.project_id, + args.merge_request_iid, + args.source_branch, + args.view + ); + return { + content: [{ type: "text", text: JSON.stringify(diffs, null, 2) }], + }; + } + + case "list_merge_request_diffs": { + const args = ListMergeRequestDiffsSchema.parse(request.params.arguments); + const changes = await gitlabSession.listMergeRequestDiffs( + args.project_id, + args.merge_request_iid, + args.source_branch, + args.page, + args.per_page, + args.unidiff + ); + return { + content: [{ type: "text", text: JSON.stringify(changes, null, 2) }], + }; + } + + case "update_merge_request": { + const args = UpdateMergeRequestSchema.parse(request.params.arguments); + const { project_id, merge_request_iid, source_branch, ...options } = args; + const mergeRequest = await gitlabSession.updateMergeRequest( + project_id, + options, + merge_request_iid, + source_branch + ); + return { + content: [{ type: "text", text: JSON.stringify(mergeRequest, null, 2) }], + }; + } + + case "mr_discussions": { + const args = ListMergeRequestDiscussionsSchema.parse(request.params.arguments); + const { project_id, merge_request_iid, ...options } = args; + const discussions = await gitlabSession.listMergeRequestDiscussions( + project_id, + merge_request_iid, + options + ); + return { + content: [{ type: "text", text: JSON.stringify(discussions, null, 2) }], + }; + } + + case "list_namespaces": { + const args = ListNamespacesSchema.parse(request.params.arguments); + const namespaces = await gitlabSession.listNamespaces({ + search: args.search, + owned_only: args.owned, + top_level_only: undefined + }); + + return { + content: [{ type: "text", text: JSON.stringify(namespaces, null, 2) }], + }; + } + + case "get_namespace": { + const args = GetNamespaceSchema.parse(request.params.arguments); + const namespace = await gitlabSession.getNamespace(args.namespace_id); + + return { + content: [{ type: "text", text: JSON.stringify(namespace, null, 2) }], + }; + } + + case "verify_namespace": { + const args = VerifyNamespaceSchema.parse(request.params.arguments); + const namespaceExists = await gitlabSession.verifyNamespaceExistence(args.path); + + return { + content: [{ type: "text", text: JSON.stringify(namespaceExists, null, 2) }], + }; + } + + case "get_project": { + const args = GetProjectSchema.parse(request.params.arguments); + const project = await gitlabSession.getProject(args.project_id); + + return { + content: [{ type: "text", text: JSON.stringify(project, null, 2) }], + }; + } + + case "list_projects": { + const args = ListProjectsSchema.parse(request.params.arguments); + const projects = await gitlabSession.listProjects(args); + + return { + content: [{ type: "text", text: JSON.stringify(projects, null, 2) }], + }; + } + + case "get_users": { + const args = GetUsersSchema.parse(request.params.arguments); + const usersMap = await gitlabSession.getUsers(args.usernames); + + return { + content: [{ type: "text", text: JSON.stringify(usersMap, null, 2) }], + }; + } + + case "create_note": { + const args = CreateNoteSchema.parse(request.params.arguments); + const { project_id, noteable_type, noteable_iid, body } = args; + + const note = await gitlabSession.createNote(project_id, noteable_type, noteable_iid, body); + return { + content: [{ type: "text", text: JSON.stringify(note, null, 2) }], + }; + } + + case "create_merge_request_thread": { + const args = CreateMergeRequestThreadSchema.parse(request.params.arguments); + const { project_id, merge_request_iid, body, position, created_at } = args; + + const thread = await gitlabSession.createMergeRequestThread( + project_id, + merge_request_iid, + body, + position, + created_at + ); + return { + content: [{ type: "text", text: JSON.stringify(thread, null, 2) }], + }; + } + + case "list_issues": { + const args = ListIssuesSchema.parse(request.params.arguments); + const { project_id, ...options } = args; + const issues = await gitlabSession.listIssues(project_id || "", options); + return { + content: [{ type: "text", text: JSON.stringify(issues, null, 2) }], + }; + } + + case "get_issue": { + const args = GetIssueSchema.parse(request.params.arguments); + const issue = await gitlabSession.getIssue(args.project_id, args.issue_iid); + return { + content: [{ type: "text", text: JSON.stringify(issue, null, 2) }], + }; + } + + case "update_issue": { + const args = UpdateIssueSchema.parse(request.params.arguments); + const { project_id, issue_iid, ...options } = args; + const issue = await gitlabSession.updateIssue(project_id, issue_iid, options); + return { + content: [{ type: "text", text: JSON.stringify(issue, null, 2) }], + }; + } + + case "delete_issue": { + const args = DeleteIssueSchema.parse(request.params.arguments); + await gitlabSession.deleteIssue(args.project_id, args.issue_iid); + return { + content: [ + { + type: "text", + text: JSON.stringify( + { status: "success", message: "Issue deleted successfully" }, + null, + 2 + ), + }, + ], + }; + } + + case "list_issue_links": { + const args = ListIssueLinksSchema.parse(request.params.arguments); + const links = await gitlabSession.listIssueLinks(args.project_id, args.issue_iid); + return { + content: [{ type: "text", text: JSON.stringify(links, null, 2) }], + }; + } + + case "list_issue_discussions": { + const args = ListIssueDiscussionsSchema.parse(request.params.arguments); + const { project_id, issue_iid, ...options } = args; + + const discussions = await gitlabSession.listIssueDiscussions(project_id, issue_iid, options); + return { + content: [{ type: "text", text: JSON.stringify(discussions, null, 2) }], + }; + } + + case "get_issue_link": { + const args = GetIssueLinkSchema.parse(request.params.arguments); + const link = await gitlabSession.getIssueLink(args.project_id, args.issue_iid, args.issue_link_id); + return { + content: [{ type: "text", text: JSON.stringify(link, null, 2) }], + }; + } + + case "create_issue_link": { + const args = CreateIssueLinkSchema.parse(request.params.arguments); + const link = await gitlabSession.createIssueLink( + args.project_id, + args.issue_iid, + args.target_project_id, + args.target_issue_iid, + args.link_type + ); + return { + content: [{ type: "text", text: JSON.stringify(link, null, 2) }], + }; + } + + case "delete_issue_link": { + const args = DeleteIssueLinkSchema.parse(request.params.arguments); + await gitlabSession.deleteIssueLink(args.project_id, args.issue_iid, args.issue_link_id); + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + status: "success", + message: "Issue link deleted successfully", + }, + null, + 2 + ), + }, + ], + }; + } + + case "list_labels": { + const args = ListLabelsSchema.parse(request.params.arguments); + const labels = await gitlabSession.listLabels(args.project_id, args); + return { + content: [{ type: "text", text: JSON.stringify(labels, null, 2) }], + }; + } + + case "get_label": { + const args = GetLabelSchema.parse(request.params.arguments); + const label = await gitlabSession.getLabel(args.project_id, args.label_id, args.include_ancestor_groups); + return { + content: [{ type: "text", text: JSON.stringify(label, null, 2) }], + }; + } + + case "create_label": { + const args = CreateLabelSchema.parse(request.params.arguments); + const label = await gitlabSession.createLabel(args.project_id, args); + return { + content: [{ type: "text", text: JSON.stringify(label, null, 2) }], + }; + } + + case "update_label": { + const args = UpdateLabelSchema.parse(request.params.arguments); + const { project_id, label_id, ...options } = args; + const label = await gitlabSession.updateLabel(project_id, label_id, options); + return { + content: [{ type: "text", text: JSON.stringify(label, null, 2) }], + }; + } + + case "delete_label": { + const args = DeleteLabelSchema.parse(request.params.arguments); + await gitlabSession.deleteLabel(args.project_id, args.label_id); + return { + content: [ + { + type: "text", + text: JSON.stringify( + { status: "success", message: "Label deleted successfully" }, + null, + 2 + ), + }, + ], + }; + } + + case "list_group_projects": { + const args = ListGroupProjectsSchema.parse(request.params.arguments); + const projects = await gitlabSession.listGroupProjects(args); + return { + content: [{ type: "text", text: JSON.stringify(projects, null, 2) }], + }; + } + + case "list_wiki_pages": { + const { project_id, page, per_page, with_content } = ListWikiPagesSchema.parse( + request.params.arguments + ); + const wikiPages = await gitlabSession.listWikiPages(project_id, { page, per_page, with_content }); + return { + content: [{ type: "text", text: JSON.stringify(wikiPages, null, 2) }], + }; + } + + case "get_wiki_page": { + const { project_id, slug } = GetWikiPageSchema.parse(request.params.arguments); + const wikiPage = await gitlabSession.getWikiPage(project_id, slug); + return { + content: [{ type: "text", text: JSON.stringify(wikiPage, null, 2) }], + }; + } + + case "create_wiki_page": { + const { project_id, title, content, format } = CreateWikiPageSchema.parse( + request.params.arguments + ); + const wikiPage = await gitlabSession.createWikiPage(project_id, title, content, format); + return { + content: [{ type: "text", text: JSON.stringify(wikiPage, null, 2) }], + }; + } + + case "update_wiki_page": { + const { project_id, slug, title, content, format } = UpdateWikiPageSchema.parse( + request.params.arguments + ); + const wikiPage = await gitlabSession.updateWikiPage(project_id, slug, title, content, format); + return { + content: [{ type: "text", text: JSON.stringify(wikiPage, null, 2) }], + }; + } + + case "delete_wiki_page": { + const { project_id, slug } = DeleteWikiPageSchema.parse(request.params.arguments); + await gitlabSession.deleteWikiPage(project_id, slug); + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + status: "success", + message: "Wiki page deleted successfully", + }, + null, + 2 + ), + }, + ], + }; + } + + case "get_repository_tree": { + const args = GetRepositoryTreeSchema.parse(request.params.arguments); + const tree = await gitlabSession.getRepositoryTree(args); + return { + content: [{ type: "text", text: JSON.stringify(tree, null, 2) }], + }; + } + + case "list_pipelines": { + const args = ListPipelinesSchema.parse(request.params.arguments); + const { project_id, ...options } = args; + const pipelines = await gitlabSession.listPipelines(project_id, options); + return { + content: [{ type: "text", text: JSON.stringify(pipelines, null, 2) }], + }; + } + + case "get_pipeline": { + const { project_id, pipeline_id } = GetPipelineSchema.parse(request.params.arguments); + const pipeline = await gitlabSession.getPipeline(project_id, pipeline_id); + return { + content: [ + { + type: "text", + text: JSON.stringify(pipeline, null, 2), + }, + ], + }; + } + + case "list_pipeline_jobs": { + const { project_id, pipeline_id, ...options } = ListPipelineJobsSchema.parse( + request.params.arguments + ); + const jobs = await gitlabSession.listPipelineJobs(project_id, pipeline_id, options); + return { + content: [ + { + type: "text", + text: JSON.stringify(jobs, null, 2), + }, + ], + }; + } + + case "get_pipeline_job": { + const { project_id, job_id } = GetPipelineJobOutputSchema.parse(request.params.arguments); + const jobDetails = await gitlabSession.getPipelineJob(project_id, job_id); + return { + content: [ + { + type: "text", + text: JSON.stringify(jobDetails, null, 2), + }, + ], + }; + } + + case "get_pipeline_job_output": { + const { project_id, job_id, limit, offset } = GetPipelineJobOutputSchema.parse(request.params.arguments); + const jobOutput = await gitlabSession.getPipelineJobOutput(project_id, job_id, limit, offset); + return { + content: [ + { + type: "text", + text: jobOutput, + }, + ], + }; + } + + case "create_pipeline": { + const { project_id, ref, variables } = CreatePipelineSchema.parse(request.params.arguments); + const pipeline = await gitlabSession.createPipeline(project_id, ref, variables); + return { + content: [ + { + type: "text", + text: `Created pipeline #${pipeline.id} for ${ref}. Status: ${pipeline.status}\nWeb URL: ${pipeline.web_url}`, + }, + ], + }; + } + + case "retry_pipeline": { + const { project_id, pipeline_id } = RetryPipelineSchema.parse(request.params.arguments); + const pipeline = await gitlabSession.retryPipeline(project_id, pipeline_id); + return { + content: [ + { + type: "text", + text: `Retried pipeline #${pipeline.id}. Status: ${pipeline.status}\nWeb URL: ${pipeline.web_url}`, + }, + ], + }; + } + + case "cancel_pipeline": { + const { project_id, pipeline_id } = CancelPipelineSchema.parse(request.params.arguments); + const pipeline = await gitlabSession.cancelPipeline(project_id, pipeline_id); + return { + content: [ + { + type: "text", + text: `Canceled pipeline #${pipeline.id}. Status: ${pipeline.status}\nWeb URL: ${pipeline.web_url}`, + }, + ], + }; + } + + case "list_merge_requests": { + const args = ListMergeRequestsSchema.parse(request.params.arguments); + const mergeRequests = await gitlabSession.listMergeRequests(args.project_id, args); + return { + content: [{ type: "text", text: JSON.stringify(mergeRequests, null, 2) }], + }; + } + + case "list_milestones": { + const { project_id, ...options } = ListProjectMilestonesSchema.parse( + request.params.arguments + ); + const milestones = await gitlabSession.listProjectMilestones(project_id, options); + return { + content: [ + { + type: "text", + text: JSON.stringify(milestones, null, 2), + }, + ], + }; + } + + case "get_milestone": { + const { project_id, milestone_id } = GetProjectMilestoneSchema.parse( + request.params.arguments + ); + const milestone = await gitlabSession.getProjectMilestone(project_id, milestone_id); + return { + content: [ + { + type: "text", + text: JSON.stringify(milestone, null, 2), + }, + ], + }; + } + + case "create_milestone": { + const { project_id, ...options } = CreateProjectMilestoneSchema.parse( + request.params.arguments + ); + const milestone = await gitlabSession.createProjectMilestone(project_id, options); + return { + content: [ + { + type: "text", + text: JSON.stringify(milestone, null, 2), + }, + ], + }; + } + + case "edit_milestone": { + const { project_id, milestone_id, ...options } = EditProjectMilestoneSchema.parse( + request.params.arguments + ); + const milestone = await gitlabSession.editProjectMilestone(project_id, milestone_id, options); + return { + content: [ + { + type: "text", + text: JSON.stringify(milestone, null, 2), + }, + ], + }; + } + + case "delete_milestone": { + const { project_id, milestone_id } = DeleteProjectMilestoneSchema.parse( + request.params.arguments + ); + await gitlabSession.deleteProjectMilestone(project_id, milestone_id); + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + status: "success", + message: "Milestone deleted successfully", + }, + null, + 2 + ), + }, + ], + }; + } + + case "get_milestone_issue": { + const { project_id, milestone_id } = GetMilestoneIssuesSchema.parse( + request.params.arguments + ); + const issues = await gitlabSession.getMilestoneIssues(project_id, milestone_id); + return { + content: [ + { + type: "text", + text: JSON.stringify(issues, null, 2), + }, + ], + }; + } + + case "get_milestone_merge_requests": { + const { project_id, milestone_id } = GetMilestoneMergeRequestsSchema.parse( + request.params.arguments + ); + const mergeRequests = await gitlabSession.getMilestoneMergeRequests(project_id, milestone_id); + return { + content: [ + { + type: "text", + text: JSON.stringify(mergeRequests, null, 2), + }, + ], + }; + } + + case "promote_milestone": { + const { project_id, milestone_id } = PromoteProjectMilestoneSchema.parse( + request.params.arguments + ); + const milestone = await gitlabSession.promoteProjectMilestone(project_id, milestone_id); + return { + content: [ + { + type: "text", + text: JSON.stringify(milestone, null, 2), + }, + ], + }; + } + + case "get_milestone_burndown_events": { + const { project_id, milestone_id } = GetMilestoneBurndownEventsSchema.parse( + request.params.arguments + ); + const events = await gitlabSession.getMilestoneBurndownEvents(project_id, milestone_id); + return { + content: [ + { + type: "text", + text: JSON.stringify(events, null, 2), + }, + ], + }; + } + + case "list_commits": { + const args = ListCommitsSchema.parse(request.params.arguments); + const commits = await gitlabSession.listCommits(args.project_id, args); + return { + content: [{ type: "text", text: JSON.stringify(commits, null, 2) }], + }; + } + + case "get_commit": { + const args = GetCommitSchema.parse(request.params.arguments); + const commit = await gitlabSession.getCommit(args.project_id, args.sha, args.stats); + return { + content: [{ type: "text", text: JSON.stringify(commit, null, 2) }], + }; + } + + case "get_commit_diff": { + const args = GetCommitDiffSchema.parse(request.params.arguments); + const diff = await gitlabSession.getCommitDiff(args.project_id, args.sha); + return { + content: [{ type: "text", text: JSON.stringify(diff, null, 2) }], + }; + } + case "get_current_user": { + const user = await gitlabSession.getCurrentUser(); + return { + content: [{ type: "text", text: JSON.stringify(user, null, 2) }], + }; + } + + case "get_draft_note": { + const args = GetDraftNoteSchema.parse(request.params.arguments); + const { project_id, merge_request_iid, draft_note_id } = args; + + const draftNote = await gitlabSession.getDraftNote(project_id, merge_request_iid, draft_note_id); + return { + content: [{ type: "text", text: JSON.stringify(draftNote, null, 2) }], + }; + } + + case "list_draft_notes": { + const args = ListDraftNotesSchema.parse(request.params.arguments); + const { project_id, merge_request_iid } = args; + + const draftNotes = await gitlabSession.listDraftNotes(project_id, merge_request_iid); + return { + content: [{ type: "text", text: JSON.stringify(draftNotes, null, 2) }], + }; + } + + case "create_draft_note": { + const args = CreateDraftNoteSchema.parse(request.params.arguments); + const { project_id, merge_request_iid, body, position, resolve_discussion } = args; + + const draftNote = await gitlabSession.createDraftNote(project_id, merge_request_iid, body, position, resolve_discussion); + return { + content: [{ type: "text", text: JSON.stringify(draftNote, null, 2) }], + }; + } + + case "update_draft_note": { + const args = UpdateDraftNoteSchema.parse(request.params.arguments); + const { project_id, merge_request_iid, draft_note_id, body, position, resolve_discussion } = args; + + const draftNote = await gitlabSession.updateDraftNote(project_id, merge_request_iid, draft_note_id, body, position, resolve_discussion); + return { + content: [{ type: "text", text: JSON.stringify(draftNote, null, 2) }], + }; + } + + case "delete_draft_note": { + const args = DeleteDraftNoteSchema.parse(request.params.arguments); + const { project_id, merge_request_iid, draft_note_id } = args; + + await gitlabSession.deleteDraftNote(project_id, merge_request_iid, draft_note_id); + return { + content: [{ type: "text", text: "Draft note deleted successfully" }], + }; + } + + case "publish_draft_note": { + const args = PublishDraftNoteSchema.parse(request.params.arguments); + const { project_id, merge_request_iid, draft_note_id } = args; + + const publishedNote = await gitlabSession.publishDraftNote(project_id, merge_request_iid, draft_note_id); + return { + content: [{ type: "text", text: JSON.stringify(publishedNote, null, 2) }], + }; + } + + case "bulk_publish_draft_notes": { + const args = BulkPublishDraftNotesSchema.parse(request.params.arguments); + const { project_id, merge_request_iid } = args; + + const publishedNotes = await gitlabSession.bulkPublishDraftNotes(project_id, merge_request_iid); + return { + content: [{ type: "text", text: JSON.stringify(publishedNotes, null, 2) }], + }; + } + + case "merge_merge_request": { + const args = MergeMergeRequestSchema.parse(request.params.arguments); + const { project_id, merge_request_iid, ...options } = args; + const mergeRequest = await gitlabSession.mergeMergeRequest(project_id, options, merge_request_iid); + return { + content: [{ type: "text", text: JSON.stringify(mergeRequest, null, 2) }], + }; + } + + case "my_issues": { + const args = MyIssuesSchema.parse(request.params.arguments); + const issues = await gitlabSession.myIssues(args); + return { + content: [{ type: "text", text: JSON.stringify(issues, null, 2) }], + }; + } + + case "list_project_members": { + const args = ListProjectMembersSchema.parse(request.params.arguments); + const { project_id, ...options } = args; + const members = await gitlabSession.listProjectMembers(project_id, options); + return { + content: [{ type: "text", text: JSON.stringify(members, null, 2) }], + }; + } + + case "upload_markdown": { + const args = MarkdownUploadSchema.parse(request.params.arguments); + const upload = await gitlabSession.markdownUpload(args.project_id, args.file_path); + return { + content: [{ type: "text", text: JSON.stringify(upload, null, 2) }], + }; + } + + case "download_attachment": { + const args = DownloadAttachmentSchema.parse(request.params.arguments); + const filePath = await gitlabSession.downloadAttachment(args.project_id, args.secret, args.filename, args.local_path); + return { + content: [{ type: "text", text: JSON.stringify({ success: true, file_path: filePath }, null, 2) }], + }; + } + + default: + throw new Error(`Unknown tool: ${request.params.name}`); + } + } catch (error) { + logger.debug(request.params) + if (error instanceof z.ZodError) { + throw new Error( + `Invalid arguments: ${error.errors + .map(e => `${e.path.join(".")}: ${e.message}`) + .join(", ")}` + ); + } + throw error; + } +}); + +export const mcpserver = server diff --git a/src/oauth.ts b/src/oauth.ts new file mode 100644 index 0000000..56fd193 --- /dev/null +++ b/src/oauth.ts @@ -0,0 +1,505 @@ +import { ProxyOAuthServerProvider, ProxyOptions } from '@modelcontextprotocol/sdk/server/auth/providers/proxyProvider.js'; +import { config } from './config.js'; +import { mcpAuthRouter } from '@modelcontextprotocol/sdk/server/auth/router.js'; +import { OAuthClientInformationFull, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js'; +import { AuthorizationParams } from '@modelcontextprotocol/sdk/server/auth/provider.js'; +import { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js'; +import { OAuthRegisteredClientsStore } from '@modelcontextprotocol/sdk/server/auth/clients.js'; +import { Request, Response } from 'express'; +import { logger } from './logger.js'; +import { Database } from 'better-sqlite3'; +import argon2 from './argon2wrapper.js'; +import { randomBytes } from 'crypto'; + +// Custom provider that handles dynamic registration and maps to GitLab OAuth +class GitLabProxyProvider extends ProxyOAuthServerProvider { + // Static async factory method + static async New(options: any): Promise { + // we put this here so we dont initialize this unless we are using the oauth provider + const Database = (await import('better-sqlite3')).default; + const db = new Database(config.GITLAB_OAUTH2_DB_PATH); + + // Create tables if they don't exist + db.exec(` + CREATE TABLE IF NOT EXISTS oauth_clients ( + client_id TEXT PRIMARY KEY, + client_secret TEXT NOT NULL, + redirect_uris TEXT NOT NULL, + grant_types TEXT NOT NULL, + response_types TEXT NOT NULL, + token_endpoint_auth_method TEXT NOT NULL, + client_id_issued_at INTEGER NOT NULL, + metadata TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS client_redirect_uris ( + client_id TEXT PRIMARY KEY, + redirect_uris TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS state_mappings ( + state TEXT PRIMARY KEY, + redirect_uri TEXT NOT NULL, + timestamp INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS access_tokens ( + token_hash TEXT PRIMARY KEY, + client_id TEXT NOT NULL, + scopes TEXT NOT NULL, + expires_at INTEGER, + timestamp INTEGER NOT NULL + ); + `); + + const provider = new GitLabProxyProvider(options, db); + return provider; + } + + // State expiry time in milliseconds (15 minutes) + private readonly STATE_EXPIRY_MS = 15 * 60 * 1000; + + // Token expiry time in milliseconds (7 days) + private readonly TOKEN_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000; + + // Cleanup interval + private cleanupInterval: NodeJS.Timeout; + + private db: Database + + constructor(options: ProxyOptions, db: Database) { + super(options); + this.db = db; + + // Start cleanup interval + this.cleanupInterval = setInterval(() => { + const now = Date.now(); + + try { + // Clean up expired state mappings + db.prepare('DELETE FROM state_mappings WHERE timestamp < ?').run(now - this.STATE_EXPIRY_MS); + + // Clean up expired tokens + db.prepare('DELETE FROM access_tokens WHERE timestamp < ? OR (expires_at IS NOT NULL AND expires_at < ?)') + .run(now - this.TOKEN_EXPIRY_MS, Math.floor(now / 1000)); + } catch (err) { + logger.error('Error during cleanup:', err); + } + }, 5 * 60 * 1000); + } + get clientsStore(): OAuthRegisteredClientsStore { + return { + getClient: async (clientId: string) => { + // Check if this is the actual GitLab client + if (clientId === config.GITLAB_OAUTH2_CLIENT_ID) { + return { + client_id: config.GITLAB_OAUTH2_CLIENT_ID!, + client_secret: config.GITLAB_OAUTH2_CLIENT_SECRET!, + redirect_uris: [config.GITLAB_OAUTH2_REDIRECT_URL!], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'client_secret_post' + }; + } + + // Check if this is a registered dynamic client + const row = this.db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(clientId) as { + client_id: string; + client_secret: string; + redirect_uris: string; + grant_types: string; + response_types: string; + token_endpoint_auth_method: string; + client_id_issued_at: number; + metadata: string; + } | undefined; + + if (!row) { + return undefined; + } + + const client: OAuthClientInformationFull = { + ...JSON.parse(row.metadata), + client_id: row.client_id, + client_secret: row.client_secret, + redirect_uris: JSON.parse(row.redirect_uris), + grant_types: JSON.parse(row.grant_types), + response_types: JSON.parse(row.response_types), + token_endpoint_auth_method: row.token_endpoint_auth_method, + client_id_issued_at: row.client_id_issued_at + }; + + return client; + }, + + registerClient: async (clientMetadata: any) => { + // Generate a unique client ID for this MCP client using crypto-safe random + const randomId = randomBytes(16).toString('hex'); + const clientId = `mcp_${Date.now()}_${randomId}`; + + // Generate a secure client secret + const randomSecret = randomBytes(32).toString('hex'); + + // Create the client registration + const client: OAuthClientInformationFull = { + ...clientMetadata, + client_id: clientId, + client_secret: `secret_${randomSecret}`, + client_id_issued_at: Math.floor(Date.now() / 1000), + grant_types: clientMetadata.grant_types || ['authorization_code', 'refresh_token'], + response_types: clientMetadata.response_types || ['code'], + token_endpoint_auth_method: clientMetadata.token_endpoint_auth_method || 'client_secret_post' + }; + + // Store the client in database + try { + // Store client + this.db.prepare(` + INSERT INTO oauth_clients + (client_id, client_secret, redirect_uris, grant_types, response_types, + token_endpoint_auth_method, client_id_issued_at, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `).run( + client.client_id, + client.client_secret, + JSON.stringify(client.redirect_uris), + JSON.stringify(client.grant_types), + JSON.stringify(client.response_types), + client.token_endpoint_auth_method, + client.client_id_issued_at, + JSON.stringify(clientMetadata) + ); + + // Store redirect URIs + this.db.prepare('INSERT INTO client_redirect_uris (client_id, redirect_uris) VALUES (?, ?)') + .run(clientId, JSON.stringify(clientMetadata.redirect_uris || [])); + + return client; + } catch (err) { + throw err; + } + } + }; + } + + // Override authorization to use GitLab OAuth credentials + async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise { + // Store the mapping between state and client's actual redirect URI with timestamp + if (params.state && params.redirectUri) { + try { + this.db.prepare('INSERT OR REPLACE INTO state_mappings (state, redirect_uri, timestamp) VALUES (?, ?, ?)') + .run(params.state, params.redirectUri, Date.now()); + logger.debug(`Stored state mapping: ${params.state} -> ${params.redirectUri} at ${new Date().toISOString()}`); + } catch (err) { + logger.error('Error storing state mapping:', err); + throw err; + } + } + + // Construct the authorization URL directly to ensure proper formatting + const authUrl = new URL(this._endpoints.authorizationUrl); + + // Add required OAuth parameters + authUrl.searchParams.set('client_id', config.GITLAB_OAUTH2_CLIENT_ID!); + authUrl.searchParams.set('response_type', 'code'); + authUrl.searchParams.set('redirect_uri', config.GITLAB_OAUTH2_REDIRECT_URL!.trim()); + authUrl.searchParams.set('code_challenge', params.codeChallenge); + authUrl.searchParams.set('code_challenge_method', 'S256'); + + // Add optional parameters + if (params.state) { + authUrl.searchParams.set('state', params.state); + } + + + const gitlabScopes = ['api', 'openid','profile','email']; + authUrl.searchParams.set('scope', gitlabScopes.join(' ')); + + // GitLab doesn't support the 'resource' parameter, so we skip it + + logger.debug({ + url: authUrl.toString(), + scopes: gitlabScopes, + requested_scopes: params.scopes + },`Redirecting to GitLab OAuth`); + + // Redirect to GitLab + res.redirect(authUrl.toString()); + } + + // Method to get redirect URI from state + async getRedirectUriFromState(state: string): Promise<{ redirectUri: string; timestamp: number } | undefined> { + const row = this.db.prepare('SELECT redirect_uri, timestamp FROM state_mappings WHERE state = ?').get(state) as { + redirect_uri: string; + timestamp: number; + } | undefined; + + if (!row) { + return undefined; + } + + return { + redirectUri: row.redirect_uri, + timestamp: row.timestamp + }; + } + + // Method to delete state mapping + async deleteStateMapping(state: string): Promise { + this.db.prepare('DELETE FROM state_mappings WHERE state = ?').run(state); + } + + // Method to verify token + async verifyToken(token: string): Promise<{ authInfo: AuthInfo; gitlabToken: string; timestamp: number } | undefined> { + // Get all token hashes to check against + const rows = this.db.prepare('SELECT * FROM access_tokens').all() as Array<{ + token_hash: string; + client_id: string; + scopes: string; + expires_at: number | null; + timestamp: number; + }>; + + // Find the matching token by verifying against each hash + let matchingRow = null; + for (const row of rows) { + try { + if (await argon2.verify(row.token_hash, token)) { + matchingRow = row; + break; + } + } catch (err) { + // Skip invalid hashes + continue; + } + } + + if (!matchingRow) { + return undefined; + } + + const now = Date.now(); + + // Check if token has expired by timestamp + if (now - matchingRow.timestamp > this.TOKEN_EXPIRY_MS) { + await this.deleteAccessTokenByHash(matchingRow.token_hash); + return undefined; + } + + // Check if token has an explicit expiry time + if (matchingRow.expires_at && matchingRow.expires_at < Math.floor(now / 1000)) { + await this.deleteAccessTokenByHash(matchingRow.token_hash); + return undefined; + } + + const authInfo: AuthInfo = { + token: token, // Return the original token + clientId: matchingRow.client_id, + scopes: JSON.parse(matchingRow.scopes), + expiresAt: matchingRow.expires_at ?? undefined + }; + + return { + authInfo, + gitlabToken: token, // Return the original token since we don't store gitlab_token anymore + timestamp: matchingRow.timestamp + }; + } + + // Helper method to delete access token by hash + private async deleteAccessTokenByHash(tokenHash: string): Promise { + this.db.prepare('DELETE FROM access_tokens WHERE token_hash = ?').run(tokenHash); + } + + // Override token exchange to use GitLab OAuth credentials + async exchangeAuthorizationCode( + client: OAuthClientInformationFull, + authorizationCode: string, + codeVerifier?: string, + redirectUri?: string, + resource?: URL + ): Promise { + // Use GitLab OAuth credentials for token exchange + const gitlabClient = { + ...client, + client_id: config.GITLAB_OAUTH2_CLIENT_ID!, + client_secret: config.GITLAB_OAUTH2_CLIENT_SECRET!, + redirect_uris: [config.GITLAB_OAUTH2_REDIRECT_URL!] + }; + + // Use GitLab's redirect URI for the token exchange + const tokens = await super.exchangeAuthorizationCode( + gitlabClient, + authorizationCode, + codeVerifier, + config.GITLAB_OAUTH2_REDIRECT_URL!, + resource + ); + + // Store the token mapping for our own verification + if (tokens.access_token) { + const expiresAt = tokens.expires_in ? Math.floor(Date.now() / 1000) + tokens.expires_in : null; + const scopes = tokens.scope ? tokens.scope.split(' ') : []; + + try { + // Hash the token before storing + const tokenHash = await argon2.hash(tokens.access_token); + + this.db.prepare(` + INSERT OR REPLACE INTO access_tokens + (token_hash, client_id, scopes, expires_at, timestamp) + VALUES (?, ?, ?, ?, ?) + `).run( + tokenHash, + client.client_id, + JSON.stringify(scopes), + expiresAt, + Date.now() + ); + } catch (err) { + logger.error('Error storing access token:', err); + throw err; + } + } + + return tokens; + } + + // Override verifyAccessToken to use our internal token store + async verifyAccessToken(token: string): Promise { + const tokenInfo = await this.verifyToken(token); + + if (!tokenInfo) { + throw new Error('Invalid or expired token'); + } + + return tokenInfo.authInfo; + } + + // Handle OAuth callback and redirect to client's actual callback URL + handleOAuthCallback = async (req: Request, res: Response): Promise => { + const { code, state, error, error_description } = req.query; + + logger.debug({ code: !!code, state, error }, 'OAuth callback received'); + + if (!state) { + res.status(400).send('Missing state parameter'); + return; + } + + try { + // Get the client's actual redirect URI with timestamp + const stateMapping = await this.getRedirectUriFromState(state as string); + + if (!stateMapping) { + logger.error(`No redirect URI found for state: ${state}`); + res.status(400).send('Invalid state parameter'); + return; + } + + // Check if the state mapping has expired + if (Date.now() - stateMapping.timestamp > this.STATE_EXPIRY_MS) { + logger.error(`State mapping expired for state: ${state}`); + await this.deleteStateMapping(state as string); + res.status(400).send('State parameter expired'); + return; + } + + const clientRedirectUri = stateMapping.redirectUri; + + // Clean up the state mapping + await this.deleteStateMapping(state as string); + + // Build the redirect URL with all parameters + const redirectUrl = new URL(clientRedirectUri); + + // Pass through all query parameters + if (code) redirectUrl.searchParams.set('code', code as string); + if (state) redirectUrl.searchParams.set('state', state as string); + if (error) redirectUrl.searchParams.set('error', error as string); + if (error_description) redirectUrl.searchParams.set('error_description', error_description as string); + if(error) { + logger.debug({error}, "oauth callback error"); + } + + logger.debug(`sending redirecting to client callback ${state}`); + + // Redirect to the client's actual callback URL + res.redirect(redirectUrl.toString()); + } catch (err) { + logger.error('Error handling OAuth callback:', err); + res.status(500).send('Internal server error'); + } + } + + // Create OAuth2 router + createOAuth2Router() { + if (!config.GITLAB_OAUTH2_BASE_URL) { + throw new Error("GITLAB_OAUTH2_BASE_URL is not set") + } + + return mcpAuthRouter({ + issuerUrl: new URL(config.GITLAB_OAUTH2_BASE_URL), + baseUrl: new URL(config.GITLAB_OAUTH2_BASE_URL), + authorizationOptions: { + }, + provider: this, + }) + } + + // Create token verifier + createTokenVerifier() { + const tokenVerifier = { + verifyAccessToken: async (token: string) => { + return this.verifyAccessToken(token); + } + } + + return tokenVerifier + } +} + +// Export the provider class +export { GitLabProxyProvider }; + +// Create the GitLab OAuth provider +export const createGitLabOAuthProvider = async () => { + if(!config.GITLAB_OAUTH2_AUTHORIZATION_URL) { + throw new Error("GITLAB_OAUTH2_AUTHORIZATION_URL is not set") + } + if(!config.GITLAB_OAUTH2_CLIENT_ID) { + throw new Error("GITLAB_OAUTH2_CLIENT_ID is not set") + } + if(!config.GITLAB_OAUTH2_REDIRECT_URL) { + throw new Error("GITLAB_OAUTH2_REDIRECT_URIS is not set") + } + if(!config.GITLAB_OAUTH2_TOKEN_URL) { + throw new Error("GITLAB_OAUTH2_TOKEN_URL is not set") + } + if(!config.GITLAB_OAUTH2_ISSUER_URL) { + throw new Error("GITLAB_OAUTH2_ISSUER_URL is not set") + } + if (!config.GITLAB_OAUTH2_BASE_URL) { + throw new Error("GITLAB_OAUTH2_BASE_URL is not set") + } + + const provider = await GitLabProxyProvider.New({ + endpoints: { + authorizationUrl: config.GITLAB_OAUTH2_AUTHORIZATION_URL, + tokenUrl: config.GITLAB_OAUTH2_TOKEN_URL, + revocationUrl: config.GITLAB_OAUTH2_REVOCATION_URL, + }, + verifyAccessToken: async () => { + // This will be overridden by the class method + throw new Error('Should not be called'); + }, + getClient: async (client_id: string) => { + // This is handled by our custom provider's clientsStore + return undefined; + } + }) + + return provider; +} + + diff --git a/schemas.ts b/src/schemas.ts similarity index 99% rename from schemas.ts rename to src/schemas.ts index 370f2f7..0b90b50 100644 --- a/schemas.ts +++ b/src/schemas.ts @@ -1784,6 +1784,8 @@ export const GetCommitDiffSchema = z.object({ sha: z.string().describe("The commit hash or name of a repository branch or tag"), }); +export const GetCurrentUserSchema = z.object({}); + // Schema for listing issues assigned to the current user export const MyIssuesSchema = z.object({ project_id: z.string().optional().describe("Project ID or URL-encoded path (optional when GITLAB_PROJECT_ID is set)"),