This is page 2 of 2. Use http://codebase.md/pdogra1299/bitbucket-mcp-server?page={x} to view the full context. # Directory Structure ``` ├── .gitignore ├── .npmignore ├── CHANGELOG.md ├── LICENSE ├── memory-bank │ ├── .clinerules │ ├── activeContext.yml │ ├── productContext.yml │ ├── progress.yml │ ├── projectbrief.yml │ ├── systemPatterns.yml │ └── techContext.yml ├── package-lock.json ├── package.json ├── README.md ├── scripts │ └── setup-auth.js ├── SETUP_GUIDE_SERVER.md ├── SETUP_GUIDE.md ├── src │ ├── handlers │ │ ├── branch-handlers.ts │ │ ├── file-handlers.ts │ │ ├── project-handlers.ts │ │ ├── pull-request-handlers.ts │ │ ├── review-handlers.ts │ │ └── search-handlers.ts │ ├── index.ts │ ├── tools │ │ └── definitions.ts │ ├── types │ │ ├── bitbucket.ts │ │ └── guards.ts │ └── utils │ ├── api-client.ts │ ├── diff-parser.ts │ ├── formatters.ts │ └── suggestion-formatter.ts └── tsconfig.json ``` # Files -------------------------------------------------------------------------------- /src/tools/definitions.ts: -------------------------------------------------------------------------------- ```typescript export const toolDefinitions = [ { name: 'get_pull_request', description: 'Get details of a Bitbucket pull request including merge commit information', 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: 'list_pull_requests', description: 'List pull requests for a repository with optional filters', 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")', }, state: { type: 'string', description: 'Filter by PR state: OPEN, MERGED, DECLINED, ALL (default: OPEN)', enum: ['OPEN', 'MERGED', 'DECLINED', 'ALL'], }, author: { type: 'string', description: 'Filter by author username', }, limit: { type: 'number', description: 'Maximum number of PRs to return (default: 25)', }, start: { type: 'number', description: 'Start index for pagination (default: 0)', }, }, required: ['workspace', 'repository'], }, }, { 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. When updating without specifying reviewers, existing reviewers and their approval status will be preserved.', 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. If provided, replaces the reviewer list (preserving approval status for existing reviewers). If omitted, existing reviewers are preserved. (optional)', }, }, required: ['workspace', 'repository', 'pull_request_id'], }, }, { name: 'add_comment', description: 'Add a comment to a pull request. Supports: 1) General PR comments, 2) Replies to existing comments, 3) Inline comments on specific code lines (using line_number OR code_snippet), 4) Code suggestions for single or multi-line replacements. For inline comments, you can either provide exact line_number or use code_snippet to auto-detect the line.', 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: 'The main comment text. For suggestions, this is the explanation before the code suggestion.', }, parent_comment_id: { type: 'number', description: 'ID of comment to reply to. Use this to create threaded conversations (optional)', }, file_path: { type: 'string', description: 'File path for inline comment. Required for inline comments. Example: "src/components/Button.js" (optional)', }, line_number: { type: 'number', description: 'Exact line number in the file. Use this OR code_snippet, not both. Required with file_path unless using code_snippet (optional)', }, line_type: { type: 'string', description: 'Type of line: ADDED (green/new lines), REMOVED (red/deleted lines), or CONTEXT (unchanged lines). Default: CONTEXT', enum: ['ADDED', 'REMOVED', 'CONTEXT'], }, suggestion: { type: 'string', description: 'Replacement code for a suggestion. Creates a suggestion block that can be applied in Bitbucket UI. Requires file_path and line_number. For multi-line, include newlines in the string (optional)', }, suggestion_end_line: { type: 'number', description: 'For multi-line suggestions: the last line number to replace. If not provided, only replaces the single line at line_number (optional)', }, code_snippet: { type: 'string', description: 'Exact code text from the diff to find and comment on. Use this instead of line_number for auto-detection. Must match exactly including whitespace (optional)', }, search_context: { type: 'object', properties: { before: { type: 'array', items: { type: 'string' }, description: 'Array of code lines that appear BEFORE the target line. Helps disambiguate when code_snippet appears multiple times', }, after: { type: 'array', items: { type: 'string' }, description: 'Array of code lines that appear AFTER the target line. Helps disambiguate when code_snippet appears multiple times', }, }, description: 'Additional context lines to help locate the exact position when using code_snippet. Useful when the same code appears multiple times (optional)', }, match_strategy: { type: 'string', enum: ['strict', 'best'], description: 'How to handle multiple matches when using code_snippet. "strict": fail with detailed error showing all matches. "best": automatically pick the highest confidence match. Default: "strict"', }, }, 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'], }, }, { name: 'get_pull_request_diff', description: 'Get the diff/changes for a pull request with optional filtering', 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)', }, include_patterns: { type: 'array', items: { type: 'string' }, description: 'Array of glob patterns to include (e.g., ["*.res", "src/**/*.js"]) (optional)', }, exclude_patterns: { type: 'array', items: { type: 'string' }, description: 'Array of glob patterns to exclude (e.g., ["*.lock", "*.svg"]) (optional)', }, file_path: { type: 'string', description: 'Specific file path to get diff for (e.g., "src/index.ts") (optional)', }, }, 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'], }, }, { name: 'get_branch', description: 'Get detailed information about a branch including associated pull requests', 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 get details for', }, include_merged_prs: { type: 'boolean', description: 'Include merged PRs from this branch (default: false)', }, }, required: ['workspace', 'repository', 'branch_name'], }, }, { name: 'list_directory_content', description: 'List files and directories in a repository path', 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")', }, path: { type: 'string', description: 'Directory path (optional, defaults to root, e.g., "src/components")', }, branch: { type: 'string', description: 'Branch name (optional, defaults to default branch)', }, }, required: ['workspace', 'repository'], }, }, { name: 'get_file_content', description: 'Get file content from a repository with smart truncation for large files', 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")', }, file_path: { type: 'string', description: 'Path to the file (e.g., "src/index.ts")', }, branch: { type: 'string', description: 'Branch name (optional, defaults to default branch)', }, start_line: { type: 'number', description: 'Starting line number (1-based). Use negative for lines from end (optional)', }, line_count: { type: 'number', description: 'Number of lines to return (optional, default varies by file size)', }, full_content: { type: 'boolean', description: 'Force return full content regardless of size (optional, default: false)', }, }, required: ['workspace', 'repository', 'file_path'], }, }, { name: 'list_branch_commits', description: 'List commits in a branch with detailed information and filtering options', 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 get commits from', }, limit: { type: 'number', description: 'Maximum number of commits to return (default: 25)', }, start: { type: 'number', description: 'Start index for pagination (default: 0)', }, since: { type: 'string', description: 'ISO date string - only show commits after this date (optional)', }, until: { type: 'string', description: 'ISO date string - only show commits before this date (optional)', }, author: { type: 'string', description: 'Filter by author email/username (optional)', }, include_merge_commits: { type: 'boolean', description: 'Include merge commits in results (default: true)', }, search: { type: 'string', description: 'Search for text in commit messages (optional)', }, include_build_status: { type: 'boolean', description: 'Include CI/CD build status for each commit (Bitbucket Server only, default: false)', }, }, required: ['workspace', 'repository', 'branch_name'], }, }, { name: 'list_pr_commits', description: 'List all commits that are part of 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', }, limit: { type: 'number', description: 'Maximum number of commits to return (default: 25)', }, start: { type: 'number', description: 'Start index for pagination (default: 0)', }, include_build_status: { type: 'boolean', description: 'Include CI/CD build status for each commit (Bitbucket Server only, default: false)', }, }, required: ['workspace', 'repository', 'pull_request_id'], }, }, { name: 'search_code', description: 'Search for code across Bitbucket repositories with enhanced context-aware search patterns (currently only supported for Bitbucket Server)', inputSchema: { type: 'object', properties: { workspace: { type: 'string', description: 'Bitbucket workspace/project key (e.g., "PROJ")', }, repository: { type: 'string', description: 'Repository slug to search in (optional, searches all repos if not specified)', }, search_query: { type: 'string', description: 'The search term or phrase to look for in code (e.g., "variable")', }, search_context: { type: 'string', enum: ['assignment', 'declaration', 'usage', 'exact', 'any'], description: 'Context to search for: assignment (term=value), declaration (defining term), usage (calling/accessing term), exact (quoted match), or any (all patterns)', }, file_pattern: { type: 'string', description: 'File path pattern to filter results (e.g., "*.java", "src/**/*.ts") (optional)', }, include_patterns: { type: 'array', items: { type: 'string' }, description: 'Additional custom search patterns to include (e.g., ["variable =", ".variable"]) (optional)', }, limit: { type: 'number', description: 'Maximum number of results to return (default: 25)', }, start: { type: 'number', description: 'Start index for pagination (default: 0)', }, }, required: ['workspace', 'search_query'], }, }, { name: 'list_projects', description: 'List all accessible Bitbucket projects with optional filtering', inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'Filter by project name (partial match, optional)', }, permission: { type: 'string', description: 'Filter by permission level (e.g., PROJECT_READ, PROJECT_WRITE, PROJECT_ADMIN, optional)', }, limit: { type: 'number', description: 'Maximum number of projects to return (default: 25)', }, start: { type: 'number', description: 'Start index for pagination (default: 0)', }, }, required: [], }, }, { name: 'list_repositories', description: 'List repositories in a project or across all accessible projects', inputSchema: { type: 'object', properties: { workspace: { type: 'string', description: 'Bitbucket workspace/project key to filter repositories (optional, if not provided lists all accessible repos)', }, name: { type: 'string', description: 'Filter by repository name (partial match, optional)', }, permission: { type: 'string', description: 'Filter by permission level (e.g., REPO_READ, REPO_WRITE, REPO_ADMIN, optional)', }, limit: { type: 'number', description: 'Maximum number of repositories to return (default: 25)', }, start: { type: 'number', description: 'Start index for pagination (default: 0)', }, }, required: [], }, }, ]; ``` -------------------------------------------------------------------------------- /src/handlers/pull-request-handlers.ts: -------------------------------------------------------------------------------- ```typescript import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; import { BitbucketApiClient } from '../utils/api-client.js'; import { formatServerResponse, formatCloudResponse, formatServerCommit, formatCloudCommit } from '../utils/formatters.js'; import { formatSuggestionComment } from '../utils/suggestion-formatter.js'; import { DiffParser } from '../utils/diff-parser.js'; import { BitbucketServerPullRequest, BitbucketCloudPullRequest, BitbucketServerActivity, MergeInfo, BitbucketCloudComment, BitbucketCloudFileChange, FormattedComment, FormattedFileChange, CodeMatch, MultipleMatchesError, BitbucketServerCommit, BitbucketCloudCommit, FormattedCommit } from '../types/bitbucket.js'; import { isGetPullRequestArgs, isListPullRequestsArgs, isCreatePullRequestArgs, isUpdatePullRequestArgs, isAddCommentArgs, isMergePullRequestArgs, isListPrCommitsArgs } from '../types/guards.js'; export class PullRequestHandlers { constructor( private apiClient: BitbucketApiClient, private baseUrl: string, private username: string ) {} private async getFilteredPullRequestDiff( workspace: string, repository: string, pullRequestId: number, filePath: string, contextLines: number = 3 ): Promise<string> { let apiPath: string; let config: any = {}; if (this.apiClient.getIsServer()) { // Bitbucket Server API apiPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pullRequestId}/diff`; config.params = { contextLines }; } else { // Bitbucket Cloud API apiPath = `/repositories/${workspace}/${repository}/pullrequests/${pullRequestId}/diff`; config.params = { context: contextLines }; } config.headers = { 'Accept': 'text/plain' }; const rawDiff = await this.apiClient.makeRequest<string>('get', apiPath, undefined, config); const diffParser = new DiffParser(); const sections = diffParser.parseDiffIntoSections(rawDiff); const filterOptions = { filePath: filePath }; const filteredResult = diffParser.filterSections(sections, filterOptions); const filteredDiff = diffParser.reconstructDiff(filteredResult.sections); return filteredDiff; } async handleGetPullRequest(args: any) { if (!isGetPullRequestArgs(args)) { throw new McpError( ErrorCode.InvalidParams, 'Invalid arguments for get_pull_request' ); } const { workspace, repository, pull_request_id } = args; try { const apiPath = this.apiClient.getIsServer() ? `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pull_request_id}` : `/repositories/${workspace}/${repository}/pullrequests/${pull_request_id}`; const pr = await this.apiClient.makeRequest<any>('get', apiPath); let mergeInfo: MergeInfo = {}; if (this.apiClient.getIsServer() && pr.state === 'MERGED') { try { const activitiesPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pull_request_id}/activities`; const activitiesResponse = await this.apiClient.makeRequest<any>('get', activitiesPath, undefined, { params: { limit: 100 } }); const activities = activitiesResponse.values || []; const mergeActivity = activities.find((a: BitbucketServerActivity) => a.action === 'MERGED'); if (mergeActivity) { mergeInfo.mergeCommitHash = mergeActivity.commit?.id || null; mergeInfo.mergedBy = mergeActivity.user?.displayName || null; mergeInfo.mergedAt = new Date(mergeActivity.createdDate).toISOString(); if (mergeActivity.commit?.id) { try { const commitPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/commits/${mergeActivity.commit.id}`; const commitResponse = await this.apiClient.makeRequest<any>('get', commitPath); mergeInfo.mergeCommitMessage = commitResponse.message || null; } catch (commitError) { console.error('Failed to fetch merge commit message:', commitError); } } } } catch (activitiesError) { console.error('Failed to fetch PR activities:', activitiesError); } } let comments: FormattedComment[] = []; let activeCommentCount = 0; let totalCommentCount = 0; let fileChanges: FormattedFileChange[] = []; let fileChangesSummary: any = null; try { const [commentsResult, fileChangesResult] = await Promise.all([ this.fetchPullRequestComments(workspace, repository, pull_request_id), this.fetchPullRequestFileChanges(workspace, repository, pull_request_id) ]); comments = commentsResult.comments; activeCommentCount = commentsResult.activeCount; totalCommentCount = commentsResult.totalCount; fileChanges = fileChangesResult.fileChanges; fileChangesSummary = fileChangesResult.summary; } catch (error) { console.error('Failed to fetch additional PR data:', error); } const formattedResponse = this.apiClient.getIsServer() ? formatServerResponse(pr as BitbucketServerPullRequest, mergeInfo, this.baseUrl) : formatCloudResponse(pr as BitbucketCloudPullRequest); const enhancedResponse = { ...formattedResponse, active_comments: comments, active_comment_count: activeCommentCount, total_comment_count: totalCommentCount, file_changes: fileChanges, file_changes_summary: fileChangesSummary }; return { content: [ { type: 'text', text: JSON.stringify(enhancedResponse, null, 2), }, ], }; } catch (error) { return this.apiClient.handleApiError(error, `getting pull request ${pull_request_id} in ${workspace}/${repository}`); } } async handleListPullRequests(args: any) { if (!isListPullRequestsArgs(args)) { throw new McpError( ErrorCode.InvalidParams, 'Invalid arguments for list_pull_requests' ); } const { workspace, repository, state = 'OPEN', author, limit = 25, start = 0 } = args; try { let apiPath: string; let params: any = {}; if (this.apiClient.getIsServer()) { // Bitbucket Server API apiPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests`; params = { state: state === 'ALL' ? undefined : state, limit, start, }; if (author) { params['role.1'] = 'AUTHOR'; params['username.1'] = author; } } else { // Bitbucket Cloud API apiPath = `/repositories/${workspace}/${repository}/pullrequests`; params = { state: state === 'ALL' ? undefined : state, pagelen: limit, page: Math.floor(start / limit) + 1, }; if (author) { params['q'] = `author.username="${author}"`; } } const response = await this.apiClient.makeRequest<any>('get', apiPath, undefined, { params }); let pullRequests: any[] = []; let totalCount = 0; let nextPageStart = null; if (this.apiClient.getIsServer()) { pullRequests = (response.values || []).map((pr: BitbucketServerPullRequest) => formatServerResponse(pr, undefined, this.baseUrl) ); totalCount = response.size || 0; if (!response.isLastPage && response.nextPageStart !== undefined) { nextPageStart = response.nextPageStart; } } else { pullRequests = (response.values || []).map((pr: BitbucketCloudPullRequest) => formatCloudResponse(pr) ); totalCount = response.size || 0; if (response.next) { nextPageStart = start + limit; } } return { content: [ { type: 'text', text: JSON.stringify({ pull_requests: pullRequests, total_count: totalCount, start, limit, has_more: nextPageStart !== null, next_start: nextPageStart, }, null, 2), }, ], }; } catch (error) { return this.apiClient.handleApiError(error, `listing pull requests in ${workspace}/${repository}`); } } 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.apiClient.getIsServer()) { // 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.apiClient.makeRequest<any>('post', apiPath, requestBody); const formattedResponse = this.apiClient.getIsServer() ? formatServerResponse(pr as BitbucketServerPullRequest, undefined, this.baseUrl) : 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.apiClient.handleApiError(error, `creating pull request in ${workspace}/${repository}`); } } 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.apiClient.getIsServer()) { // 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 and existing data const currentPr = await this.apiClient.makeRequest<any>('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 } } }; } // Handle reviewers: preserve existing ones if not explicitly updating if (reviewers !== undefined) { // User wants to update reviewers // Create a map of existing reviewers for preservation of approval status const existingReviewersMap = new Map( currentPr.reviewers.map((r: any) => [r.user.name, r]) ); requestBody.reviewers = reviewers.map(username => { const existing = existingReviewersMap.get(username); if (existing) { // Preserve existing reviewer's full data including approval status return existing; } else { // Add new reviewer (without approval status) return { user: { name: username } }; } }); } else { // No reviewers provided - preserve existing reviewers with their full data requestBody.reviewers = currentPr.reviewers; } } 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.apiClient.makeRequest<any>('put', apiPath, requestBody); const formattedResponse = this.apiClient.getIsServer() ? formatServerResponse(pr as BitbucketServerPullRequest, undefined, this.baseUrl) : 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.apiClient.handleApiError(error, `updating pull request ${pull_request_id} in ${workspace}/${repository}`); } } async handleAddComment(args: any) { if (!isAddCommentArgs(args)) { throw new McpError( ErrorCode.InvalidParams, 'Invalid arguments for add_comment' ); } let { workspace, repository, pull_request_id, comment_text, parent_comment_id, file_path, line_number, line_type, suggestion, suggestion_end_line, code_snippet, search_context, match_strategy = 'strict' } = args; let sequentialPosition: number | undefined; if (code_snippet && !line_number && file_path) { try { const resolved = await this.resolveLineFromCode( workspace, repository, pull_request_id, file_path, code_snippet, search_context, match_strategy ); line_number = resolved.line_number; line_type = resolved.line_type; sequentialPosition = resolved.sequential_position; } catch (error) { throw error; } } if (suggestion && (!file_path || !line_number)) { throw new McpError( ErrorCode.InvalidParams, 'Suggestions require file_path and line_number to be specified' ); } const isInlineComment = file_path !== undefined && line_number !== undefined; let finalCommentText = comment_text; if (suggestion) { finalCommentText = formatSuggestionComment( comment_text, suggestion, line_number, suggestion_end_line || line_number ); } try { let apiPath: string; let requestBody: any; if (this.apiClient.getIsServer()) { // Bitbucket Server API apiPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pull_request_id}/comments`; requestBody = { text: finalCommentText }; if (parent_comment_id !== undefined) { requestBody.parent = { id: parent_comment_id }; } if (isInlineComment) { requestBody.anchor = { line: line_number, lineType: line_type || 'CONTEXT', fileType: line_type === 'REMOVED' ? 'FROM' : 'TO', path: file_path, diffType: 'EFFECTIVE' }; } } else { // Bitbucket Cloud API apiPath = `/repositories/${workspace}/${repository}/pullrequests/${pull_request_id}/comments`; requestBody = { content: { raw: finalCommentText } }; if (parent_comment_id !== undefined) { requestBody.parent = { id: parent_comment_id }; } if (isInlineComment) { requestBody.inline = { to: line_number, path: file_path }; } } const comment = await this.apiClient.makeRequest<any>('post', apiPath, requestBody); const responseMessage = suggestion ? 'Comment with code suggestion added successfully' : (isInlineComment ? 'Inline comment added successfully' : 'Comment added successfully'); return { content: [ { type: 'text', text: JSON.stringify({ message: responseMessage, comment: { id: comment.id, text: this.apiClient.getIsServer() ? comment.text : comment.content.raw, author: this.apiClient.getIsServer() ? comment.author.displayName : comment.user.display_name, created_on: this.apiClient.getIsServer() ? 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, has_suggestion: !!suggestion, suggestion_lines: suggestion ? (suggestion_end_line ? `${line_number}-${suggestion_end_line}` : `${line_number}`) : undefined } }, null, 2), }, ], }; } catch (error) { return this.apiClient.handleApiError(error, `adding ${isInlineComment ? 'inline ' : ''}comment to pull request ${pull_request_id} in ${workspace}/${repository}`); } } 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.apiClient.getIsServer()) { // 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.apiClient.makeRequest<any>('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.apiClient.makeRequest<any>('post', apiPath, requestBody); return { content: [ { type: 'text', text: JSON.stringify({ message: 'Pull request merged successfully', merge_commit: this.apiClient.getIsServer() ? result.properties?.mergeCommit : result.merge_commit?.hash, pull_request_id }, null, 2), }, ], }; } catch (error) { return this.apiClient.handleApiError(error, `merging pull request ${pull_request_id} in ${workspace}/${repository}`); } } private async fetchPullRequestComments( workspace: string, repository: string, pullRequestId: number ): Promise<{ comments: FormattedComment[]; activeCount: number; totalCount: number }> { try { let comments: FormattedComment[] = []; let activeCount = 0; let totalCount = 0; if (this.apiClient.getIsServer()) { const processNestedComments = (comment: any, anchor: any): FormattedComment => { const formattedComment: FormattedComment = { id: comment.id, author: comment.author.displayName, text: comment.text, created_on: new Date(comment.createdDate).toISOString(), is_inline: !!anchor, file_path: anchor?.path, line_number: anchor?.line, state: comment.state }; if (comment.comments && comment.comments.length > 0) { formattedComment.replies = comment.comments .filter((reply: any) => { if (reply.state === 'RESOLVED') return false; if (anchor && anchor.orphaned === true) return false; return true; }) .map((reply: any) => processNestedComments(reply, anchor)); } return formattedComment; }; const countAllComments = (comment: any): number => { let count = 1; if (comment.comments && comment.comments.length > 0) { count += comment.comments.reduce((sum: number, reply: any) => sum + countAllComments(reply), 0); } return count; }; const countActiveComments = (comment: any, anchor: any): number => { let count = 0; if (comment.state !== 'RESOLVED' && (!anchor || anchor.orphaned !== true)) { count = 1; } if (comment.comments && comment.comments.length > 0) { count += comment.comments.reduce((sum: number, reply: any) => sum + countActiveComments(reply, anchor), 0); } return count; }; const apiPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pullRequestId}/activities`; const response = await this.apiClient.makeRequest<any>('get', apiPath, undefined, { params: { limit: 1000 } }); const activities = response.values || []; const commentActivities = activities.filter((a: any) => a.action === 'COMMENTED' && a.comment ); totalCount = commentActivities.reduce((sum: number, activity: any) => { return sum + countAllComments(activity.comment); }, 0); activeCount = commentActivities.reduce((sum: number, activity: any) => { return sum + countActiveComments(activity.comment, activity.commentAnchor); }, 0); const processedComments = commentActivities .filter((a: any) => { const c = a.comment; const anchor = a.commentAnchor; if (c.state === 'RESOLVED') return false; if (anchor && anchor.orphaned === true) return false; return true; }) .map((a: any) => processNestedComments(a.comment, a.commentAnchor)); comments = processedComments.slice(0, 20); } else { const apiPath = `/repositories/${workspace}/${repository}/pullrequests/${pullRequestId}/comments`; const response = await this.apiClient.makeRequest<any>('get', apiPath, undefined, { params: { pagelen: 100 } }); const allComments = response.values || []; totalCount = allComments.length; const activeComments = allComments .filter((c: BitbucketCloudComment) => !c.deleted && !c.resolved) .slice(0, 20); activeCount = allComments.filter((c: BitbucketCloudComment) => !c.deleted && !c.resolved).length; comments = activeComments.map((c: BitbucketCloudComment) => ({ id: c.id, author: c.user.display_name, text: c.content.raw, created_on: c.created_on, is_inline: !!c.inline, file_path: c.inline?.path, line_number: c.inline?.to })); } return { comments, activeCount, totalCount }; } catch (error) { console.error('Failed to fetch comments:', error); return { comments: [], activeCount: 0, totalCount: 0 }; } } private async fetchPullRequestFileChanges( workspace: string, repository: string, pullRequestId: number ): Promise<{ fileChanges: FormattedFileChange[]; summary: any }> { try { let fileChanges: FormattedFileChange[] = []; let totalLinesAdded = 0; let totalLinesRemoved = 0; if (this.apiClient.getIsServer()) { const apiPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pullRequestId}/changes`; const response = await this.apiClient.makeRequest<any>('get', apiPath, undefined, { params: { limit: 1000 } }); const changes = response.values || []; fileChanges = changes.map((change: any) => { let status: 'added' | 'modified' | 'removed' | 'renamed' = 'modified'; if (change.type === 'ADD') status = 'added'; else if (change.type === 'DELETE') status = 'removed'; else if (change.type === 'MOVE' || change.type === 'RENAME') status = 'renamed'; return { path: change.path.toString, status, old_path: change.srcPath?.toString }; }); } else { const apiPath = `/repositories/${workspace}/${repository}/pullrequests/${pullRequestId}/diffstat`; const response = await this.apiClient.makeRequest<any>('get', apiPath, undefined, { params: { pagelen: 100 } }); const diffstats = response.values || []; fileChanges = diffstats.map((stat: BitbucketCloudFileChange) => { totalLinesAdded += stat.lines_added; totalLinesRemoved += stat.lines_removed; return { path: stat.path, status: stat.type, old_path: stat.old?.path }; }); } const summary = { total_files: fileChanges.length }; return { fileChanges, summary }; } catch (error) { console.error('Failed to fetch file changes:', error); return { fileChanges: [], summary: { total_files: 0 } }; } } private async resolveLineFromCode( workspace: string, repository: string, pullRequestId: number, filePath: string, codeSnippet: string, searchContext?: { before?: string[]; after?: string[] }, matchStrategy: 'strict' | 'best' = 'strict' ): Promise<{ line_number: number; line_type: 'ADDED' | 'REMOVED' | 'CONTEXT'; sequential_position?: number; hunk_info?: any; diff_context?: string; diff_content_preview?: string; calculation_details?: string; }> { try { const diffContent = await this.getFilteredPullRequestDiff(workspace, repository, pullRequestId, filePath); const parser = new DiffParser(); const sections = parser.parseDiffIntoSections(diffContent); let fileSection = sections[0]; if (!this.apiClient.getIsServer()) { fileSection = sections.find(s => s.filePath === filePath) || sections[0]; } if (!fileSection) { throw new McpError( ErrorCode.InvalidParams, `File ${filePath} not found in pull request diff` ); } const matches = this.findCodeMatches( fileSection.content, codeSnippet, searchContext ); if (matches.length === 0) { throw new McpError( ErrorCode.InvalidParams, `Code snippet not found in ${filePath}` ); } if (matches.length === 1) { return { line_number: matches[0].line_number, line_type: matches[0].line_type, sequential_position: matches[0].sequential_position, hunk_info: matches[0].hunk_info, diff_context: matches[0].preview, diff_content_preview: diffContent.split('\n').slice(0, 50).join('\n'), calculation_details: `Direct line number from diff: ${matches[0].line_number}` }; } if (matchStrategy === 'best') { const best = this.selectBestMatch(matches); return { line_number: best.line_number, line_type: best.line_type, sequential_position: best.sequential_position, hunk_info: best.hunk_info, diff_context: best.preview, diff_content_preview: diffContent.split('\n').slice(0, 50).join('\n'), calculation_details: `Best match selected from ${matches.length} matches, line: ${best.line_number}` }; } const error: MultipleMatchesError = { code: 'MULTIPLE_MATCHES_FOUND', message: `Code snippet '${codeSnippet.substring(0, 50)}...' found in ${matches.length} locations`, occurrences: matches.map(m => ({ line_number: m.line_number, file_path: filePath, preview: m.preview, confidence: m.confidence, line_type: m.line_type })), suggestion: 'To resolve, either:\n1. Add more context to uniquely identify the location\n2. Use match_strategy: \'best\' to auto-select highest confidence match\n3. Use line_number directly' }; throw new McpError( ErrorCode.InvalidParams, JSON.stringify({ error }) ); } catch (error) { if (error instanceof McpError) { throw error; } throw new McpError( ErrorCode.InternalError, `Failed to resolve line from code: ${error instanceof Error ? error.message : String(error)}` ); } } private findCodeMatches( diffContent: string, codeSnippet: string, searchContext?: { before?: string[]; after?: string[] } ): CodeMatch[] { const lines = diffContent.split('\n'); const matches: CodeMatch[] = []; let currentDestLine = 0; // Destination file line number let currentSrcLine = 0; // Source file line number let inHunk = false; let sequentialAddedCount = 0; // Track sequential ADDED lines let currentHunkIndex = -1; let currentHunkDestStart = 0; let currentHunkSrcStart = 0; let destPositionInHunk = 0; // Track position in destination file relative to hunk start let srcPositionInHunk = 0; // Track position in source file relative to hunk start for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (line.startsWith('@@')) { const match = line.match(/@@ -(\d+),\d+ \+(\d+),\d+ @@/); if (match) { currentHunkSrcStart = parseInt(match[1]); currentHunkDestStart = parseInt(match[2]); currentSrcLine = currentHunkSrcStart; currentDestLine = currentHunkDestStart; inHunk = true; currentHunkIndex++; destPositionInHunk = 0; srcPositionInHunk = 0; continue; } } if (!inHunk) continue; if (line === '') { inHunk = false; continue; } let lineType: 'ADDED' | 'REMOVED' | 'CONTEXT'; let lineContent = ''; let lineNumber = 0; if (line.startsWith('+')) { lineType = 'ADDED'; lineContent = line.substring(1); lineNumber = currentHunkDestStart + destPositionInHunk; destPositionInHunk++; sequentialAddedCount++; } else if (line.startsWith('-')) { lineType = 'REMOVED'; lineContent = line.substring(1); lineNumber = currentHunkSrcStart + srcPositionInHunk; srcPositionInHunk++; } else if (line.startsWith(' ')) { lineType = 'CONTEXT'; lineContent = line.substring(1); lineNumber = currentHunkDestStart + destPositionInHunk; destPositionInHunk++; srcPositionInHunk++; } else { inHunk = false; continue; } if (lineContent.trim() === codeSnippet.trim()) { const confidence = this.calculateConfidence( lines, i, searchContext, lineType ); matches.push({ line_number: lineNumber, line_type: lineType, exact_content: codeSnippet, preview: this.getPreview(lines, i), confidence, context: this.extractContext(lines, i), sequential_position: lineType === 'ADDED' ? sequentialAddedCount : undefined, hunk_info: { hunk_index: currentHunkIndex, destination_start: currentHunkDestStart, line_in_hunk: destPositionInHunk } }); } if (lineType === 'ADDED') { currentDestLine++; } else if (lineType === 'REMOVED') { currentSrcLine++; } else if (lineType === 'CONTEXT') { currentSrcLine++; currentDestLine++; } } return matches; } private calculateConfidence( lines: string[], index: number, searchContext?: { before?: string[]; after?: string[] }, lineType?: 'ADDED' | 'REMOVED' | 'CONTEXT' ): number { let confidence = 0.5; // Base confidence if (!searchContext) { return confidence; } if (searchContext.before) { let matchedBefore = 0; for (let j = 0; j < searchContext.before.length; j++) { const contextLine = searchContext.before[searchContext.before.length - 1 - j]; const checkIndex = index - j - 1; if (checkIndex >= 0) { const checkLine = lines[checkIndex].substring(1); if (checkLine.trim() === contextLine.trim()) { matchedBefore++; } } } confidence += (matchedBefore / searchContext.before.length) * 0.3; } if (searchContext.after) { let matchedAfter = 0; for (let j = 0; j < searchContext.after.length; j++) { const contextLine = searchContext.after[j]; const checkIndex = index + j + 1; if (checkIndex < lines.length) { const checkLine = lines[checkIndex].substring(1); if (checkLine.trim() === contextLine.trim()) { matchedAfter++; } } } confidence += (matchedAfter / searchContext.after.length) * 0.3; } if (lineType === 'ADDED') { confidence += 0.1; } return Math.min(confidence, 1.0); } private getPreview(lines: string[], index: number): string { const start = Math.max(0, index - 1); const end = Math.min(lines.length, index + 2); const previewLines = []; for (let i = start; i < end; i++) { const prefix = i === index ? '> ' : ' '; previewLines.push(prefix + lines[i]); } return previewLines.join('\n'); } private extractContext(lines: string[], index: number): { lines_before: string[]; lines_after: string[] } { const linesBefore: string[] = []; const linesAfter: string[] = []; for (let i = Math.max(0, index - 2); i < index; i++) { if (lines[i].match(/^[+\- ]/)) { linesBefore.push(lines[i].substring(1)); } } for (let i = index + 1; i < Math.min(lines.length, index + 3); i++) { if (lines[i].match(/^[+\- ]/)) { linesAfter.push(lines[i].substring(1)); } } return { lines_before: linesBefore, lines_after: linesAfter }; } private selectBestMatch(matches: CodeMatch[]): CodeMatch { return matches.sort((a, b) => b.confidence - a.confidence)[0]; } async handleListPrCommits(args: any) { if (!isListPrCommitsArgs(args)) { throw new McpError( ErrorCode.InvalidParams, 'Invalid arguments for list_pr_commits' ); } const { workspace, repository, pull_request_id, limit = 25, start = 0, include_build_status = false } = args; try { // First get the PR details to include in response const prPath = this.apiClient.getIsServer() ? `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pull_request_id}` : `/repositories/${workspace}/${repository}/pullrequests/${pull_request_id}`; let prTitle = ''; try { const pr = await this.apiClient.makeRequest<any>('get', prPath); prTitle = pr.title; } catch (e) { // Ignore error, PR title is optional } let apiPath: string; let params: any = {}; let commits: FormattedCommit[] = []; let totalCount = 0; let nextPageStart: number | null = null; if (this.apiClient.getIsServer()) { // Bitbucket Server API apiPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pull_request_id}/commits`; params = { limit, start, withCounts: true }; const response = await this.apiClient.makeRequest<any>('get', apiPath, undefined, { params }); // Format commits commits = (response.values || []).map((commit: BitbucketServerCommit) => formatServerCommit(commit)); totalCount = response.size || commits.length; if (!response.isLastPage && response.nextPageStart !== undefined) { nextPageStart = response.nextPageStart; } } else { // Bitbucket Cloud API apiPath = `/repositories/${workspace}/${repository}/pullrequests/${pull_request_id}/commits`; params = { pagelen: limit, page: Math.floor(start / limit) + 1 }; const response = await this.apiClient.makeRequest<any>('get', apiPath, undefined, { params }); // Format commits commits = (response.values || []).map((commit: BitbucketCloudCommit) => formatCloudCommit(commit)); totalCount = response.size || commits.length; if (response.next) { nextPageStart = start + limit; } } // Fetch build status if requested (Server only) if (include_build_status && this.apiClient.getIsServer() && commits.length > 0) { try { const commitIds = commits.map(c => c.hash); const buildSummaries = await this.apiClient.getBuildSummaries( workspace, repository, commitIds ); // Enhance commits with build status commits = commits.map(commit => { const buildData = buildSummaries[commit.hash]; if (buildData) { return { ...commit, build_status: { successful: buildData.successful || 0, failed: buildData.failed || 0, in_progress: buildData.inProgress || 0, unknown: buildData.unknown || 0 } }; } return commit; }); } catch (error) { console.error('Failed to fetch build status for PR commits:', error); // Graceful degradation - continue without build status } } return { content: [ { type: 'text', text: JSON.stringify({ pull_request_id, pull_request_title: prTitle, commits, total_count: totalCount, start, limit, has_more: nextPageStart !== null, next_start: nextPageStart }, null, 2), }, ], }; } catch (error) { return this.apiClient.handleApiError(error, `listing commits for pull request ${pull_request_id} in ${workspace}/${repository}`); } } } ```