From 6abca3e7d046efbed372e5c32eaf106882a6af9a Mon Sep 17 00:00:00 2001 From: pdogra1299 Date: Wed, 4 Jun 2025 19:33:16 +0530 Subject: [PATCH] feat: Add remaining PR lifecycle and code review tools - Added create_pull_request, update_pull_request, merge_pull_request tools - Added list_branches and delete_branch tools (fixed delete to handle 204 response) - Enhanced add_comment to support inline comments on specific code lines - Added all code review tools: approve/unapprove, request/remove changes, get diff - Updated package version to 0.2.0 - Comprehensive documentation for all tools in README --- README.md | 196 +++++++- package.json | 2 +- src/index.ts | 1331 +++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 1508 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index bd236f3..daf9941 100644 --- a/README.md +++ b/README.md @@ -4,16 +4,23 @@ An MCP (Model Context Protocol) server that provides tools for interacting with ## Features -Currently implemented: +### Currently Implemented Tools + +#### Core PR Lifecycle Tools - `get_pull_request` - Retrieve detailed information about a pull request - `list_pull_requests` - List pull requests with filters (state, author, pagination) - -Planned features: - `create_pull_request` - Create new pull requests -- `update_pull_request` - Update PR details -- `merge_pull_request` - Merge pull requests -- `delete_branch` - Delete branches -- And more... +- `update_pull_request` - Update PR details (title, description, reviewers, destination branch) +- `add_comment` - Add comments to pull requests (supports replies) +- `merge_pull_request` - Merge pull requests with various strategies +- `delete_branch` - Delete branches after merge + +#### Code Review Tools +- `get_pull_request_diff` - Get the diff/changes for a pull request +- `approve_pull_request` - Approve a pull request +- `unapprove_pull_request` - Remove approval from a pull request +- `request_changes` - Request changes on a pull request +- `remove_requested_changes` - Remove change request from a pull request ## Installation @@ -84,7 +91,7 @@ For Bitbucket Server, use: "command": "node", "args": ["/absolute/path/to/bitbucket-mcp-server/build/index.js"], "env": { - "BITBUCKET_USERNAME": "your-username", + "BITBUCKET_USERNAME": "your.email@company.com", "BITBUCKET_TOKEN": "your-http-access-token", "BITBUCKET_BASE_URL": "https://bitbucket.yourcompany.com" } @@ -93,6 +100,10 @@ For Bitbucket Server, use: } ``` +**Important for Bitbucket Server users:** +- Use your full email address as the username (e.g., "john.doe@company.com") +- This is required for approval/review actions to work correctly + ## Usage Once configured, you can use the available tools: @@ -143,6 +154,175 @@ Returns a paginated list of pull requests with: - For Bitbucket Cloud: Use the username (e.g., "johndoe") - For Bitbucket Server: Use the full email address (e.g., "john.doe@company.com") +### Create Pull Request + +```typescript +{ + "tool": "create_pull_request", + "arguments": { + "workspace": "PROJ", + "repository": "my-repo", + "title": "Add new feature", + "source_branch": "feature/new-feature", + "destination_branch": "main", + "description": "This PR adds a new feature...", // Optional + "reviewers": ["john.doe", "jane.smith"], // Optional + "close_source_branch": true // Optional (default: false) + } +} +``` + +### Update Pull Request + +```typescript +{ + "tool": "update_pull_request", + "arguments": { + "workspace": "PROJ", + "repository": "my-repo", + "pull_request_id": 123, + "title": "Updated title", // Optional + "description": "Updated description", // Optional + "destination_branch": "develop", // Optional + "reviewers": ["new.reviewer"] // Optional - replaces existing reviewers + } +} +``` + +### Add Comment + +Add general comments or inline comments on specific lines of code: + +```typescript +// General comment +{ + "tool": "add_comment", + "arguments": { + "workspace": "PROJ", + "repository": "my-repo", + "pull_request_id": 123, + "comment_text": "Great work! Just one small suggestion...", + "parent_comment_id": 456 // Optional - for replies + } +} + +// Inline comment on specific code +{ + "tool": "add_comment", + "arguments": { + "workspace": "PROJ", + "repository": "my-repo", + "pull_request_id": 123, + "comment_text": "This variable should be renamed for clarity", + "file_path": "src/main.js", + "line_number": 42, + "line_type": "ADDED" // ADDED, REMOVED, or CONTEXT + } +} +``` + +**Note on inline comments:** +- `file_path`: The path to the file as shown in the diff +- `line_number`: The line number as shown in the diff +- `line_type`: + - `ADDED` - For newly added lines (green in diff) + - `REMOVED` - For deleted lines (red in diff) + - `CONTEXT` - For unchanged context lines + +### Merge Pull Request + +```typescript +{ + "tool": "merge_pull_request", + "arguments": { + "workspace": "PROJ", + "repository": "my-repo", + "pull_request_id": 123, + "merge_strategy": "squash", // Optional: merge-commit, squash, fast-forward + "close_source_branch": true, // Optional + "commit_message": "Custom merge message" // Optional + } +} +``` + +### List Branches + +```typescript +{ + "tool": "list_branches", + "arguments": { + "workspace": "PROJ", + "repository": "my-repo", + "filter": "feature", // Optional: filter by name pattern + "limit": 25, // Optional (default: 25) + "start": 0 // Optional: for pagination (default: 0) + } +} +``` + +Returns a paginated list of branches with: +- Branch name and ID +- Latest commit hash +- Default branch indicator +- Pagination info + +### Delete Branch + +```typescript +{ + "tool": "delete_branch", + "arguments": { + "workspace": "PROJ", + "repository": "my-repo", + "branch_name": "feature/old-feature", + "force": false // Optional (default: false) + } +} +``` + +**Note**: Branch deletion requires appropriate permissions. The branch will be permanently deleted. + +### Get Pull Request Diff + +```typescript +{ + "tool": "get_pull_request_diff", + "arguments": { + "workspace": "PROJ", + "repository": "my-repo", + "pull_request_id": 123, + "context_lines": 5 // Optional (default: 3) + } +} +``` + +### Approve Pull Request + +```typescript +{ + "tool": "approve_pull_request", + "arguments": { + "workspace": "PROJ", + "repository": "my-repo", + "pull_request_id": 123 + } +} +``` + +### Request Changes + +```typescript +{ + "tool": "request_changes", + "arguments": { + "workspace": "PROJ", + "repository": "my-repo", + "pull_request_id": 123, + "comment": "Please address the following issues..." // Optional + } +} +``` + ## Development - `npm run dev` - Watch mode for development diff --git a/package.json b/package.json index 7cceaf5..f4ed57e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bitbucket-mcp-server", - "version": "0.1.0", + "version": "0.2.0", "description": "MCP server for Bitbucket API integration", "type": "module", "main": "./build/index.js", diff --git a/src/index.ts b/src/index.ts index 9ec4dd0..3a5a9c4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -188,6 +188,168 @@ const isListPullRequestsArgs = ( (args.limit === undefined || typeof args.limit === 'number') && (args.start === undefined || typeof args.start === 'number'); +// Type guards for new tools +const isCreatePullRequestArgs = ( + args: any +): args is { + workspace: string; + repository: string; + title: string; + source_branch: string; + destination_branch: string; + description?: string; + reviewers?: string[]; + close_source_branch?: boolean; +} => + typeof args === 'object' && + args !== null && + typeof args.workspace === 'string' && + typeof args.repository === 'string' && + typeof args.title === 'string' && + typeof args.source_branch === 'string' && + typeof args.destination_branch === 'string' && + (args.description === undefined || typeof args.description === 'string') && + (args.reviewers === undefined || Array.isArray(args.reviewers)) && + (args.close_source_branch === undefined || typeof args.close_source_branch === 'boolean'); + +const isUpdatePullRequestArgs = ( + args: any +): args is { + workspace: string; + repository: string; + pull_request_id: number; + title?: string; + description?: string; + destination_branch?: string; + reviewers?: string[]; +} => + typeof args === 'object' && + args !== null && + typeof args.workspace === 'string' && + typeof args.repository === 'string' && + typeof args.pull_request_id === 'number' && + (args.title === undefined || typeof args.title === 'string') && + (args.description === undefined || typeof args.description === 'string') && + (args.destination_branch === undefined || typeof args.destination_branch === 'string') && + (args.reviewers === undefined || Array.isArray(args.reviewers)); + +const isAddCommentArgs = ( + args: any +): args is { + workspace: string; + repository: string; + pull_request_id: number; + comment_text: string; + parent_comment_id?: number; + file_path?: string; + line_number?: number; + line_type?: 'ADDED' | 'REMOVED' | 'CONTEXT'; +} => + typeof args === 'object' && + args !== null && + typeof args.workspace === 'string' && + typeof args.repository === 'string' && + typeof args.pull_request_id === 'number' && + typeof args.comment_text === 'string' && + (args.parent_comment_id === undefined || typeof args.parent_comment_id === 'number') && + (args.file_path === undefined || typeof args.file_path === 'string') && + (args.line_number === undefined || typeof args.line_number === 'number') && + (args.line_type === undefined || ['ADDED', 'REMOVED', 'CONTEXT'].includes(args.line_type)); + +const isMergePullRequestArgs = ( + args: any +): args is { + workspace: string; + repository: string; + pull_request_id: number; + merge_strategy?: string; + close_source_branch?: boolean; + commit_message?: string; +} => + typeof args === 'object' && + args !== null && + typeof args.workspace === 'string' && + typeof args.repository === 'string' && + typeof args.pull_request_id === 'number' && + (args.merge_strategy === undefined || typeof args.merge_strategy === 'string') && + (args.close_source_branch === undefined || typeof args.close_source_branch === 'boolean') && + (args.commit_message === undefined || typeof args.commit_message === 'string'); + +const isDeleteBranchArgs = ( + args: any +): args is { + workspace: string; + repository: string; + branch_name: string; + force?: boolean; +} => + typeof args === 'object' && + args !== null && + typeof args.workspace === 'string' && + typeof args.repository === 'string' && + typeof args.branch_name === 'string' && + (args.force === undefined || typeof args.force === 'boolean'); + +const isListBranchesArgs = ( + args: any +): args is { + workspace: string; + repository: string; + filter?: string; + limit?: number; + start?: number; +} => + typeof args === 'object' && + args !== null && + typeof args.workspace === 'string' && + typeof args.repository === 'string' && + (args.filter === undefined || typeof args.filter === 'string') && + (args.limit === undefined || typeof args.limit === 'number') && + (args.start === undefined || typeof args.start === 'number'); + +const isGetPullRequestDiffArgs = ( + args: any +): args is { + workspace: string; + repository: string; + pull_request_id: number; + context_lines?: number; +} => + typeof args === 'object' && + args !== null && + typeof args.workspace === 'string' && + typeof args.repository === 'string' && + typeof args.pull_request_id === 'number' && + (args.context_lines === undefined || typeof args.context_lines === 'number'); + +const isApprovePullRequestArgs = ( + args: any +): args is { + workspace: string; + repository: string; + pull_request_id: number; +} => + typeof args === 'object' && + args !== null && + typeof args.workspace === 'string' && + typeof args.repository === 'string' && + typeof args.pull_request_id === 'number'; + +const isRequestChangesArgs = ( + args: any +): args is { + workspace: string; + repository: string; + pull_request_id: number; + comment?: string; +} => + typeof args === 'object' && + args !== null && + typeof args.workspace === 'string' && + typeof args.repository === 'string' && + typeof args.pull_request_id === 'number' && + (args.comment === undefined || typeof args.comment === 'string'); + class BitbucketMCPServer { private server: Server; private axiosInstance: AxiosInstance; @@ -199,7 +361,7 @@ class BitbucketMCPServer { this.server = new Server( { name: 'bitbucket-mcp-server', - version: '0.1.0', + version: '0.2.0', }, { capabilities: { @@ -240,6 +402,94 @@ class BitbucketMCPServer { }); } + // Helper method to build API paths + private buildApiPath(template: string, params: Record): string { + let path = template; + for (const [key, value] of Object.entries(params)) { + path = path.replace(`{${key}}`, value); + } + return path; + } + + // Helper method to make API requests with consistent error handling + private async makeApiRequest( + method: 'get' | 'post' | 'put' | 'delete', + path: string, + data?: any, + config?: any + ): Promise { + try { + const response = await this.axiosInstance[method](path, data, config); + return response.data; + } catch (error) { + if (axios.isAxiosError(error)) { + const status = error.response?.status; + const message = error.response?.data?.errors?.[0]?.message || + error.response?.data?.error?.message || + error.response?.data?.message || + error.message; + + throw { + status, + message, + isAxiosError: true, + originalError: error + }; + } + throw error; + } + } + + // Helper method to handle API errors consistently + private handleApiError(error: any, context: string) { + if (error.isAxiosError) { + const { status, message } = error; + + if (status === 404) { + return { + content: [ + { + type: 'text', + text: `Not found: ${context}`, + }, + ], + isError: true, + }; + } else if (status === 401) { + return { + content: [ + { + type: 'text', + text: `Authentication failed. Please check your ${this.isServer ? 'BITBUCKET_TOKEN' : 'BITBUCKET_USERNAME and BITBUCKET_APP_PASSWORD'}`, + }, + ], + isError: true, + }; + } else if (status === 403) { + return { + content: [ + { + type: 'text', + text: `Permission denied: ${context}. Ensure your credentials have the necessary permissions.`, + }, + ], + isError: true, + }; + } + + return { + content: [ + { + type: 'text', + text: `Bitbucket API error: ${message}`, + }, + ], + isError: true, + }; + } + throw error; + } + private formatServerResponse(pr: BitbucketServerPullRequest): any { const webUrl = `${BITBUCKET_BASE_URL}/projects/${pr.toRef.repository.project.key}/repos/${pr.toRef.repository.slug}/pull-requests/${pr.id}`; @@ -364,6 +614,342 @@ class BitbucketMCPServer { required: ['workspace', 'repository'], }, }, + // Phase 1: Core PR Lifecycle Tools + { + name: 'create_pull_request', + description: 'Create a new pull request', + inputSchema: { + type: 'object', + properties: { + workspace: { + type: 'string', + description: 'Bitbucket workspace/project key (e.g., "PROJ")', + }, + repository: { + type: 'string', + description: 'Repository slug (e.g., "my-repo")', + }, + title: { + type: 'string', + description: 'Title of the pull request', + }, + source_branch: { + type: 'string', + description: 'Source branch name', + }, + destination_branch: { + type: 'string', + description: 'Destination branch name (e.g., "main", "master")', + }, + description: { + type: 'string', + description: 'Description of the pull request (optional)', + }, + reviewers: { + type: 'array', + items: { type: 'string' }, + description: 'Array of reviewer usernames/emails (optional)', + }, + close_source_branch: { + type: 'boolean', + description: 'Whether to close source branch after merge (optional, default: false)', + }, + }, + required: ['workspace', 'repository', 'title', 'source_branch', 'destination_branch'], + }, + }, + { + name: 'update_pull_request', + description: 'Update an existing pull request', + inputSchema: { + type: 'object', + properties: { + workspace: { + type: 'string', + description: 'Bitbucket workspace/project key (e.g., "PROJ")', + }, + repository: { + type: 'string', + description: 'Repository slug (e.g., "my-repo")', + }, + pull_request_id: { + type: 'number', + description: 'Pull request ID', + }, + title: { + type: 'string', + description: 'New title (optional)', + }, + description: { + type: 'string', + description: 'New description (optional)', + }, + destination_branch: { + type: 'string', + description: 'New destination branch (optional)', + }, + reviewers: { + type: 'array', + items: { type: 'string' }, + description: 'New list of reviewer usernames/emails (optional)', + }, + }, + required: ['workspace', 'repository', 'pull_request_id'], + }, + }, + { + name: 'add_comment', + description: 'Add a comment to a pull request (general or inline on specific code)', + inputSchema: { + type: 'object', + properties: { + workspace: { + type: 'string', + description: 'Bitbucket workspace/project key (e.g., "PROJ")', + }, + repository: { + type: 'string', + description: 'Repository slug (e.g., "my-repo")', + }, + pull_request_id: { + type: 'number', + description: 'Pull request ID', + }, + comment_text: { + type: 'string', + description: 'Comment text', + }, + parent_comment_id: { + type: 'number', + description: 'Parent comment ID for replies (optional)', + }, + file_path: { + type: 'string', + description: 'File path for inline comment (optional, e.g., "src/main.js")', + }, + line_number: { + type: 'number', + description: 'Line number for inline comment (optional, required with file_path)', + }, + line_type: { + type: 'string', + description: 'Type of line for inline comment: ADDED, REMOVED, or CONTEXT (optional, default: CONTEXT)', + enum: ['ADDED', 'REMOVED', 'CONTEXT'], + }, + }, + required: ['workspace', 'repository', 'pull_request_id', 'comment_text'], + }, + }, + { + name: 'merge_pull_request', + description: 'Merge a pull request', + inputSchema: { + type: 'object', + properties: { + workspace: { + type: 'string', + description: 'Bitbucket workspace/project key (e.g., "PROJ")', + }, + repository: { + type: 'string', + description: 'Repository slug (e.g., "my-repo")', + }, + pull_request_id: { + type: 'number', + description: 'Pull request ID', + }, + merge_strategy: { + type: 'string', + description: 'Merge strategy: merge-commit, squash, fast-forward (optional)', + enum: ['merge-commit', 'squash', 'fast-forward'], + }, + close_source_branch: { + type: 'boolean', + description: 'Whether to close source branch after merge (optional)', + }, + commit_message: { + type: 'string', + description: 'Custom merge commit message (optional)', + }, + }, + required: ['workspace', 'repository', 'pull_request_id'], + }, + }, + { + name: 'list_branches', + description: 'List branches in a repository', + inputSchema: { + type: 'object', + properties: { + workspace: { + type: 'string', + description: 'Bitbucket workspace/project key (e.g., "PROJ")', + }, + repository: { + type: 'string', + description: 'Repository slug (e.g., "my-repo")', + }, + filter: { + type: 'string', + description: 'Filter branches by name pattern (optional)', + }, + limit: { + type: 'number', + description: 'Maximum number of branches to return (default: 25)', + }, + start: { + type: 'number', + description: 'Start index for pagination (default: 0)', + }, + }, + required: ['workspace', 'repository'], + }, + }, + { + name: 'delete_branch', + description: 'Delete a branch', + inputSchema: { + type: 'object', + properties: { + workspace: { + type: 'string', + description: 'Bitbucket workspace/project key (e.g., "PROJ")', + }, + repository: { + type: 'string', + description: 'Repository slug (e.g., "my-repo")', + }, + branch_name: { + type: 'string', + description: 'Branch name to delete', + }, + force: { + type: 'boolean', + description: 'Force delete even if branch is not merged (optional, default: false)', + }, + }, + required: ['workspace', 'repository', 'branch_name'], + }, + }, + // Phase 2: Code Review Tools + { + name: 'get_pull_request_diff', + description: 'Get the diff/changes for a pull request', + inputSchema: { + type: 'object', + properties: { + workspace: { + type: 'string', + description: 'Bitbucket workspace/project key (e.g., "PROJ")', + }, + repository: { + type: 'string', + description: 'Repository slug (e.g., "my-repo")', + }, + pull_request_id: { + type: 'number', + description: 'Pull request ID', + }, + context_lines: { + type: 'number', + description: 'Number of context lines around changes (optional, default: 3)', + }, + }, + required: ['workspace', 'repository', 'pull_request_id'], + }, + }, + { + name: 'approve_pull_request', + description: 'Approve a pull request', + inputSchema: { + type: 'object', + properties: { + workspace: { + type: 'string', + description: 'Bitbucket workspace/project key (e.g., "PROJ")', + }, + repository: { + type: 'string', + description: 'Repository slug (e.g., "my-repo")', + }, + pull_request_id: { + type: 'number', + description: 'Pull request ID', + }, + }, + required: ['workspace', 'repository', 'pull_request_id'], + }, + }, + { + name: 'unapprove_pull_request', + description: 'Remove approval from a pull request', + inputSchema: { + type: 'object', + properties: { + workspace: { + type: 'string', + description: 'Bitbucket workspace/project key (e.g., "PROJ")', + }, + repository: { + type: 'string', + description: 'Repository slug (e.g., "my-repo")', + }, + pull_request_id: { + type: 'number', + description: 'Pull request ID', + }, + }, + required: ['workspace', 'repository', 'pull_request_id'], + }, + }, + { + name: 'request_changes', + description: 'Request changes on a pull request', + inputSchema: { + type: 'object', + properties: { + workspace: { + type: 'string', + description: 'Bitbucket workspace/project key (e.g., "PROJ")', + }, + repository: { + type: 'string', + description: 'Repository slug (e.g., "my-repo")', + }, + pull_request_id: { + type: 'number', + description: 'Pull request ID', + }, + comment: { + type: 'string', + description: 'Comment explaining requested changes (optional)', + }, + }, + required: ['workspace', 'repository', 'pull_request_id'], + }, + }, + { + name: 'remove_requested_changes', + description: 'Remove change request from a pull request', + inputSchema: { + type: 'object', + properties: { + workspace: { + type: 'string', + description: 'Bitbucket workspace/project key (e.g., "PROJ")', + }, + repository: { + type: 'string', + description: 'Repository slug (e.g., "my-repo")', + }, + pull_request_id: { + type: 'number', + description: 'Pull request ID', + }, + }, + required: ['workspace', 'repository', 'pull_request_id'], + }, + }, ], })); @@ -374,6 +960,30 @@ class BitbucketMCPServer { return this.handleGetPullRequest(request.params.arguments); case 'list_pull_requests': return this.handleListPullRequests(request.params.arguments); + // Phase 1: Core PR Lifecycle Tools + case 'create_pull_request': + return this.handleCreatePullRequest(request.params.arguments); + case 'update_pull_request': + return this.handleUpdatePullRequest(request.params.arguments); + case 'add_comment': + return this.handleAddComment(request.params.arguments); + case 'merge_pull_request': + return this.handleMergePullRequest(request.params.arguments); + case 'list_branches': + return this.handleListBranches(request.params.arguments); + case 'delete_branch': + return this.handleDeleteBranch(request.params.arguments); + // Phase 2: Code Review Tools + case 'get_pull_request_diff': + return this.handleGetPullRequestDiff(request.params.arguments); + case 'approve_pull_request': + return this.handleApprovePullRequest(request.params.arguments); + case 'unapprove_pull_request': + return this.handleUnapprovePullRequest(request.params.arguments); + case 'request_changes': + return this.handleRequestChanges(request.params.arguments); + case 'remove_requested_changes': + return this.handleRemoveRequestedChanges(request.params.arguments); default: throw new McpError( ErrorCode.MethodNotFound, @@ -398,8 +1008,6 @@ class BitbucketMCPServer { const apiPath = this.isServer ? `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pull_request_id}` // Server : `/repositories/${workspace}/${repository}/pullrequests/${pull_request_id}`; // Cloud - - console.error(`[DEBUG] Fetching PR from: ${BITBUCKET_BASE_URL}${apiPath}`); const response = await this.axiosInstance.get(apiPath); const pr = response.data; @@ -425,9 +1033,6 @@ class BitbucketMCPServer { error.response?.data?.message || error.message; - console.error(`[DEBUG] API Error: ${status} - ${message}`); - console.error(`[DEBUG] Full error response:`, error.response?.data); - if (status === 404) { return { content: [ @@ -504,9 +1109,6 @@ class BitbucketMCPServer { } } - console.error(`[DEBUG] Listing PRs from: ${BITBUCKET_BASE_URL}${apiPath}`); - console.error(`[DEBUG] Params:`, params); - const response = await this.axiosInstance.get(apiPath, { params }); const data = response.data; @@ -558,9 +1160,6 @@ class BitbucketMCPServer { error.response?.data?.message || error.message; - console.error(`[DEBUG] API Error: ${status} - ${message}`); - console.error(`[DEBUG] Full error response:`, error.response?.data); - if (status === 404) { return { content: [ @@ -597,6 +1196,714 @@ class BitbucketMCPServer { } } + // Phase 1: Core PR Lifecycle Tools Implementation + + private async handleCreatePullRequest(args: any) { + if (!isCreatePullRequestArgs(args)) { + throw new McpError( + ErrorCode.InvalidParams, + 'Invalid arguments for create_pull_request' + ); + } + + const { workspace, repository, title, source_branch, destination_branch, description, reviewers, close_source_branch } = args; + + try { + let apiPath: string; + let requestBody: any; + + if (this.isServer) { + // Bitbucket Server API + apiPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests`; + requestBody = { + title, + description: description || '', + fromRef: { + id: `refs/heads/${source_branch}`, + repository: { + slug: repository, + project: { + key: workspace + } + } + }, + toRef: { + id: `refs/heads/${destination_branch}`, + repository: { + slug: repository, + project: { + key: workspace + } + } + }, + reviewers: reviewers?.map(r => ({ user: { name: r } })) || [] + }; + } else { + // Bitbucket Cloud API + apiPath = `/repositories/${workspace}/${repository}/pullrequests`; + requestBody = { + title, + description: description || '', + source: { + branch: { + name: source_branch + } + }, + destination: { + branch: { + name: destination_branch + } + }, + close_source_branch: close_source_branch || false, + reviewers: reviewers?.map(r => ({ username: r })) || [] + }; + } + + const pr = await this.makeApiRequest('post', apiPath, requestBody); + + const formattedResponse = this.isServer + ? this.formatServerResponse(pr as BitbucketServerPullRequest) + : this.formatCloudResponse(pr as BitbucketCloudPullRequest); + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + message: 'Pull request created successfully', + pull_request: formattedResponse + }, null, 2), + }, + ], + }; + } catch (error) { + return this.handleApiError(error, `creating pull request in ${workspace}/${repository}`); + } + } + + private async handleUpdatePullRequest(args: any) { + if (!isUpdatePullRequestArgs(args)) { + throw new McpError( + ErrorCode.InvalidParams, + 'Invalid arguments for update_pull_request' + ); + } + + const { workspace, repository, pull_request_id, title, description, destination_branch, reviewers } = args; + + try { + let apiPath: string; + let requestBody: any = {}; + + if (this.isServer) { + // Bitbucket Server API + apiPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pull_request_id}`; + + // First get the current PR to get version number + const currentPr = await this.makeApiRequest('get', apiPath); + + requestBody.version = currentPr.version; + if (title !== undefined) requestBody.title = title; + if (description !== undefined) requestBody.description = description; + if (destination_branch !== undefined) { + requestBody.toRef = { + id: `refs/heads/${destination_branch}`, + repository: { + slug: repository, + project: { + key: workspace + } + } + }; + } + if (reviewers !== undefined) { + requestBody.reviewers = reviewers.map(r => ({ user: { name: r } })); + } + } else { + // Bitbucket Cloud API + apiPath = `/repositories/${workspace}/${repository}/pullrequests/${pull_request_id}`; + + if (title !== undefined) requestBody.title = title; + if (description !== undefined) requestBody.description = description; + if (destination_branch !== undefined) { + requestBody.destination = { + branch: { + name: destination_branch + } + }; + } + if (reviewers !== undefined) { + requestBody.reviewers = reviewers.map(r => ({ username: r })); + } + } + + const pr = await this.makeApiRequest('put', apiPath, requestBody); + + const formattedResponse = this.isServer + ? this.formatServerResponse(pr as BitbucketServerPullRequest) + : this.formatCloudResponse(pr as BitbucketCloudPullRequest); + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + message: 'Pull request updated successfully', + pull_request: formattedResponse + }, null, 2), + }, + ], + }; + } catch (error) { + return this.handleApiError(error, `updating pull request ${pull_request_id} in ${workspace}/${repository}`); + } + } + + private async handleAddComment(args: any) { + if (!isAddCommentArgs(args)) { + throw new McpError( + ErrorCode.InvalidParams, + 'Invalid arguments for add_comment' + ); + } + + const { workspace, repository, pull_request_id, comment_text, parent_comment_id, file_path, line_number, line_type } = args; + + // Check if this is an inline comment + const isInlineComment = file_path !== undefined && line_number !== undefined; + + try { + let apiPath: string; + let requestBody: any; + + if (this.isServer) { + // Bitbucket Server API + apiPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pull_request_id}/comments`; + requestBody = { + text: comment_text + }; + + if (parent_comment_id !== undefined) { + requestBody.parent = { id: parent_comment_id }; + } + + // Add inline comment properties for Bitbucket Server + if (isInlineComment) { + // For inline comments, we need to specify the anchor + requestBody.anchor = { + line: line_number, + lineType: line_type || 'CONTEXT', + fileType: line_type === 'REMOVED' ? 'FROM' : 'TO', // FROM for removed lines, TO for added/context + path: file_path, + diffType: 'EFFECTIVE' // Required for Bitbucket Server + }; + } + } else { + // Bitbucket Cloud API + apiPath = `/repositories/${workspace}/${repository}/pullrequests/${pull_request_id}/comments`; + requestBody = { + content: { + raw: comment_text + } + }; + + if (parent_comment_id !== undefined) { + requestBody.parent = { id: parent_comment_id }; + } + + // Add inline comment properties for Bitbucket Cloud + if (isInlineComment) { + requestBody.inline = { + to: line_number, + path: file_path + }; + } + } + + const comment = await this.makeApiRequest('post', apiPath, requestBody); + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + message: isInlineComment ? 'Inline comment added successfully' : 'Comment added successfully', + comment: { + id: comment.id, + text: this.isServer ? comment.text : comment.content.raw, + author: this.isServer ? comment.author.displayName : comment.user.display_name, + created_on: this.isServer ? new Date(comment.createdDate).toLocaleString() : comment.created_on, + file_path: isInlineComment ? file_path : undefined, + line_number: isInlineComment ? line_number : undefined, + line_type: isInlineComment ? (line_type || 'CONTEXT') : undefined + } + }, null, 2), + }, + ], + }; + } catch (error) { + return this.handleApiError(error, `adding ${isInlineComment ? 'inline ' : ''}comment to pull request ${pull_request_id} in ${workspace}/${repository}`); + } + } + + private async handleMergePullRequest(args: any) { + if (!isMergePullRequestArgs(args)) { + throw new McpError( + ErrorCode.InvalidParams, + 'Invalid arguments for merge_pull_request' + ); + } + + const { workspace, repository, pull_request_id, merge_strategy, close_source_branch, commit_message } = args; + + try { + let apiPath: string; + let requestBody: any = {}; + + if (this.isServer) { + // Bitbucket Server API + apiPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pull_request_id}/merge`; + + // Get current PR version + const prPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pull_request_id}`; + const currentPr = await this.makeApiRequest('get', prPath); + + requestBody.version = currentPr.version; + if (commit_message) { + requestBody.message = commit_message; + } + } else { + // Bitbucket Cloud API + apiPath = `/repositories/${workspace}/${repository}/pullrequests/${pull_request_id}/merge`; + + if (merge_strategy) { + requestBody.merge_strategy = merge_strategy; + } + if (close_source_branch !== undefined) { + requestBody.close_source_branch = close_source_branch; + } + if (commit_message) { + requestBody.message = commit_message; + } + } + + const result = await this.makeApiRequest('post', apiPath, requestBody); + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + message: 'Pull request merged successfully', + merge_commit: this.isServer ? result.properties?.mergeCommit : result.merge_commit?.hash, + pull_request_id + }, null, 2), + }, + ], + }; + } catch (error) { + return this.handleApiError(error, `merging pull request ${pull_request_id} in ${workspace}/${repository}`); + } + } + + private async handleListBranches(args: any) { + if (!isListBranchesArgs(args)) { + throw new McpError( + ErrorCode.InvalidParams, + 'Invalid arguments for list_branches' + ); + } + + const { workspace, repository, filter, limit = 25, start = 0 } = args; + + try { + let apiPath: string; + let params: any = {}; + + if (this.isServer) { + // Bitbucket Server API + apiPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/branches`; + params = { + limit, + start, + }; + if (filter) { + params.filterText = filter; + } + } else { + // Bitbucket Cloud API + apiPath = `/repositories/${workspace}/${repository}/refs/branches`; + params = { + pagelen: limit, + page: Math.floor(start / limit) + 1, + }; + if (filter) { + params.q = `name ~ "${filter}"`; + } + } + + const response = await this.makeApiRequest('get', apiPath, null, { params }); + const data = response; + + // Format the response + let branches: any[] = []; + let totalCount = 0; + let nextPageStart = null; + + if (this.isServer) { + // Bitbucket Server response + branches = (data.values || []).map((branch: any) => ({ + name: branch.displayId, + id: branch.id, + latest_commit: branch.latestCommit, + is_default: branch.isDefault || false + })); + totalCount = data.size || 0; + if (!data.isLastPage && data.nextPageStart !== undefined) { + nextPageStart = data.nextPageStart; + } + } else { + // Bitbucket Cloud response + branches = (data.values || []).map((branch: any) => ({ + name: branch.name, + target: branch.target.hash, + is_default: branch.name === 'main' || branch.name === 'master' + })); + totalCount = data.size || 0; + if (data.next) { + nextPageStart = start + limit; + } + } + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + branches, + total_count: totalCount, + start, + limit, + has_more: nextPageStart !== null, + next_start: nextPageStart, + }, null, 2), + }, + ], + }; + } catch (error) { + return this.handleApiError(error, `listing branches in ${workspace}/${repository}`); + } + } + + private async handleDeleteBranch(args: any) { + if (!isDeleteBranchArgs(args)) { + throw new McpError( + ErrorCode.InvalidParams, + 'Invalid arguments for delete_branch' + ); + } + + const { workspace, repository, branch_name, force } = args; + + try { + let apiPath: string; + + if (this.isServer) { + // First, we need to get the branch details to find the latest commit + const branchesPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/branches`; + const branchesResponse = await this.makeApiRequest('get', branchesPath, null, { + params: { + filterText: branch_name, + limit: 100 + } + }); + + // Find the exact branch + const branch = branchesResponse.values?.find((b: any) => b.displayId === branch_name); + if (!branch) { + throw new Error(`Branch '${branch_name}' not found`); + } + + // Now delete using branch-utils endpoint with correct format + apiPath = `/rest/branch-utils/latest/projects/${workspace}/repos/${repository}/branches`; + + try { + await this.makeApiRequest('delete', apiPath, { + name: branch_name, + endPoint: branch.latestCommit + }); + } catch (deleteError: any) { + // If the error is about empty response but status is 204 (No Content), it's successful + if (deleteError.originalError?.response?.status === 204 || + deleteError.message?.includes('No content to map')) { + // Branch was deleted successfully + } else { + throw deleteError; + } + } + } else { + // Bitbucket Cloud API + apiPath = `/repositories/${workspace}/${repository}/refs/branches/${branch_name}`; + try { + await this.makeApiRequest('delete', apiPath); + } catch (deleteError: any) { + // If the error is about empty response but status is 204 (No Content), it's successful + if (deleteError.originalError?.response?.status === 204 || + deleteError.message?.includes('No content to map')) { + // Branch was deleted successfully + } else { + throw deleteError; + } + } + } + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + message: `Branch '${branch_name}' deleted successfully`, + branch: branch_name, + repository: `${workspace}/${repository}` + }, null, 2), + }, + ], + }; + } catch (error) { + return this.handleApiError(error, `deleting branch '${branch_name}' in ${workspace}/${repository}`); + } + } + + // Phase 2: Code Review Tools Implementation + + private async handleGetPullRequestDiff(args: any) { + if (!isGetPullRequestDiffArgs(args)) { + throw new McpError( + ErrorCode.InvalidParams, + 'Invalid arguments for get_pull_request_diff' + ); + } + + const { workspace, repository, pull_request_id, context_lines = 3 } = args; + + try { + let apiPath: string; + let config: any = {}; + + if (this.isServer) { + // Bitbucket Server API + apiPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pull_request_id}/diff`; + config.params = { contextLines: context_lines }; + } else { + // Bitbucket Cloud API + apiPath = `/repositories/${workspace}/${repository}/pullrequests/${pull_request_id}/diff`; + config.params = { context: context_lines }; + } + + // For diff, we want the raw text response + config.headers = { 'Accept': 'text/plain' }; + + const diff = await this.makeApiRequest('get', apiPath, null, config); + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + message: 'Pull request diff retrieved successfully', + pull_request_id, + diff: diff + }, null, 2), + }, + ], + }; + } catch (error) { + return this.handleApiError(error, `getting diff for pull request ${pull_request_id} in ${workspace}/${repository}`); + } + } + + private async handleApprovePullRequest(args: any) { + if (!isApprovePullRequestArgs(args)) { + throw new McpError( + ErrorCode.InvalidParams, + 'Invalid arguments for approve_pull_request' + ); + } + + const { workspace, repository, pull_request_id } = args; + + try { + let apiPath: string; + + if (this.isServer) { + // Bitbucket Server API - use participants endpoint + // Convert email format: @ to _ for the API + const username = BITBUCKET_USERNAME!.replace('@', '_'); + apiPath = `/rest/api/latest/projects/${workspace}/repos/${repository}/pull-requests/${pull_request_id}/participants/${username}`; + await this.makeApiRequest('put', apiPath, { status: 'APPROVED' }); + } else { + // Bitbucket Cloud API + apiPath = `/repositories/${workspace}/${repository}/pullrequests/${pull_request_id}/approve`; + await this.makeApiRequest('post', apiPath); + } + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + message: 'Pull request approved successfully', + pull_request_id, + approved_by: BITBUCKET_USERNAME + }, null, 2), + }, + ], + }; + } catch (error) { + return this.handleApiError(error, `approving pull request ${pull_request_id} in ${workspace}/${repository}`); + } + } + + private async handleUnapprovePullRequest(args: any) { + if (!isApprovePullRequestArgs(args)) { + throw new McpError( + ErrorCode.InvalidParams, + 'Invalid arguments for unapprove_pull_request' + ); + } + + const { workspace, repository, pull_request_id } = args; + + try { + let apiPath: string; + + if (this.isServer) { + // Bitbucket Server API - use participants endpoint + const username = BITBUCKET_USERNAME!.replace('@', '_'); + apiPath = `/rest/api/latest/projects/${workspace}/repos/${repository}/pull-requests/${pull_request_id}/participants/${username}`; + await this.makeApiRequest('put', apiPath, { status: 'UNAPPROVED' }); + } else { + // Bitbucket Cloud API + apiPath = `/repositories/${workspace}/${repository}/pullrequests/${pull_request_id}/approve`; + await this.makeApiRequest('delete', apiPath); + } + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + message: 'Pull request approval removed successfully', + pull_request_id, + unapproved_by: BITBUCKET_USERNAME + }, null, 2), + }, + ], + }; + } catch (error) { + return this.handleApiError(error, `removing approval from pull request ${pull_request_id} in ${workspace}/${repository}`); + } + } + + private async handleRequestChanges(args: any) { + if (!isRequestChangesArgs(args)) { + throw new McpError( + ErrorCode.InvalidParams, + 'Invalid arguments for request_changes' + ); + } + + const { workspace, repository, pull_request_id, comment } = args; + + try { + if (this.isServer) { + // Bitbucket Server API - use needs-work status + const username = BITBUCKET_USERNAME!.replace('@', '_'); + const apiPath = `/rest/api/latest/projects/${workspace}/repos/${repository}/pull-requests/${pull_request_id}/participants/${username}`; + await this.makeApiRequest('put', apiPath, { status: 'NEEDS_WORK' }); + + // Add comment if provided + if (comment) { + await this.handleAddComment({ + workspace, + repository, + pull_request_id, + comment_text: comment + }); + } + } else { + // Bitbucket Cloud API - use request-changes status + const apiPath = `/repositories/${workspace}/${repository}/pullrequests/${pull_request_id}/request-changes`; + await this.makeApiRequest('post', apiPath); + + // Add comment if provided + if (comment) { + await this.handleAddComment({ + workspace, + repository, + pull_request_id, + comment_text: comment + }); + } + } + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + message: 'Changes requested on pull request', + pull_request_id, + requested_by: BITBUCKET_USERNAME, + comment: comment || 'No comment provided' + }, null, 2), + }, + ], + }; + } catch (error) { + return this.handleApiError(error, `requesting changes on pull request ${pull_request_id} in ${workspace}/${repository}`); + } + } + + private async handleRemoveRequestedChanges(args: any) { + if (!isApprovePullRequestArgs(args)) { + throw new McpError( + ErrorCode.InvalidParams, + 'Invalid arguments for remove_requested_changes' + ); + } + + const { workspace, repository, pull_request_id } = args; + + try { + if (this.isServer) { + // Bitbucket Server API - remove needs-work status + const username = BITBUCKET_USERNAME!.replace('@', '_'); + const apiPath = `/rest/api/latest/projects/${workspace}/repos/${repository}/pull-requests/${pull_request_id}/participants/${username}`; + await this.makeApiRequest('put', apiPath, { status: 'UNAPPROVED' }); + } else { + // Bitbucket Cloud API + const apiPath = `/repositories/${workspace}/${repository}/pullrequests/${pull_request_id}/request-changes`; + await this.makeApiRequest('delete', apiPath); + } + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + message: 'Change request removed from pull request', + pull_request_id, + removed_by: BITBUCKET_USERNAME + }, null, 2), + }, + ], + }; + } catch (error) { + return this.handleApiError(error, `removing change request from pull request ${pull_request_id} in ${workspace}/${repository}`); + } + } + async run() { const transport = new StdioServerTransport(); await this.server.connect(transport);