This is page 2 of 2. Use http://codebase.md/pdogra1299/bitbucket-mcp-server?lines=true&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/types/guards.ts: -------------------------------------------------------------------------------- ```typescript 1 | // Type guards for tool arguments 2 | export const isGetPullRequestArgs = ( 3 | args: any 4 | ): args is { workspace: string; repository: string; pull_request_id: number } => 5 | typeof args === 'object' && 6 | args !== null && 7 | typeof args.workspace === 'string' && 8 | typeof args.repository === 'string' && 9 | typeof args.pull_request_id === 'number'; 10 | 11 | export const isListPullRequestsArgs = ( 12 | args: any 13 | ): args is { 14 | workspace: string; 15 | repository: string; 16 | state?: string; 17 | author?: string; 18 | limit?: number; 19 | start?: number; 20 | } => 21 | typeof args === 'object' && 22 | args !== null && 23 | typeof args.workspace === 'string' && 24 | typeof args.repository === 'string' && 25 | (args.state === undefined || typeof args.state === 'string') && 26 | (args.author === undefined || typeof args.author === 'string') && 27 | (args.limit === undefined || typeof args.limit === 'number') && 28 | (args.start === undefined || typeof args.start === 'number'); 29 | 30 | export const isCreatePullRequestArgs = ( 31 | args: any 32 | ): args is { 33 | workspace: string; 34 | repository: string; 35 | title: string; 36 | source_branch: string; 37 | destination_branch: string; 38 | description?: string; 39 | reviewers?: string[]; 40 | close_source_branch?: boolean; 41 | } => 42 | typeof args === 'object' && 43 | args !== null && 44 | typeof args.workspace === 'string' && 45 | typeof args.repository === 'string' && 46 | typeof args.title === 'string' && 47 | typeof args.source_branch === 'string' && 48 | typeof args.destination_branch === 'string' && 49 | (args.description === undefined || typeof args.description === 'string') && 50 | (args.reviewers === undefined || Array.isArray(args.reviewers)) && 51 | (args.close_source_branch === undefined || typeof args.close_source_branch === 'boolean'); 52 | 53 | export const isUpdatePullRequestArgs = ( 54 | args: any 55 | ): args is { 56 | workspace: string; 57 | repository: string; 58 | pull_request_id: number; 59 | title?: string; 60 | description?: string; 61 | destination_branch?: string; 62 | reviewers?: string[]; 63 | } => 64 | typeof args === 'object' && 65 | args !== null && 66 | typeof args.workspace === 'string' && 67 | typeof args.repository === 'string' && 68 | typeof args.pull_request_id === 'number' && 69 | (args.title === undefined || typeof args.title === 'string') && 70 | (args.description === undefined || typeof args.description === 'string') && 71 | (args.destination_branch === undefined || typeof args.destination_branch === 'string') && 72 | (args.reviewers === undefined || Array.isArray(args.reviewers)); 73 | 74 | export const isAddCommentArgs = ( 75 | args: any 76 | ): args is { 77 | workspace: string; 78 | repository: string; 79 | pull_request_id: number; 80 | comment_text: string; 81 | parent_comment_id?: number; 82 | file_path?: string; 83 | line_number?: number; 84 | line_type?: 'ADDED' | 'REMOVED' | 'CONTEXT'; 85 | suggestion?: string; 86 | suggestion_end_line?: number; 87 | code_snippet?: string; 88 | search_context?: { 89 | before?: string[]; 90 | after?: string[]; 91 | }; 92 | match_strategy?: 'strict' | 'best'; 93 | } => 94 | typeof args === 'object' && 95 | args !== null && 96 | typeof args.workspace === 'string' && 97 | typeof args.repository === 'string' && 98 | typeof args.pull_request_id === 'number' && 99 | typeof args.comment_text === 'string' && 100 | (args.parent_comment_id === undefined || typeof args.parent_comment_id === 'number') && 101 | (args.file_path === undefined || typeof args.file_path === 'string') && 102 | (args.line_number === undefined || typeof args.line_number === 'number') && 103 | (args.line_type === undefined || ['ADDED', 'REMOVED', 'CONTEXT'].includes(args.line_type)) && 104 | (args.suggestion === undefined || typeof args.suggestion === 'string') && 105 | (args.suggestion_end_line === undefined || typeof args.suggestion_end_line === 'number') && 106 | (args.code_snippet === undefined || typeof args.code_snippet === 'string') && 107 | (args.search_context === undefined || ( 108 | typeof args.search_context === 'object' && 109 | (args.search_context.before === undefined || Array.isArray(args.search_context.before)) && 110 | (args.search_context.after === undefined || Array.isArray(args.search_context.after)) 111 | )) && 112 | (args.match_strategy === undefined || ['strict', 'best'].includes(args.match_strategy)); 113 | 114 | export const isMergePullRequestArgs = ( 115 | args: any 116 | ): args is { 117 | workspace: string; 118 | repository: string; 119 | pull_request_id: number; 120 | merge_strategy?: string; 121 | close_source_branch?: boolean; 122 | commit_message?: string; 123 | } => 124 | typeof args === 'object' && 125 | args !== null && 126 | typeof args.workspace === 'string' && 127 | typeof args.repository === 'string' && 128 | typeof args.pull_request_id === 'number' && 129 | (args.merge_strategy === undefined || typeof args.merge_strategy === 'string') && 130 | (args.close_source_branch === undefined || typeof args.close_source_branch === 'boolean') && 131 | (args.commit_message === undefined || typeof args.commit_message === 'string'); 132 | 133 | export const isDeleteBranchArgs = ( 134 | args: any 135 | ): args is { 136 | workspace: string; 137 | repository: string; 138 | branch_name: string; 139 | force?: boolean; 140 | } => 141 | typeof args === 'object' && 142 | args !== null && 143 | typeof args.workspace === 'string' && 144 | typeof args.repository === 'string' && 145 | typeof args.branch_name === 'string' && 146 | (args.force === undefined || typeof args.force === 'boolean'); 147 | 148 | export const isListBranchesArgs = ( 149 | args: any 150 | ): args is { 151 | workspace: string; 152 | repository: string; 153 | filter?: string; 154 | limit?: number; 155 | start?: number; 156 | } => 157 | typeof args === 'object' && 158 | args !== null && 159 | typeof args.workspace === 'string' && 160 | typeof args.repository === 'string' && 161 | (args.filter === undefined || typeof args.filter === 'string') && 162 | (args.limit === undefined || typeof args.limit === 'number') && 163 | (args.start === undefined || typeof args.start === 'number'); 164 | 165 | export const isGetPullRequestDiffArgs = ( 166 | args: any 167 | ): args is { 168 | workspace: string; 169 | repository: string; 170 | pull_request_id: number; 171 | context_lines?: number; 172 | include_patterns?: string[]; 173 | exclude_patterns?: string[]; 174 | file_path?: string; 175 | } => 176 | typeof args === 'object' && 177 | args !== null && 178 | typeof args.workspace === 'string' && 179 | typeof args.repository === 'string' && 180 | typeof args.pull_request_id === 'number' && 181 | (args.context_lines === undefined || typeof args.context_lines === 'number') && 182 | (args.include_patterns === undefined || (Array.isArray(args.include_patterns) && args.include_patterns.every((p: any) => typeof p === 'string'))) && 183 | (args.exclude_patterns === undefined || (Array.isArray(args.exclude_patterns) && args.exclude_patterns.every((p: any) => typeof p === 'string'))) && 184 | (args.file_path === undefined || typeof args.file_path === 'string'); 185 | 186 | export const isApprovePullRequestArgs = ( 187 | args: any 188 | ): args is { 189 | workspace: string; 190 | repository: string; 191 | pull_request_id: number; 192 | } => 193 | typeof args === 'object' && 194 | args !== null && 195 | typeof args.workspace === 'string' && 196 | typeof args.repository === 'string' && 197 | typeof args.pull_request_id === 'number'; 198 | 199 | export const isRequestChangesArgs = ( 200 | args: any 201 | ): args is { 202 | workspace: string; 203 | repository: string; 204 | pull_request_id: number; 205 | comment?: string; 206 | } => 207 | typeof args === 'object' && 208 | args !== null && 209 | typeof args.workspace === 'string' && 210 | typeof args.repository === 'string' && 211 | typeof args.pull_request_id === 'number' && 212 | (args.comment === undefined || typeof args.comment === 'string'); 213 | 214 | export const isGetBranchArgs = ( 215 | args: any 216 | ): args is { 217 | workspace: string; 218 | repository: string; 219 | branch_name: string; 220 | include_merged_prs?: boolean; 221 | } => 222 | typeof args === 'object' && 223 | args !== null && 224 | typeof args.workspace === 'string' && 225 | typeof args.repository === 'string' && 226 | typeof args.branch_name === 'string' && 227 | (args.include_merged_prs === undefined || typeof args.include_merged_prs === 'boolean'); 228 | 229 | export const isListDirectoryContentArgs = ( 230 | args: any 231 | ): args is { 232 | workspace: string; 233 | repository: string; 234 | path?: string; 235 | branch?: string; 236 | } => 237 | typeof args === 'object' && 238 | args !== null && 239 | typeof args.workspace === 'string' && 240 | typeof args.repository === 'string' && 241 | (args.path === undefined || typeof args.path === 'string') && 242 | (args.branch === undefined || typeof args.branch === 'string'); 243 | 244 | export const isGetFileContentArgs = ( 245 | args: any 246 | ): args is { 247 | workspace: string; 248 | repository: string; 249 | file_path: string; 250 | branch?: string; 251 | start_line?: number; 252 | line_count?: number; 253 | full_content?: boolean; 254 | } => 255 | typeof args === 'object' && 256 | args !== null && 257 | typeof args.workspace === 'string' && 258 | typeof args.repository === 'string' && 259 | typeof args.file_path === 'string' && 260 | (args.branch === undefined || typeof args.branch === 'string') && 261 | (args.start_line === undefined || typeof args.start_line === 'number') && 262 | (args.line_count === undefined || typeof args.line_count === 'number') && 263 | (args.full_content === undefined || typeof args.full_content === 'boolean'); 264 | 265 | export const isListBranchCommitsArgs = ( 266 | args: any 267 | ): args is { 268 | workspace: string; 269 | repository: string; 270 | branch_name: string; 271 | limit?: number; 272 | start?: number; 273 | since?: string; 274 | until?: string; 275 | author?: string; 276 | include_merge_commits?: boolean; 277 | search?: string; 278 | include_build_status?: boolean; 279 | } => 280 | typeof args === 'object' && 281 | args !== null && 282 | typeof args.workspace === 'string' && 283 | typeof args.repository === 'string' && 284 | typeof args.branch_name === 'string' && 285 | (args.limit === undefined || typeof args.limit === 'number') && 286 | (args.start === undefined || typeof args.start === 'number') && 287 | (args.since === undefined || typeof args.since === 'string') && 288 | (args.until === undefined || typeof args.until === 'string') && 289 | (args.author === undefined || typeof args.author === 'string') && 290 | (args.include_merge_commits === undefined || typeof args.include_merge_commits === 'boolean') && 291 | (args.search === undefined || typeof args.search === 'string') && 292 | (args.include_build_status === undefined || typeof args.include_build_status === 'boolean'); 293 | 294 | export const isListPrCommitsArgs = ( 295 | args: any 296 | ): args is { 297 | workspace: string; 298 | repository: string; 299 | pull_request_id: number; 300 | limit?: number; 301 | start?: number; 302 | include_build_status?: boolean; 303 | } => 304 | typeof args === 'object' && 305 | args !== null && 306 | typeof args.workspace === 'string' && 307 | typeof args.repository === 'string' && 308 | typeof args.pull_request_id === 'number' && 309 | (args.limit === undefined || typeof args.limit === 'number') && 310 | (args.start === undefined || typeof args.start === 'number') && 311 | (args.include_build_status === undefined || typeof args.include_build_status === 'boolean'); 312 | 313 | export const isSearchCodeArgs = ( 314 | args: any 315 | ): args is { 316 | workspace: string; 317 | repository?: string; 318 | search_query: string; 319 | file_pattern?: string; 320 | limit?: number; 321 | start?: number; 322 | } => 323 | typeof args === 'object' && 324 | args !== null && 325 | typeof args.workspace === 'string' && 326 | typeof args.search_query === 'string' && 327 | (args.repository === undefined || typeof args.repository === 'string') && 328 | (args.file_pattern === undefined || typeof args.file_pattern === 'string') && 329 | (args.limit === undefined || typeof args.limit === 'number') && 330 | (args.start === undefined || typeof args.start === 'number'); 331 | 332 | export const isListProjectsArgs = ( 333 | args: any 334 | ): args is { 335 | name?: string; 336 | permission?: string; 337 | limit?: number; 338 | start?: number; 339 | } => 340 | typeof args === 'object' && 341 | args !== null && 342 | (args.name === undefined || typeof args.name === 'string') && 343 | (args.permission === undefined || typeof args.permission === 'string') && 344 | (args.limit === undefined || typeof args.limit === 'number') && 345 | (args.start === undefined || typeof args.start === 'number'); 346 | 347 | export const isListRepositoriesArgs = ( 348 | args: any 349 | ): args is { 350 | workspace?: string; 351 | name?: string; 352 | permission?: string; 353 | limit?: number; 354 | start?: number; 355 | } => 356 | typeof args === 'object' && 357 | args !== null && 358 | (args.workspace === undefined || typeof args.workspace === 'string') && 359 | (args.name === undefined || typeof args.name === 'string') && 360 | (args.permission === undefined || typeof args.permission === 'string') && 361 | (args.limit === undefined || typeof args.limit === 'number') && 362 | (args.start === undefined || typeof args.start === 'number'); 363 | ``` -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- ```markdown 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [1.1.2] - 2025-10-14 9 | 10 | ### Added 11 | - **CI/CD build status support in `list_pr_commits` tool**: 12 | - Added `include_build_status` optional parameter to fetch build/CI status for pull request commits 13 | - Returns build status with counts: successful, failed, in_progress, and unknown builds 14 | - Uses same Bitbucket Server UI API endpoint as `list_branch_commits` for consistency 15 | - Graceful degradation: failures in fetching build status don't break commit listing 16 | - Currently only supports Bitbucket Server (Cloud has different build status APIs) 17 | - Useful for tracking CI/CD pipeline status for all commits in a pull request 18 | 19 | ### Changed 20 | - Enhanced README.md with comprehensive documentation for new v1.1.0 features: 21 | - Added usage examples and documentation for `include_build_status` parameter in `list_branch_commits` tool 22 | - Added complete documentation for `list_projects` tool with examples, parameters, and response format 23 | - Added complete documentation for `list_repositories` tool with examples, parameters, and response format 24 | - Improved feature list to include new Project and Repository Discovery Tools section 25 | 26 | ## [1.1.0] - 2025-10-14 27 | 28 | ### Added 29 | - **CI/CD build status support in `list_branch_commits` tool**: 30 | - Added `include_build_status` optional parameter to fetch build/CI status for commits 31 | - Returns build status with counts: successful, failed, in_progress, and unknown builds 32 | - Uses Bitbucket Server UI API endpoint for efficient batch fetching of build summaries 33 | - Graceful degradation: failures in fetching build status don't break commit listing 34 | - Currently only supports Bitbucket Server (Cloud has different build status APIs) 35 | - Useful for tracking CI/CD pipeline status alongside commit history 36 | 37 | - **New `list_projects` tool for project/workspace discovery**: 38 | - List all accessible Bitbucket projects (Server) or workspaces (Cloud) 39 | - Optional filtering by project name and permission level 40 | - Returns project metadata: key, ID, name, description, visibility, and type 41 | - Pagination support with `limit` and `start` parameters 42 | - Works with both Bitbucket Server and Cloud with unified response format 43 | 44 | - **New `list_repositories` tool for repository discovery**: 45 | - List repositories within a specific project/workspace or across all accessible repos 46 | - Optional filtering by repository name and permission level 47 | - Returns comprehensive repository details: 48 | - Basic info: slug, ID, name, description, state 49 | - Project association: project key and name 50 | - Clone URLs for both HTTP(S) and SSH 51 | - Repository settings: visibility, forkable status 52 | - Pagination support with `limit` and `start` parameters 53 | - Bitbucket Cloud requires workspace parameter (documented in response) 54 | - Bitbucket Server supports listing all accessible repos without workspace filter 55 | 56 | ### Changed 57 | - Added `ProjectHandlers` class following the modular architecture pattern 58 | - Enhanced TypeScript interfaces with project and repository types: 59 | - `BitbucketServerProject` and `BitbucketCloudProject` 60 | - `BitbucketServerRepository` and `BitbucketCloudRepository` 61 | - `BitbucketServerBuildSummary` and `BuildStatus` 62 | - Added custom params serializer for multiple `commitId` parameters in build status API 63 | - Enhanced `FormattedCommit` interface with optional `build_status` field 64 | - Updated API client with `getBuildSummaries` method for batch build status fetching 65 | 66 | ## [1.0.1] - 2025-08-08 67 | 68 | ### Fixed 69 | - **Improved search_code tool response formatting**: 70 | - Added simplified `formatCodeSearchOutput` for cleaner AI consumption 71 | - Enhanced HTML entity decoding (handles ", <, >, &, /, ') 72 | - Improved response structure showing file paths and line numbers clearly 73 | - Removed HTML formatting tags for better readability 74 | 75 | ### Changed 76 | - Search results now use simplified formatter by default for better AI tool integration 77 | - Enhanced query display to show actual search patterns used 78 | 79 | ## [1.0.0] - 2025-07-25 80 | 81 | ### Added 82 | - **New `search_code` tool for searching code across repositories**: 83 | - Search for code snippets, functions, or any text within Bitbucket repositories 84 | - Supports searching within a specific repository or across all repositories in a workspace 85 | - File path pattern filtering with glob patterns (e.g., `*.java`, `src/**/*.ts`) 86 | - Returns matched lines with highlighted segments showing exact matches 87 | - Pagination support for large result sets 88 | - Currently only supports Bitbucket Server (Cloud API support planned for future) 89 | - Added `SearchHandlers` class following the modular architecture pattern 90 | - Added TypeScript interfaces for search requests and responses 91 | - Added `formatSearchResults` formatter function for consistent output 92 | 93 | ### Changed 94 | - Major version bump to 1.0.0 indicating stable API with comprehensive feature set 95 | - Enhanced documentation with search examples 96 | 97 | ## [0.10.0] - 2025-07-03 98 | 99 | ### Added 100 | - **New `list_branch_commits` tool for retrieving commit history**: 101 | - List all commits in a specific branch with detailed information 102 | - Advanced filtering options: 103 | - `since` and `until` parameters for date range filtering (ISO date strings) 104 | - `author` parameter to filter by author email/username 105 | - `include_merge_commits` parameter to include/exclude merge commits (default: true) 106 | - `search` parameter to search in commit messages 107 | - Returns branch head information and paginated commit list 108 | - Each commit includes hash, message, author details, date, parents, and merge status 109 | - Supports both Bitbucket Server and Cloud APIs with appropriate parameter mapping 110 | - Useful for reviewing commit history, tracking changes, and analyzing branch activity 111 | 112 | - **New `list_pr_commits` tool for pull request commits**: 113 | - List all commits that are part of a specific pull request 114 | - Returns PR title and paginated commit list 115 | - Simpler than branch commits - focused specifically on PR changes 116 | - Each commit includes same detailed information as branch commits 117 | - Supports pagination with `limit` and `start` parameters 118 | - Useful for reviewing all changes in a PR before merging 119 | 120 | ### Changed 121 | - Added new TypeScript interfaces for commit types: 122 | - `BitbucketServerCommit` and `BitbucketCloudCommit` for API responses 123 | - `FormattedCommit` for consistent commit representation 124 | - Added formatter functions `formatServerCommit` and `formatCloudCommit` for unified output 125 | - Enhanced type guards with `isListBranchCommitsArgs` and `isListPrCommitsArgs` 126 | 127 | ## [0.9.1] - 2025-01-27 128 | 129 | ### Fixed 130 | - **Fixed `update_pull_request` reviewer preservation**: 131 | - When updating a PR without specifying reviewers, existing reviewers are now preserved 132 | - Previously, omitting the `reviewers` parameter would clear all reviewers 133 | - Now properly includes existing reviewers in the API request when not explicitly updating them 134 | - When updating reviewers, approval status is preserved for existing reviewers 135 | - This prevents accidentally removing reviewers when only updating PR title or description 136 | 137 | ### Changed 138 | - Updated tool documentation to clarify reviewer behavior in `update_pull_request` 139 | - Enhanced README with detailed explanation of reviewer handling 140 | 141 | ## [0.9.0] - 2025-01-26 142 | 143 | ### Added 144 | - **Code snippet support in `add_comment` tool**: 145 | - Added `code_snippet` parameter to find line numbers automatically using code text 146 | - Added `search_context` parameter with `before` and `after` arrays to disambiguate multiple matches 147 | - Added `match_strategy` parameter with options: 148 | - `"strict"` (default): Fails with detailed error when multiple matches found 149 | - `"best"`: Auto-selects the highest confidence match 150 | - Returns detailed error with all occurrences when multiple matches found in strict mode 151 | - Particularly useful for AI-powered code review tools that analyze diffs 152 | - Created comprehensive line matching algorithm that: 153 | - Parses diffs to find exact code snippets 154 | - Calculates confidence scores based on context matching 155 | - Handles added, removed, and context lines appropriately 156 | 157 | ### Changed 158 | - Enhanced `add_comment` tool to resolve line numbers from code snippets when `line_number` is not provided 159 | - Improved error messages to include preview and suggestions for resolving ambiguous matches 160 | 161 | ## [0.8.0] - 2025-01-26 162 | 163 | ### Added 164 | - **Code suggestions support in `add_comment` tool**: 165 | - Added `suggestion` parameter to add code suggestions in comments 166 | - Added `suggestion_end_line` parameter for multi-line suggestions 167 | - Suggestions are formatted using GitHub-style markdown ````suggestion` blocks 168 | - Works with both single-line and multi-line code replacements 169 | - Requires `file_path` and `line_number` to be specified when using suggestions 170 | - Compatible with both Bitbucket Cloud and Server 171 | - Created `suggestion-formatter.ts` utility for formatting suggestion comments 172 | 173 | ### Changed 174 | - Enhanced `add_comment` tool to validate suggestion requirements 175 | - Updated tool response to indicate when a comment contains a suggestion 176 | 177 | ## [0.7.0] - 2025-01-26 178 | 179 | ### Added 180 | - **Enhanced `get_pull_request_diff` with filtering capabilities**: 181 | - Added `include_patterns` parameter to filter diff by file patterns (whitelist) 182 | - Added `exclude_patterns` parameter to exclude files from diff (blacklist) 183 | - Added `file_path` parameter to get diff for a specific file only 184 | - Patterns support standard glob syntax (e.g., `*.js`, `src/**/*.res`, `node_modules/**`) 185 | - Response includes filtering metadata showing total files, included/excluded counts, and excluded file list 186 | - Added `minimatch` dependency for glob pattern matching 187 | - Created `DiffParser` utility class for parsing and filtering unified diff format 188 | 189 | ### Changed 190 | - Modified `get_pull_request_diff` tool to support optional filtering without breaking existing usage 191 | - Updated tool definition and type guards to include new optional parameters 192 | - Enhanced documentation with comprehensive examples of filtering usage 193 | 194 | ## [0.6.1] - 2025-01-26 195 | 196 | ### Added 197 | - Support for nested comment replies in Bitbucket Server 198 | - Added `replies` field to `FormattedComment` interface to support nested comment threads 199 | - Comments now include nested replies that are still relevant (not orphaned or resolved) 200 | - Total and active comment counts now include nested replies 201 | 202 | ### Changed 203 | - Updated comment fetching logic to handle Bitbucket Server's nested comment structure 204 | - Server uses `comments` array inside each comment object for replies 205 | - Cloud continues to use `parent` field for reply relationships 206 | - Improved comment filtering to exclude orphaned inline comments when code has changed 207 | 208 | ### Fixed 209 | - Fixed missing comment replies in PR details - replies are now properly included in the response 210 | 211 | ## [0.6.0] - 2025-01-26 212 | 213 | ### Added 214 | - **Enhanced `get_pull_request` with active comments and file changes**: 215 | - Fetches and displays active (unresolved) comments that need attention 216 | - Shows up to 20 most recent active comments with: 217 | - Comment text, author, and creation date 218 | - Inline comment details (file path and line number) 219 | - Comment state (OPEN/RESOLVED for Server) 220 | - Provides comment counts: 221 | - `active_comment_count`: Total unresolved comments 222 | - `total_comment_count`: Total comments including resolved 223 | - Includes file change statistics: 224 | - List of all modified files with lines added/removed 225 | - File status (added, modified, removed, renamed) 226 | - Summary statistics (total files, lines added/removed) 227 | - Added new TypeScript interfaces for comments and file changes 228 | - Added `FormattedComment` and `FormattedFileChange` types for consistent response format 229 | 230 | ### Changed 231 | - Modified `handleGetPullRequest` to make parallel API calls for better performance 232 | - Enhanced error handling to gracefully continue if comment/file fetching fails 233 | 234 | ## [0.5.0] - 2025-01-21 235 | 236 | ### Added 237 | - **New file and directory handling tools**: 238 | - `list_directory_content` - List files and directories in any repository path 239 | - Shows file/directory type, size, and full paths 240 | - Supports browsing specific branches 241 | - Works with both Bitbucket Server and Cloud APIs 242 | - `get_file_content` - Retrieve file content with smart truncation for large files 243 | - Automatic smart defaults by file type (config: 200 lines, docs: 300 lines, code: 500 lines) 244 | - Pagination support with `start_line` and `line_count` parameters 245 | - Tail functionality using negative `start_line` values (e.g., -50 for last 50 lines) 246 | - Automatic truncation for files >50KB to prevent token overload 247 | - Files >1MB require explicit `full_content: true` parameter 248 | - Returns metadata including file size, encoding, and last modified info 249 | - Added `FileHandlers` class following existing modular architecture patterns 250 | - Added TypeScript interfaces for file/directory entries and metadata 251 | - Added type guards `isListDirectoryContentArgs` and `isGetFileContentArgs` 252 | 253 | ### Changed 254 | - Enhanced documentation with comprehensive examples for file handling tools 255 | 256 | ## [0.4.0] - 2025-01-21 257 | 258 | ### Added 259 | - **New `get_branch` tool for comprehensive branch information**: 260 | - Returns detailed branch information including name, ID, and latest commit details 261 | - Lists all open pull requests originating from the branch with approval status 262 | - Optionally includes merged pull requests when `include_merged_prs` is true 263 | - Provides useful statistics like PR counts and days since last commit 264 | - Supports both Bitbucket Server and Cloud APIs 265 | - Particularly useful for checking if a branch has open PRs before deletion 266 | - Added TypeScript interfaces for `BitbucketServerBranch` and `BitbucketCloudBranch` 267 | - Added type guard `isGetBranchArgs` for input validation 268 | 269 | ### Changed 270 | - Updated documentation to include the new `get_branch` tool with comprehensive examples 271 | 272 | ## [0.3.0] - 2025-01-06 273 | 274 | ### Added 275 | - **Enhanced merge commit details in `get_pull_request`**: 276 | - Added `merge_commit_hash` field for both Cloud and Server 277 | - Added `merged_by` field showing who performed the merge 278 | - Added `merged_at` timestamp for when the merge occurred 279 | - Added `merge_commit_message` with the merge commit message 280 | - For Bitbucket Server: Fetches merge details from activities API when PR is merged 281 | - For Bitbucket Cloud: Extracts merge information from existing response fields 282 | 283 | ### Changed 284 | - **Major code refactoring for better maintainability**: 285 | - Split monolithic `index.ts` into modular architecture 286 | - Created separate handler classes for different tool categories: 287 | - `PullRequestHandlers` for PR lifecycle operations 288 | - `BranchHandlers` for branch management 289 | - `ReviewHandlers` for code review tools 290 | - Extracted types into dedicated files (`types/bitbucket.ts`, `types/guards.ts`) 291 | - Created utility modules (`utils/api-client.ts`, `utils/formatters.ts`) 292 | - Centralized tool definitions in `tools/definitions.ts` 293 | - Improved error handling and API client abstraction 294 | - Better separation of concerns between Cloud and Server implementations 295 | 296 | ### Fixed 297 | - Improved handling of merge commit information retrieval failures 298 | - Fixed API parameter passing for GET requests across all handlers (was passing config as third parameter instead of fourth) 299 | - Updated Bitbucket Server branch listing to use `/rest/api/latest/` endpoint with proper parameters 300 | - Branch filtering now works correctly with the `filterText` parameter for Bitbucket Server 301 | 302 | ## [0.2.0] - 2025-06-04 303 | 304 | ### Added 305 | - Complete implementation of all Bitbucket MCP tools 306 | - Support for both Bitbucket Cloud and Server 307 | - Core PR lifecycle tools: 308 | - `create_pull_request` - Create new pull requests 309 | - `update_pull_request` - Update PR details 310 | - `merge_pull_request` - Merge pull requests 311 | - `list_branches` - List repository branches 312 | - `delete_branch` - Delete branches 313 | - Enhanced `add_comment` with inline comment support 314 | - Code review tools: 315 | - `get_pull_request_diff` - Get PR diff/changes 316 | - `approve_pull_request` - Approve PRs 317 | - `unapprove_pull_request` - Remove approval 318 | - `request_changes` - Request changes on PRs 319 | - `remove_requested_changes` - Remove change requests 320 | - npm package configuration for easy installation via npx 321 | 322 | ### Fixed 323 | - Author filter for Bitbucket Server (uses `role.1=AUTHOR` and `username.1=email`) 324 | - Branch deletion handling for 204 No Content responses 325 | 326 | ### Changed 327 | - Package name to `@nexus2520/bitbucket-mcp-server` for npm publishing 328 | 329 | ## [0.1.0] - 2025-06-03 330 | 331 | ### Added 332 | - Initial implementation with basic tools: 333 | - `get_pull_request` - Get PR details 334 | - `list_pull_requests` - List PRs with filters 335 | - Support for Bitbucket Cloud with app passwords 336 | - Support for Bitbucket Server with HTTP access tokens 337 | - Authentication setup script 338 | - Comprehensive documentation 339 | ``` -------------------------------------------------------------------------------- /src/handlers/branch-handlers.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; 2 | import { BitbucketApiClient } from '../utils/api-client.js'; 3 | import { 4 | isListBranchesArgs, 5 | isDeleteBranchArgs, 6 | isGetBranchArgs, 7 | isListBranchCommitsArgs 8 | } from '../types/guards.js'; 9 | import { 10 | BitbucketServerBranch, 11 | BitbucketCloudBranch, 12 | BitbucketServerCommit, 13 | BitbucketCloudCommit, 14 | FormattedCommit 15 | } from '../types/bitbucket.js'; 16 | import { formatServerCommit, formatCloudCommit } from '../utils/formatters.js'; 17 | 18 | export class BranchHandlers { 19 | constructor( 20 | private apiClient: BitbucketApiClient, 21 | private baseUrl: string 22 | ) {} 23 | 24 | async handleListBranches(args: any) { 25 | if (!isListBranchesArgs(args)) { 26 | throw new McpError( 27 | ErrorCode.InvalidParams, 28 | 'Invalid arguments for list_branches' 29 | ); 30 | } 31 | 32 | const { workspace, repository, filter, limit = 25, start = 0 } = args; 33 | 34 | try { 35 | let apiPath: string; 36 | let params: any = {}; 37 | 38 | if (this.apiClient.getIsServer()) { 39 | // Bitbucket Server API - using latest version for better filtering support 40 | apiPath = `/rest/api/latest/projects/${workspace}/repos/${repository}/branches`; 41 | params = { 42 | limit, 43 | start, 44 | details: true, 45 | orderBy: 'MODIFICATION' 46 | }; 47 | if (filter) { 48 | params.filterText = filter; 49 | } 50 | } else { 51 | // Bitbucket Cloud API 52 | apiPath = `/repositories/${workspace}/${repository}/refs/branches`; 53 | params = { 54 | pagelen: limit, 55 | page: Math.floor(start / limit) + 1, 56 | }; 57 | if (filter) { 58 | params.q = `name ~ "${filter}"`; 59 | } 60 | } 61 | 62 | const response = await this.apiClient.makeRequest<any>('get', apiPath, undefined, { params }); 63 | 64 | // Format the response 65 | let branches: any[] = []; 66 | let totalCount = 0; 67 | let nextPageStart = null; 68 | 69 | if (this.apiClient.getIsServer()) { 70 | // Bitbucket Server response 71 | branches = (response.values || []).map((branch: any) => ({ 72 | name: branch.displayId, 73 | id: branch.id, 74 | latest_commit: branch.latestCommit, 75 | is_default: branch.isDefault || false 76 | })); 77 | totalCount = response.size || 0; 78 | if (!response.isLastPage && response.nextPageStart !== undefined) { 79 | nextPageStart = response.nextPageStart; 80 | } 81 | } else { 82 | // Bitbucket Cloud response 83 | branches = (response.values || []).map((branch: any) => ({ 84 | name: branch.name, 85 | target: branch.target.hash, 86 | is_default: branch.name === 'main' || branch.name === 'master' 87 | })); 88 | totalCount = response.size || 0; 89 | if (response.next) { 90 | nextPageStart = start + limit; 91 | } 92 | } 93 | 94 | return { 95 | content: [ 96 | { 97 | type: 'text', 98 | text: JSON.stringify({ 99 | branches, 100 | total_count: totalCount, 101 | start, 102 | limit, 103 | has_more: nextPageStart !== null, 104 | next_start: nextPageStart 105 | }, null, 2), 106 | }, 107 | ], 108 | }; 109 | } catch (error) { 110 | return this.apiClient.handleApiError(error, `listing branches in ${workspace}/${repository}`); 111 | } 112 | } 113 | 114 | async handleDeleteBranch(args: any) { 115 | if (!isDeleteBranchArgs(args)) { 116 | throw new McpError( 117 | ErrorCode.InvalidParams, 118 | 'Invalid arguments for delete_branch' 119 | ); 120 | } 121 | 122 | const { workspace, repository, branch_name, force } = args; 123 | 124 | try { 125 | let apiPath: string; 126 | 127 | if (this.apiClient.getIsServer()) { 128 | // First, we need to get the branch details to find the latest commit 129 | const branchesPath = `/rest/api/latest/projects/${workspace}/repos/${repository}/branches`; 130 | const branchesResponse = await this.apiClient.makeRequest<any>('get', branchesPath, undefined, { 131 | params: { 132 | filterText: branch_name, 133 | limit: 100 134 | } 135 | }); 136 | 137 | // Find the exact branch 138 | const branch = branchesResponse.values?.find((b: any) => b.displayId === branch_name); 139 | if (!branch) { 140 | throw new Error(`Branch '${branch_name}' not found`); 141 | } 142 | 143 | // Now delete using branch-utils endpoint with correct format 144 | apiPath = `/rest/branch-utils/latest/projects/${workspace}/repos/${repository}/branches`; 145 | 146 | try { 147 | await this.apiClient.makeRequest<any>('delete', apiPath, { 148 | name: branch_name, 149 | endPoint: branch.latestCommit 150 | }); 151 | } catch (deleteError: any) { 152 | // If the error is about empty response but status is 204 (No Content), it's successful 153 | if (deleteError.originalError?.response?.status === 204 || 154 | deleteError.message?.includes('No content to map')) { 155 | // Branch was deleted successfully 156 | } else { 157 | throw deleteError; 158 | } 159 | } 160 | } else { 161 | // Bitbucket Cloud API 162 | apiPath = `/repositories/${workspace}/${repository}/refs/branches/${branch_name}`; 163 | try { 164 | await this.apiClient.makeRequest<any>('delete', apiPath); 165 | } catch (deleteError: any) { 166 | // If the error is about empty response but status is 204 (No Content), it's successful 167 | if (deleteError.originalError?.response?.status === 204 || 168 | deleteError.message?.includes('No content to map')) { 169 | // Branch was deleted successfully 170 | } else { 171 | throw deleteError; 172 | } 173 | } 174 | } 175 | 176 | return { 177 | content: [ 178 | { 179 | type: 'text', 180 | text: JSON.stringify({ 181 | message: `Branch '${branch_name}' deleted successfully`, 182 | branch: branch_name, 183 | repository: `${workspace}/${repository}` 184 | }, null, 2), 185 | }, 186 | ], 187 | }; 188 | } catch (error) { 189 | return this.apiClient.handleApiError(error, `deleting branch '${branch_name}' in ${workspace}/${repository}`); 190 | } 191 | } 192 | 193 | async handleGetBranch(args: any) { 194 | if (!isGetBranchArgs(args)) { 195 | throw new McpError( 196 | ErrorCode.InvalidParams, 197 | 'Invalid arguments for get_branch' 198 | ); 199 | } 200 | 201 | const { workspace, repository, branch_name, include_merged_prs = false } = args; 202 | 203 | try { 204 | // Step 1: Get branch details 205 | let branchInfo: any; 206 | let branchCommitInfo: any = {}; 207 | 208 | if (this.apiClient.getIsServer()) { 209 | // Bitbucket Server - get branch details 210 | const branchesPath = `/rest/api/latest/projects/${workspace}/repos/${repository}/branches`; 211 | const branchesResponse = await this.apiClient.makeRequest<any>('get', branchesPath, undefined, { 212 | params: { 213 | filterText: branch_name, 214 | limit: 100, 215 | details: true 216 | } 217 | }); 218 | 219 | // Find the exact branch 220 | const branch = branchesResponse.values?.find((b: BitbucketServerBranch) => b.displayId === branch_name); 221 | if (!branch) { 222 | throw new Error(`Branch '${branch_name}' not found`); 223 | } 224 | 225 | branchInfo = { 226 | name: branch.displayId, 227 | id: branch.id, 228 | latest_commit: { 229 | id: branch.latestCommit, 230 | message: branch.metadata?.['com.atlassian.bitbucket.server.bitbucket-branch:latest-commit-metadata']?.message || null, 231 | author: branch.metadata?.['com.atlassian.bitbucket.server.bitbucket-branch:latest-commit-metadata']?.author || null, 232 | date: branch.metadata?.['com.atlassian.bitbucket.server.bitbucket-branch:latest-commit-metadata']?.authorTimestamp 233 | ? new Date(branch.metadata['com.atlassian.bitbucket.server.bitbucket-branch:latest-commit-metadata'].authorTimestamp).toISOString() 234 | : null 235 | }, 236 | is_default: branch.isDefault || false 237 | }; 238 | } else { 239 | // Bitbucket Cloud - get branch details 240 | const branchPath = `/repositories/${workspace}/${repository}/refs/branches/${encodeURIComponent(branch_name)}`; 241 | const branch = await this.apiClient.makeRequest<BitbucketCloudBranch>('get', branchPath); 242 | 243 | branchInfo = { 244 | name: branch.name, 245 | id: `refs/heads/${branch.name}`, 246 | latest_commit: { 247 | id: branch.target.hash, 248 | message: branch.target.message, 249 | author: branch.target.author.user?.display_name || branch.target.author.raw, 250 | date: branch.target.date 251 | }, 252 | is_default: false // Will check this with default branch info 253 | }; 254 | 255 | // Check if this is the default branch 256 | try { 257 | const repoPath = `/repositories/${workspace}/${repository}`; 258 | const repoInfo = await this.apiClient.makeRequest<any>('get', repoPath); 259 | branchInfo.is_default = branch.name === repoInfo.mainbranch?.name; 260 | } catch (e) { 261 | // Ignore error, just assume not default 262 | } 263 | } 264 | 265 | // Step 2: Get open PRs from this branch 266 | let openPRs: any[] = []; 267 | 268 | if (this.apiClient.getIsServer()) { 269 | // Bitbucket Server 270 | const prPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests`; 271 | const prResponse = await this.apiClient.makeRequest<any>('get', prPath, undefined, { 272 | params: { 273 | state: 'OPEN', 274 | direction: 'OUTGOING', 275 | at: `refs/heads/${branch_name}`, 276 | limit: 100 277 | } 278 | }); 279 | 280 | openPRs = (prResponse.values || []).map((pr: any) => ({ 281 | id: pr.id, 282 | title: pr.title, 283 | destination_branch: pr.toRef.displayId, 284 | author: pr.author.user.displayName, 285 | created_on: new Date(pr.createdDate).toISOString(), 286 | reviewers: pr.reviewers.map((r: any) => r.user.displayName), 287 | approval_status: { 288 | approved_by: pr.reviewers.filter((r: any) => r.approved).map((r: any) => r.user.displayName), 289 | changes_requested_by: pr.reviewers.filter((r: any) => r.status === 'NEEDS_WORK').map((r: any) => r.user.displayName), 290 | pending: pr.reviewers.filter((r: any) => !r.approved && r.status !== 'NEEDS_WORK').map((r: any) => r.user.displayName) 291 | }, 292 | url: `${this.baseUrl}/projects/${workspace}/repos/${repository}/pull-requests/${pr.id}` 293 | })); 294 | } else { 295 | // Bitbucket Cloud 296 | const prPath = `/repositories/${workspace}/${repository}/pullrequests`; 297 | const prResponse = await this.apiClient.makeRequest<any>('get', prPath, undefined, { 298 | params: { 299 | state: 'OPEN', 300 | q: `source.branch.name="${branch_name}"`, 301 | pagelen: 50 302 | } 303 | }); 304 | 305 | openPRs = (prResponse.values || []).map((pr: any) => ({ 306 | id: pr.id, 307 | title: pr.title, 308 | destination_branch: pr.destination.branch.name, 309 | author: pr.author.display_name, 310 | created_on: pr.created_on, 311 | reviewers: pr.reviewers.map((r: any) => r.display_name), 312 | approval_status: { 313 | approved_by: pr.participants.filter((p: any) => p.approved).map((p: any) => p.user.display_name), 314 | changes_requested_by: [], // Cloud doesn't have explicit "changes requested" status 315 | pending: pr.reviewers.filter((r: any) => !pr.participants.find((p: any) => p.user.account_id === r.account_id && p.approved)) 316 | .map((r: any) => r.display_name) 317 | }, 318 | url: pr.links.html.href 319 | })); 320 | } 321 | 322 | // Step 3: Optionally get merged PRs 323 | let mergedPRs: any[] = []; 324 | 325 | if (include_merged_prs) { 326 | if (this.apiClient.getIsServer()) { 327 | // Bitbucket Server 328 | const mergedPrPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests`; 329 | const mergedPrResponse = await this.apiClient.makeRequest<any>('get', mergedPrPath, undefined, { 330 | params: { 331 | state: 'MERGED', 332 | direction: 'OUTGOING', 333 | at: `refs/heads/${branch_name}`, 334 | limit: 25 335 | } 336 | }); 337 | 338 | mergedPRs = (mergedPrResponse.values || []).map((pr: any) => ({ 339 | id: pr.id, 340 | title: pr.title, 341 | merged_at: new Date(pr.updatedDate).toISOString(), // Using updated date as merge date 342 | merged_by: pr.participants.find((p: any) => p.role === 'PARTICIPANT' && p.approved)?.user.displayName || 'Unknown' 343 | })); 344 | } else { 345 | // Bitbucket Cloud 346 | const mergedPrPath = `/repositories/${workspace}/${repository}/pullrequests`; 347 | const mergedPrResponse = await this.apiClient.makeRequest<any>('get', mergedPrPath, undefined, { 348 | params: { 349 | state: 'MERGED', 350 | q: `source.branch.name="${branch_name}"`, 351 | pagelen: 25 352 | } 353 | }); 354 | 355 | mergedPRs = (mergedPrResponse.values || []).map((pr: any) => ({ 356 | id: pr.id, 357 | title: pr.title, 358 | merged_at: pr.updated_on, 359 | merged_by: pr.closed_by?.display_name || 'Unknown' 360 | })); 361 | } 362 | } 363 | 364 | // Step 4: Calculate statistics 365 | const daysSinceLastCommit = branchInfo.latest_commit.date 366 | ? Math.floor((Date.now() - new Date(branchInfo.latest_commit.date).getTime()) / (1000 * 60 * 60 * 24)) 367 | : null; 368 | 369 | // Step 5: Format and return combined response 370 | return { 371 | content: [ 372 | { 373 | type: 'text', 374 | text: JSON.stringify({ 375 | branch: branchInfo, 376 | open_pull_requests: openPRs, 377 | merged_pull_requests: mergedPRs, 378 | statistics: { 379 | total_open_prs: openPRs.length, 380 | total_merged_prs: mergedPRs.length, 381 | days_since_last_commit: daysSinceLastCommit 382 | } 383 | }, null, 2), 384 | }, 385 | ], 386 | }; 387 | } catch (error: any) { 388 | // Handle specific not found error 389 | if (error.message?.includes('not found')) { 390 | return { 391 | content: [ 392 | { 393 | type: 'text', 394 | text: `Branch '${branch_name}' not found in ${workspace}/${repository}`, 395 | }, 396 | ], 397 | isError: true, 398 | }; 399 | } 400 | return this.apiClient.handleApiError(error, `getting branch '${branch_name}' in ${workspace}/${repository}`); 401 | } 402 | } 403 | 404 | async handleListBranchCommits(args: any) { 405 | if (!isListBranchCommitsArgs(args)) { 406 | throw new McpError( 407 | ErrorCode.InvalidParams, 408 | 'Invalid arguments for list_branch_commits' 409 | ); 410 | } 411 | 412 | const { 413 | workspace, 414 | repository, 415 | branch_name, 416 | limit = 25, 417 | start = 0, 418 | since, 419 | until, 420 | author, 421 | include_merge_commits = true, 422 | search, 423 | include_build_status = false 424 | } = args; 425 | 426 | try { 427 | let apiPath: string; 428 | let params: any = {}; 429 | let commits: FormattedCommit[] = []; 430 | let totalCount = 0; 431 | let nextPageStart: number | null = null; 432 | 433 | if (this.apiClient.getIsServer()) { 434 | // Bitbucket Server API 435 | apiPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/commits`; 436 | params = { 437 | until: `refs/heads/${branch_name}`, 438 | limit, 439 | start, 440 | withCounts: true 441 | }; 442 | 443 | // Add filters 444 | if (since) { 445 | params.since = since; 446 | } 447 | if (!include_merge_commits) { 448 | params.merges = 'exclude'; 449 | } 450 | 451 | const response = await this.apiClient.makeRequest<any>('get', apiPath, undefined, { params }); 452 | 453 | // Format commits 454 | commits = (response.values || []).map((commit: BitbucketServerCommit) => formatServerCommit(commit)); 455 | 456 | // Apply client-side filters for Server API 457 | if (author) { 458 | // Filter by author email or name 459 | commits = commits.filter(c => 460 | c.author.email === author || 461 | c.author.name === author || 462 | c.author.email.toLowerCase() === author.toLowerCase() || 463 | c.author.name.toLowerCase() === author.toLowerCase() 464 | ); 465 | } 466 | 467 | // Filter by date if 'until' is provided (Server API doesn't support 'until' param directly) 468 | if (until) { 469 | const untilDate = new Date(until).getTime(); 470 | commits = commits.filter(c => new Date(c.date).getTime() <= untilDate); 471 | } 472 | 473 | // Filter by message search if provided 474 | if (search) { 475 | const searchLower = search.toLowerCase(); 476 | commits = commits.filter(c => c.message.toLowerCase().includes(searchLower)); 477 | } 478 | 479 | // If we applied client-side filters, update the total count 480 | if (author || until || search) { 481 | totalCount = commits.length; 482 | // Can't determine if there are more results when filtering client-side 483 | nextPageStart = null; 484 | } else { 485 | totalCount = response.size || commits.length; 486 | if (!response.isLastPage && response.nextPageStart !== undefined) { 487 | nextPageStart = response.nextPageStart; 488 | } 489 | } 490 | } else { 491 | // Bitbucket Cloud API 492 | apiPath = `/repositories/${workspace}/${repository}/commits/${encodeURIComponent(branch_name)}`; 493 | params = { 494 | pagelen: limit, 495 | page: Math.floor(start / limit) + 1 496 | }; 497 | 498 | // Build query string for filters 499 | const queryParts: string[] = []; 500 | if (author) { 501 | queryParts.push(`author.raw ~ "${author}"`); 502 | } 503 | if (!include_merge_commits) { 504 | // Cloud API doesn't have direct merge exclusion, we'll filter client-side 505 | } 506 | if (queryParts.length > 0) { 507 | params.q = queryParts.join(' AND '); 508 | } 509 | 510 | const response = await this.apiClient.makeRequest<any>('get', apiPath, undefined, { params }); 511 | 512 | // Format commits 513 | let cloudCommits = (response.values || []).map((commit: BitbucketCloudCommit) => formatCloudCommit(commit)); 514 | 515 | // Apply client-side filters 516 | if (!include_merge_commits) { 517 | cloudCommits = cloudCommits.filter((c: FormattedCommit) => !c.is_merge_commit); 518 | } 519 | if (since) { 520 | const sinceDate = new Date(since).getTime(); 521 | cloudCommits = cloudCommits.filter((c: FormattedCommit) => new Date(c.date).getTime() >= sinceDate); 522 | } 523 | if (until) { 524 | const untilDate = new Date(until).getTime(); 525 | cloudCommits = cloudCommits.filter((c: FormattedCommit) => new Date(c.date).getTime() <= untilDate); 526 | } 527 | if (search) { 528 | const searchLower = search.toLowerCase(); 529 | cloudCommits = cloudCommits.filter((c: FormattedCommit) => c.message.toLowerCase().includes(searchLower)); 530 | } 531 | 532 | commits = cloudCommits; 533 | totalCount = response.size || commits.length; 534 | if (response.next) { 535 | nextPageStart = start + limit; 536 | } 537 | } 538 | 539 | // Fetch build status if requested (Server only) 540 | if (include_build_status && this.apiClient.getIsServer() && commits.length > 0) { 541 | try { 542 | // Extract commit hashes (use full hash, not abbreviated) 543 | const commitIds = commits.map(c => c.hash); 544 | 545 | // Fetch build summaries for all commits 546 | const buildSummaries = await this.apiClient.getBuildSummaries( 547 | workspace, 548 | repository, 549 | commitIds 550 | ); 551 | 552 | // Merge build status into commits 553 | commits = commits.map(commit => { 554 | const buildData = buildSummaries[commit.hash]; 555 | if (buildData) { 556 | return { 557 | ...commit, 558 | build_status: { 559 | successful: buildData.successful || 0, 560 | failed: buildData.failed || 0, 561 | in_progress: buildData.inProgress || 0, 562 | unknown: buildData.unknown || 0 563 | } 564 | }; 565 | } 566 | return commit; 567 | }); 568 | } catch (error) { 569 | // Gracefully degrade - log error but don't fail the entire request 570 | console.error('Failed to fetch build status:', error); 571 | } 572 | } 573 | 574 | // Get branch head info 575 | let branchHead: string | null = null; 576 | try { 577 | if (this.apiClient.getIsServer()) { 578 | const branchesPath = `/rest/api/latest/projects/${workspace}/repos/${repository}/branches`; 579 | const branchesResponse = await this.apiClient.makeRequest<any>('get', branchesPath, undefined, { 580 | params: { filterText: branch_name, limit: 1 } 581 | }); 582 | const branch = branchesResponse.values?.find((b: any) => b.displayId === branch_name); 583 | branchHead = branch?.latestCommit || null; 584 | } else { 585 | const branchPath = `/repositories/${workspace}/${repository}/refs/branches/${encodeURIComponent(branch_name)}`; 586 | const branch = await this.apiClient.makeRequest<any>('get', branchPath); 587 | branchHead = branch.target?.hash || null; 588 | } 589 | } catch (e) { 590 | // Ignore error, branch head is optional 591 | } 592 | 593 | // Build filters applied summary 594 | const filtersApplied: any = {}; 595 | if (author) filtersApplied.author = author; 596 | if (since) filtersApplied.since = since; 597 | if (until) filtersApplied.until = until; 598 | if (include_merge_commits !== undefined) filtersApplied.include_merge_commits = include_merge_commits; 599 | if (search) filtersApplied.search = search; 600 | if (include_build_status) filtersApplied.include_build_status = include_build_status; 601 | 602 | return { 603 | content: [ 604 | { 605 | type: 'text', 606 | text: JSON.stringify({ 607 | branch_name, 608 | branch_head: branchHead, 609 | commits, 610 | total_count: totalCount, 611 | start, 612 | limit, 613 | has_more: nextPageStart !== null, 614 | next_start: nextPageStart, 615 | filters_applied: filtersApplied 616 | }, null, 2), 617 | }, 618 | ], 619 | }; 620 | } catch (error) { 621 | return this.apiClient.handleApiError(error, `listing commits for branch '${branch_name}' in ${workspace}/${repository}`); 622 | } 623 | } 624 | } 625 | ``` -------------------------------------------------------------------------------- /src/tools/definitions.ts: -------------------------------------------------------------------------------- ```typescript 1 | export const toolDefinitions = [ 2 | { 3 | name: 'get_pull_request', 4 | description: 'Get details of a Bitbucket pull request including merge commit information', 5 | inputSchema: { 6 | type: 'object', 7 | properties: { 8 | workspace: { 9 | type: 'string', 10 | description: 'Bitbucket workspace/project key (e.g., "PROJ")', 11 | }, 12 | repository: { 13 | type: 'string', 14 | description: 'Repository slug (e.g., "my-repo")', 15 | }, 16 | pull_request_id: { 17 | type: 'number', 18 | description: 'Pull request ID', 19 | }, 20 | }, 21 | required: ['workspace', 'repository', 'pull_request_id'], 22 | }, 23 | }, 24 | { 25 | name: 'list_pull_requests', 26 | description: 'List pull requests for a repository with optional filters', 27 | inputSchema: { 28 | type: 'object', 29 | properties: { 30 | workspace: { 31 | type: 'string', 32 | description: 'Bitbucket workspace/project key (e.g., "PROJ")', 33 | }, 34 | repository: { 35 | type: 'string', 36 | description: 'Repository slug (e.g., "my-repo")', 37 | }, 38 | state: { 39 | type: 'string', 40 | description: 'Filter by PR state: OPEN, MERGED, DECLINED, ALL (default: OPEN)', 41 | enum: ['OPEN', 'MERGED', 'DECLINED', 'ALL'], 42 | }, 43 | author: { 44 | type: 'string', 45 | description: 'Filter by author username', 46 | }, 47 | limit: { 48 | type: 'number', 49 | description: 'Maximum number of PRs to return (default: 25)', 50 | }, 51 | start: { 52 | type: 'number', 53 | description: 'Start index for pagination (default: 0)', 54 | }, 55 | }, 56 | required: ['workspace', 'repository'], 57 | }, 58 | }, 59 | { 60 | name: 'create_pull_request', 61 | description: 'Create a new pull request', 62 | inputSchema: { 63 | type: 'object', 64 | properties: { 65 | workspace: { 66 | type: 'string', 67 | description: 'Bitbucket workspace/project key (e.g., "PROJ")', 68 | }, 69 | repository: { 70 | type: 'string', 71 | description: 'Repository slug (e.g., "my-repo")', 72 | }, 73 | title: { 74 | type: 'string', 75 | description: 'Title of the pull request', 76 | }, 77 | source_branch: { 78 | type: 'string', 79 | description: 'Source branch name', 80 | }, 81 | destination_branch: { 82 | type: 'string', 83 | description: 'Destination branch name (e.g., "main", "master")', 84 | }, 85 | description: { 86 | type: 'string', 87 | description: 'Description of the pull request (optional)', 88 | }, 89 | reviewers: { 90 | type: 'array', 91 | items: { type: 'string' }, 92 | description: 'Array of reviewer usernames/emails (optional)', 93 | }, 94 | close_source_branch: { 95 | type: 'boolean', 96 | description: 'Whether to close source branch after merge (optional, default: false)', 97 | }, 98 | }, 99 | required: ['workspace', 'repository', 'title', 'source_branch', 'destination_branch'], 100 | }, 101 | }, 102 | { 103 | name: 'update_pull_request', 104 | description: 'Update an existing pull request. When updating without specifying reviewers, existing reviewers and their approval status will be preserved.', 105 | inputSchema: { 106 | type: 'object', 107 | properties: { 108 | workspace: { 109 | type: 'string', 110 | description: 'Bitbucket workspace/project key (e.g., "PROJ")', 111 | }, 112 | repository: { 113 | type: 'string', 114 | description: 'Repository slug (e.g., "my-repo")', 115 | }, 116 | pull_request_id: { 117 | type: 'number', 118 | description: 'Pull request ID', 119 | }, 120 | title: { 121 | type: 'string', 122 | description: 'New title (optional)', 123 | }, 124 | description: { 125 | type: 'string', 126 | description: 'New description (optional)', 127 | }, 128 | destination_branch: { 129 | type: 'string', 130 | description: 'New destination branch (optional)', 131 | }, 132 | reviewers: { 133 | type: 'array', 134 | items: { type: 'string' }, 135 | 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)', 136 | }, 137 | }, 138 | required: ['workspace', 'repository', 'pull_request_id'], 139 | }, 140 | }, 141 | { 142 | name: 'add_comment', 143 | 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.', 144 | inputSchema: { 145 | type: 'object', 146 | properties: { 147 | workspace: { 148 | type: 'string', 149 | description: 'Bitbucket workspace/project key (e.g., "PROJ")', 150 | }, 151 | repository: { 152 | type: 'string', 153 | description: 'Repository slug (e.g., "my-repo")', 154 | }, 155 | pull_request_id: { 156 | type: 'number', 157 | description: 'Pull request ID', 158 | }, 159 | comment_text: { 160 | type: 'string', 161 | description: 'The main comment text. For suggestions, this is the explanation before the code suggestion.', 162 | }, 163 | parent_comment_id: { 164 | type: 'number', 165 | description: 'ID of comment to reply to. Use this to create threaded conversations (optional)', 166 | }, 167 | file_path: { 168 | type: 'string', 169 | description: 'File path for inline comment. Required for inline comments. Example: "src/components/Button.js" (optional)', 170 | }, 171 | line_number: { 172 | type: 'number', 173 | description: 'Exact line number in the file. Use this OR code_snippet, not both. Required with file_path unless using code_snippet (optional)', 174 | }, 175 | line_type: { 176 | type: 'string', 177 | description: 'Type of line: ADDED (green/new lines), REMOVED (red/deleted lines), or CONTEXT (unchanged lines). Default: CONTEXT', 178 | enum: ['ADDED', 'REMOVED', 'CONTEXT'], 179 | }, 180 | suggestion: { 181 | type: 'string', 182 | 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)', 183 | }, 184 | suggestion_end_line: { 185 | type: 'number', 186 | description: 'For multi-line suggestions: the last line number to replace. If not provided, only replaces the single line at line_number (optional)', 187 | }, 188 | code_snippet: { 189 | type: 'string', 190 | 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)', 191 | }, 192 | search_context: { 193 | type: 'object', 194 | properties: { 195 | before: { 196 | type: 'array', 197 | items: { type: 'string' }, 198 | description: 'Array of code lines that appear BEFORE the target line. Helps disambiguate when code_snippet appears multiple times', 199 | }, 200 | after: { 201 | type: 'array', 202 | items: { type: 'string' }, 203 | description: 'Array of code lines that appear AFTER the target line. Helps disambiguate when code_snippet appears multiple times', 204 | }, 205 | }, 206 | description: 'Additional context lines to help locate the exact position when using code_snippet. Useful when the same code appears multiple times (optional)', 207 | }, 208 | match_strategy: { 209 | type: 'string', 210 | enum: ['strict', 'best'], 211 | 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"', 212 | }, 213 | }, 214 | required: ['workspace', 'repository', 'pull_request_id', 'comment_text'], 215 | }, 216 | }, 217 | { 218 | name: 'merge_pull_request', 219 | description: 'Merge a pull request', 220 | inputSchema: { 221 | type: 'object', 222 | properties: { 223 | workspace: { 224 | type: 'string', 225 | description: 'Bitbucket workspace/project key (e.g., "PROJ")', 226 | }, 227 | repository: { 228 | type: 'string', 229 | description: 'Repository slug (e.g., "my-repo")', 230 | }, 231 | pull_request_id: { 232 | type: 'number', 233 | description: 'Pull request ID', 234 | }, 235 | merge_strategy: { 236 | type: 'string', 237 | description: 'Merge strategy: merge-commit, squash, fast-forward (optional)', 238 | enum: ['merge-commit', 'squash', 'fast-forward'], 239 | }, 240 | close_source_branch: { 241 | type: 'boolean', 242 | description: 'Whether to close source branch after merge (optional)', 243 | }, 244 | commit_message: { 245 | type: 'string', 246 | description: 'Custom merge commit message (optional)', 247 | }, 248 | }, 249 | required: ['workspace', 'repository', 'pull_request_id'], 250 | }, 251 | }, 252 | { 253 | name: 'list_branches', 254 | description: 'List branches in a repository', 255 | inputSchema: { 256 | type: 'object', 257 | properties: { 258 | workspace: { 259 | type: 'string', 260 | description: 'Bitbucket workspace/project key (e.g., "PROJ")', 261 | }, 262 | repository: { 263 | type: 'string', 264 | description: 'Repository slug (e.g., "my-repo")', 265 | }, 266 | filter: { 267 | type: 'string', 268 | description: 'Filter branches by name pattern (optional)', 269 | }, 270 | limit: { 271 | type: 'number', 272 | description: 'Maximum number of branches to return (default: 25)', 273 | }, 274 | start: { 275 | type: 'number', 276 | description: 'Start index for pagination (default: 0)', 277 | }, 278 | }, 279 | required: ['workspace', 'repository'], 280 | }, 281 | }, 282 | { 283 | name: 'delete_branch', 284 | description: 'Delete a branch', 285 | inputSchema: { 286 | type: 'object', 287 | properties: { 288 | workspace: { 289 | type: 'string', 290 | description: 'Bitbucket workspace/project key (e.g., "PROJ")', 291 | }, 292 | repository: { 293 | type: 'string', 294 | description: 'Repository slug (e.g., "my-repo")', 295 | }, 296 | branch_name: { 297 | type: 'string', 298 | description: 'Branch name to delete', 299 | }, 300 | force: { 301 | type: 'boolean', 302 | description: 'Force delete even if branch is not merged (optional, default: false)', 303 | }, 304 | }, 305 | required: ['workspace', 'repository', 'branch_name'], 306 | }, 307 | }, 308 | { 309 | name: 'get_pull_request_diff', 310 | description: 'Get the diff/changes for a pull request with optional filtering', 311 | inputSchema: { 312 | type: 'object', 313 | properties: { 314 | workspace: { 315 | type: 'string', 316 | description: 'Bitbucket workspace/project key (e.g., "PROJ")', 317 | }, 318 | repository: { 319 | type: 'string', 320 | description: 'Repository slug (e.g., "my-repo")', 321 | }, 322 | pull_request_id: { 323 | type: 'number', 324 | description: 'Pull request ID', 325 | }, 326 | context_lines: { 327 | type: 'number', 328 | description: 'Number of context lines around changes (optional, default: 3)', 329 | }, 330 | include_patterns: { 331 | type: 'array', 332 | items: { type: 'string' }, 333 | description: 'Array of glob patterns to include (e.g., ["*.res", "src/**/*.js"]) (optional)', 334 | }, 335 | exclude_patterns: { 336 | type: 'array', 337 | items: { type: 'string' }, 338 | description: 'Array of glob patterns to exclude (e.g., ["*.lock", "*.svg"]) (optional)', 339 | }, 340 | file_path: { 341 | type: 'string', 342 | description: 'Specific file path to get diff for (e.g., "src/index.ts") (optional)', 343 | }, 344 | }, 345 | required: ['workspace', 'repository', 'pull_request_id'], 346 | }, 347 | }, 348 | { 349 | name: 'approve_pull_request', 350 | description: 'Approve a pull request', 351 | inputSchema: { 352 | type: 'object', 353 | properties: { 354 | workspace: { 355 | type: 'string', 356 | description: 'Bitbucket workspace/project key (e.g., "PROJ")', 357 | }, 358 | repository: { 359 | type: 'string', 360 | description: 'Repository slug (e.g., "my-repo")', 361 | }, 362 | pull_request_id: { 363 | type: 'number', 364 | description: 'Pull request ID', 365 | }, 366 | }, 367 | required: ['workspace', 'repository', 'pull_request_id'], 368 | }, 369 | }, 370 | { 371 | name: 'unapprove_pull_request', 372 | description: 'Remove approval from a pull request', 373 | inputSchema: { 374 | type: 'object', 375 | properties: { 376 | workspace: { 377 | type: 'string', 378 | description: 'Bitbucket workspace/project key (e.g., "PROJ")', 379 | }, 380 | repository: { 381 | type: 'string', 382 | description: 'Repository slug (e.g., "my-repo")', 383 | }, 384 | pull_request_id: { 385 | type: 'number', 386 | description: 'Pull request ID', 387 | }, 388 | }, 389 | required: ['workspace', 'repository', 'pull_request_id'], 390 | }, 391 | }, 392 | { 393 | name: 'request_changes', 394 | description: 'Request changes on a pull request', 395 | inputSchema: { 396 | type: 'object', 397 | properties: { 398 | workspace: { 399 | type: 'string', 400 | description: 'Bitbucket workspace/project key (e.g., "PROJ")', 401 | }, 402 | repository: { 403 | type: 'string', 404 | description: 'Repository slug (e.g., "my-repo")', 405 | }, 406 | pull_request_id: { 407 | type: 'number', 408 | description: 'Pull request ID', 409 | }, 410 | comment: { 411 | type: 'string', 412 | description: 'Comment explaining requested changes (optional)', 413 | }, 414 | }, 415 | required: ['workspace', 'repository', 'pull_request_id'], 416 | }, 417 | }, 418 | { 419 | name: 'remove_requested_changes', 420 | description: 'Remove change request from a pull request', 421 | inputSchema: { 422 | type: 'object', 423 | properties: { 424 | workspace: { 425 | type: 'string', 426 | description: 'Bitbucket workspace/project key (e.g., "PROJ")', 427 | }, 428 | repository: { 429 | type: 'string', 430 | description: 'Repository slug (e.g., "my-repo")', 431 | }, 432 | pull_request_id: { 433 | type: 'number', 434 | description: 'Pull request ID', 435 | }, 436 | }, 437 | required: ['workspace', 'repository', 'pull_request_id'], 438 | }, 439 | }, 440 | { 441 | name: 'get_branch', 442 | description: 'Get detailed information about a branch including associated pull requests', 443 | inputSchema: { 444 | type: 'object', 445 | properties: { 446 | workspace: { 447 | type: 'string', 448 | description: 'Bitbucket workspace/project key (e.g., "PROJ")', 449 | }, 450 | repository: { 451 | type: 'string', 452 | description: 'Repository slug (e.g., "my-repo")', 453 | }, 454 | branch_name: { 455 | type: 'string', 456 | description: 'Branch name to get details for', 457 | }, 458 | include_merged_prs: { 459 | type: 'boolean', 460 | description: 'Include merged PRs from this branch (default: false)', 461 | }, 462 | }, 463 | required: ['workspace', 'repository', 'branch_name'], 464 | }, 465 | }, 466 | { 467 | name: 'list_directory_content', 468 | description: 'List files and directories in a repository path', 469 | inputSchema: { 470 | type: 'object', 471 | properties: { 472 | workspace: { 473 | type: 'string', 474 | description: 'Bitbucket workspace/project key (e.g., "PROJ")', 475 | }, 476 | repository: { 477 | type: 'string', 478 | description: 'Repository slug (e.g., "my-repo")', 479 | }, 480 | path: { 481 | type: 'string', 482 | description: 'Directory path (optional, defaults to root, e.g., "src/components")', 483 | }, 484 | branch: { 485 | type: 'string', 486 | description: 'Branch name (optional, defaults to default branch)', 487 | }, 488 | }, 489 | required: ['workspace', 'repository'], 490 | }, 491 | }, 492 | { 493 | name: 'get_file_content', 494 | description: 'Get file content from a repository with smart truncation for large files', 495 | inputSchema: { 496 | type: 'object', 497 | properties: { 498 | workspace: { 499 | type: 'string', 500 | description: 'Bitbucket workspace/project key (e.g., "PROJ")', 501 | }, 502 | repository: { 503 | type: 'string', 504 | description: 'Repository slug (e.g., "my-repo")', 505 | }, 506 | file_path: { 507 | type: 'string', 508 | description: 'Path to the file (e.g., "src/index.ts")', 509 | }, 510 | branch: { 511 | type: 'string', 512 | description: 'Branch name (optional, defaults to default branch)', 513 | }, 514 | start_line: { 515 | type: 'number', 516 | description: 'Starting line number (1-based). Use negative for lines from end (optional)', 517 | }, 518 | line_count: { 519 | type: 'number', 520 | description: 'Number of lines to return (optional, default varies by file size)', 521 | }, 522 | full_content: { 523 | type: 'boolean', 524 | description: 'Force return full content regardless of size (optional, default: false)', 525 | }, 526 | }, 527 | required: ['workspace', 'repository', 'file_path'], 528 | }, 529 | }, 530 | { 531 | name: 'list_branch_commits', 532 | description: 'List commits in a branch with detailed information and filtering options', 533 | inputSchema: { 534 | type: 'object', 535 | properties: { 536 | workspace: { 537 | type: 'string', 538 | description: 'Bitbucket workspace/project key (e.g., "PROJ")', 539 | }, 540 | repository: { 541 | type: 'string', 542 | description: 'Repository slug (e.g., "my-repo")', 543 | }, 544 | branch_name: { 545 | type: 'string', 546 | description: 'Branch name to get commits from', 547 | }, 548 | limit: { 549 | type: 'number', 550 | description: 'Maximum number of commits to return (default: 25)', 551 | }, 552 | start: { 553 | type: 'number', 554 | description: 'Start index for pagination (default: 0)', 555 | }, 556 | since: { 557 | type: 'string', 558 | description: 'ISO date string - only show commits after this date (optional)', 559 | }, 560 | until: { 561 | type: 'string', 562 | description: 'ISO date string - only show commits before this date (optional)', 563 | }, 564 | author: { 565 | type: 'string', 566 | description: 'Filter by author email/username (optional)', 567 | }, 568 | include_merge_commits: { 569 | type: 'boolean', 570 | description: 'Include merge commits in results (default: true)', 571 | }, 572 | search: { 573 | type: 'string', 574 | description: 'Search for text in commit messages (optional)', 575 | }, 576 | include_build_status: { 577 | type: 'boolean', 578 | description: 'Include CI/CD build status for each commit (Bitbucket Server only, default: false)', 579 | }, 580 | }, 581 | required: ['workspace', 'repository', 'branch_name'], 582 | }, 583 | }, 584 | { 585 | name: 'list_pr_commits', 586 | description: 'List all commits that are part of a pull request', 587 | inputSchema: { 588 | type: 'object', 589 | properties: { 590 | workspace: { 591 | type: 'string', 592 | description: 'Bitbucket workspace/project key (e.g., "PROJ")', 593 | }, 594 | repository: { 595 | type: 'string', 596 | description: 'Repository slug (e.g., "my-repo")', 597 | }, 598 | pull_request_id: { 599 | type: 'number', 600 | description: 'Pull request ID', 601 | }, 602 | limit: { 603 | type: 'number', 604 | description: 'Maximum number of commits to return (default: 25)', 605 | }, 606 | start: { 607 | type: 'number', 608 | description: 'Start index for pagination (default: 0)', 609 | }, 610 | include_build_status: { 611 | type: 'boolean', 612 | description: 'Include CI/CD build status for each commit (Bitbucket Server only, default: false)', 613 | }, 614 | }, 615 | required: ['workspace', 'repository', 'pull_request_id'], 616 | }, 617 | }, 618 | { 619 | name: 'search_code', 620 | description: 'Search for code across Bitbucket repositories with enhanced context-aware search patterns (currently only supported for Bitbucket Server)', 621 | inputSchema: { 622 | type: 'object', 623 | properties: { 624 | workspace: { 625 | type: 'string', 626 | description: 'Bitbucket workspace/project key (e.g., "PROJ")', 627 | }, 628 | repository: { 629 | type: 'string', 630 | description: 'Repository slug to search in (optional, searches all repos if not specified)', 631 | }, 632 | search_query: { 633 | type: 'string', 634 | description: 'The search term or phrase to look for in code (e.g., "variable")', 635 | }, 636 | search_context: { 637 | type: 'string', 638 | enum: ['assignment', 'declaration', 'usage', 'exact', 'any'], 639 | description: 'Context to search for: assignment (term=value), declaration (defining term), usage (calling/accessing term), exact (quoted match), or any (all patterns)', 640 | }, 641 | file_pattern: { 642 | type: 'string', 643 | description: 'File path pattern to filter results (e.g., "*.java", "src/**/*.ts") (optional)', 644 | }, 645 | include_patterns: { 646 | type: 'array', 647 | items: { type: 'string' }, 648 | description: 'Additional custom search patterns to include (e.g., ["variable =", ".variable"]) (optional)', 649 | }, 650 | limit: { 651 | type: 'number', 652 | description: 'Maximum number of results to return (default: 25)', 653 | }, 654 | start: { 655 | type: 'number', 656 | description: 'Start index for pagination (default: 0)', 657 | }, 658 | }, 659 | required: ['workspace', 'search_query'], 660 | }, 661 | }, 662 | { 663 | name: 'list_projects', 664 | description: 'List all accessible Bitbucket projects with optional filtering', 665 | inputSchema: { 666 | type: 'object', 667 | properties: { 668 | name: { 669 | type: 'string', 670 | description: 'Filter by project name (partial match, optional)', 671 | }, 672 | permission: { 673 | type: 'string', 674 | description: 'Filter by permission level (e.g., PROJECT_READ, PROJECT_WRITE, PROJECT_ADMIN, optional)', 675 | }, 676 | limit: { 677 | type: 'number', 678 | description: 'Maximum number of projects to return (default: 25)', 679 | }, 680 | start: { 681 | type: 'number', 682 | description: 'Start index for pagination (default: 0)', 683 | }, 684 | }, 685 | required: [], 686 | }, 687 | }, 688 | { 689 | name: 'list_repositories', 690 | description: 'List repositories in a project or across all accessible projects', 691 | inputSchema: { 692 | type: 'object', 693 | properties: { 694 | workspace: { 695 | type: 'string', 696 | description: 'Bitbucket workspace/project key to filter repositories (optional, if not provided lists all accessible repos)', 697 | }, 698 | name: { 699 | type: 'string', 700 | description: 'Filter by repository name (partial match, optional)', 701 | }, 702 | permission: { 703 | type: 'string', 704 | description: 'Filter by permission level (e.g., REPO_READ, REPO_WRITE, REPO_ADMIN, optional)', 705 | }, 706 | limit: { 707 | type: 'number', 708 | description: 'Maximum number of repositories to return (default: 25)', 709 | }, 710 | start: { 711 | type: 'number', 712 | description: 'Start index for pagination (default: 0)', 713 | }, 714 | }, 715 | required: [], 716 | }, 717 | }, 718 | ]; 719 | ``` -------------------------------------------------------------------------------- /src/handlers/pull-request-handlers.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; 2 | import { BitbucketApiClient } from '../utils/api-client.js'; 3 | import { formatServerResponse, formatCloudResponse, formatServerCommit, formatCloudCommit } from '../utils/formatters.js'; 4 | import { formatSuggestionComment } from '../utils/suggestion-formatter.js'; 5 | import { DiffParser } from '../utils/diff-parser.js'; 6 | import { 7 | BitbucketServerPullRequest, 8 | BitbucketCloudPullRequest, 9 | BitbucketServerActivity, 10 | MergeInfo, 11 | BitbucketCloudComment, 12 | BitbucketCloudFileChange, 13 | FormattedComment, 14 | FormattedFileChange, 15 | CodeMatch, 16 | MultipleMatchesError, 17 | BitbucketServerCommit, 18 | BitbucketCloudCommit, 19 | FormattedCommit 20 | } from '../types/bitbucket.js'; 21 | import { 22 | isGetPullRequestArgs, 23 | isListPullRequestsArgs, 24 | isCreatePullRequestArgs, 25 | isUpdatePullRequestArgs, 26 | isAddCommentArgs, 27 | isMergePullRequestArgs, 28 | isListPrCommitsArgs 29 | } from '../types/guards.js'; 30 | 31 | export class PullRequestHandlers { 32 | constructor( 33 | private apiClient: BitbucketApiClient, 34 | private baseUrl: string, 35 | private username: string 36 | ) {} 37 | 38 | private async getFilteredPullRequestDiff( 39 | workspace: string, 40 | repository: string, 41 | pullRequestId: number, 42 | filePath: string, 43 | contextLines: number = 3 44 | ): Promise<string> { 45 | let apiPath: string; 46 | let config: any = {}; 47 | 48 | if (this.apiClient.getIsServer()) { 49 | // Bitbucket Server API 50 | apiPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pullRequestId}/diff`; 51 | config.params = { contextLines }; 52 | } else { 53 | // Bitbucket Cloud API 54 | apiPath = `/repositories/${workspace}/${repository}/pullrequests/${pullRequestId}/diff`; 55 | config.params = { context: contextLines }; 56 | } 57 | 58 | config.headers = { 'Accept': 'text/plain' }; 59 | 60 | const rawDiff = await this.apiClient.makeRequest<string>('get', apiPath, undefined, config); 61 | 62 | const diffParser = new DiffParser(); 63 | const sections = diffParser.parseDiffIntoSections(rawDiff); 64 | 65 | const filterOptions = { 66 | filePath: filePath 67 | }; 68 | 69 | const filteredResult = diffParser.filterSections(sections, filterOptions); 70 | const filteredDiff = diffParser.reconstructDiff(filteredResult.sections); 71 | 72 | return filteredDiff; 73 | } 74 | 75 | async handleGetPullRequest(args: any) { 76 | if (!isGetPullRequestArgs(args)) { 77 | throw new McpError( 78 | ErrorCode.InvalidParams, 79 | 'Invalid arguments for get_pull_request' 80 | ); 81 | } 82 | 83 | const { workspace, repository, pull_request_id } = args; 84 | 85 | try { 86 | const apiPath = this.apiClient.getIsServer() 87 | ? `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pull_request_id}` 88 | : `/repositories/${workspace}/${repository}/pullrequests/${pull_request_id}`; 89 | 90 | const pr = await this.apiClient.makeRequest<any>('get', apiPath); 91 | 92 | let mergeInfo: MergeInfo = {}; 93 | 94 | if (this.apiClient.getIsServer() && pr.state === 'MERGED') { 95 | try { 96 | const activitiesPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pull_request_id}/activities`; 97 | const activitiesResponse = await this.apiClient.makeRequest<any>('get', activitiesPath, undefined, { 98 | params: { limit: 100 } 99 | }); 100 | 101 | const activities = activitiesResponse.values || []; 102 | const mergeActivity = activities.find((a: BitbucketServerActivity) => a.action === 'MERGED'); 103 | 104 | if (mergeActivity) { 105 | mergeInfo.mergeCommitHash = mergeActivity.commit?.id || null; 106 | mergeInfo.mergedBy = mergeActivity.user?.displayName || null; 107 | mergeInfo.mergedAt = new Date(mergeActivity.createdDate).toISOString(); 108 | 109 | if (mergeActivity.commit?.id) { 110 | try { 111 | const commitPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/commits/${mergeActivity.commit.id}`; 112 | const commitResponse = await this.apiClient.makeRequest<any>('get', commitPath); 113 | mergeInfo.mergeCommitMessage = commitResponse.message || null; 114 | } catch (commitError) { 115 | console.error('Failed to fetch merge commit message:', commitError); 116 | } 117 | } 118 | } 119 | } catch (activitiesError) { 120 | console.error('Failed to fetch PR activities:', activitiesError); 121 | } 122 | } 123 | 124 | let comments: FormattedComment[] = []; 125 | let activeCommentCount = 0; 126 | let totalCommentCount = 0; 127 | let fileChanges: FormattedFileChange[] = []; 128 | let fileChangesSummary: any = null; 129 | 130 | try { 131 | const [commentsResult, fileChangesResult] = await Promise.all([ 132 | this.fetchPullRequestComments(workspace, repository, pull_request_id), 133 | this.fetchPullRequestFileChanges(workspace, repository, pull_request_id) 134 | ]); 135 | 136 | comments = commentsResult.comments; 137 | activeCommentCount = commentsResult.activeCount; 138 | totalCommentCount = commentsResult.totalCount; 139 | fileChanges = fileChangesResult.fileChanges; 140 | fileChangesSummary = fileChangesResult.summary; 141 | } catch (error) { 142 | console.error('Failed to fetch additional PR data:', error); 143 | } 144 | 145 | const formattedResponse = this.apiClient.getIsServer() 146 | ? formatServerResponse(pr as BitbucketServerPullRequest, mergeInfo, this.baseUrl) 147 | : formatCloudResponse(pr as BitbucketCloudPullRequest); 148 | 149 | const enhancedResponse = { 150 | ...formattedResponse, 151 | active_comments: comments, 152 | active_comment_count: activeCommentCount, 153 | total_comment_count: totalCommentCount, 154 | file_changes: fileChanges, 155 | file_changes_summary: fileChangesSummary 156 | }; 157 | 158 | return { 159 | content: [ 160 | { 161 | type: 'text', 162 | text: JSON.stringify(enhancedResponse, null, 2), 163 | }, 164 | ], 165 | }; 166 | } catch (error) { 167 | return this.apiClient.handleApiError(error, `getting pull request ${pull_request_id} in ${workspace}/${repository}`); 168 | } 169 | } 170 | 171 | async handleListPullRequests(args: any) { 172 | if (!isListPullRequestsArgs(args)) { 173 | throw new McpError( 174 | ErrorCode.InvalidParams, 175 | 'Invalid arguments for list_pull_requests' 176 | ); 177 | } 178 | 179 | const { workspace, repository, state = 'OPEN', author, limit = 25, start = 0 } = args; 180 | 181 | try { 182 | let apiPath: string; 183 | let params: any = {}; 184 | 185 | if (this.apiClient.getIsServer()) { 186 | // Bitbucket Server API 187 | apiPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests`; 188 | params = { 189 | state: state === 'ALL' ? undefined : state, 190 | limit, 191 | start, 192 | }; 193 | if (author) { 194 | params['role.1'] = 'AUTHOR'; 195 | params['username.1'] = author; 196 | } 197 | } else { 198 | // Bitbucket Cloud API 199 | apiPath = `/repositories/${workspace}/${repository}/pullrequests`; 200 | params = { 201 | state: state === 'ALL' ? undefined : state, 202 | pagelen: limit, 203 | page: Math.floor(start / limit) + 1, 204 | }; 205 | if (author) { 206 | params['q'] = `author.username="${author}"`; 207 | } 208 | } 209 | 210 | const response = await this.apiClient.makeRequest<any>('get', apiPath, undefined, { params }); 211 | 212 | let pullRequests: any[] = []; 213 | let totalCount = 0; 214 | let nextPageStart = null; 215 | 216 | if (this.apiClient.getIsServer()) { 217 | pullRequests = (response.values || []).map((pr: BitbucketServerPullRequest) => 218 | formatServerResponse(pr, undefined, this.baseUrl) 219 | ); 220 | totalCount = response.size || 0; 221 | if (!response.isLastPage && response.nextPageStart !== undefined) { 222 | nextPageStart = response.nextPageStart; 223 | } 224 | } else { 225 | pullRequests = (response.values || []).map((pr: BitbucketCloudPullRequest) => 226 | formatCloudResponse(pr) 227 | ); 228 | totalCount = response.size || 0; 229 | if (response.next) { 230 | nextPageStart = start + limit; 231 | } 232 | } 233 | 234 | return { 235 | content: [ 236 | { 237 | type: 'text', 238 | text: JSON.stringify({ 239 | pull_requests: pullRequests, 240 | total_count: totalCount, 241 | start, 242 | limit, 243 | has_more: nextPageStart !== null, 244 | next_start: nextPageStart, 245 | }, null, 2), 246 | }, 247 | ], 248 | }; 249 | } catch (error) { 250 | return this.apiClient.handleApiError(error, `listing pull requests in ${workspace}/${repository}`); 251 | } 252 | } 253 | 254 | async handleCreatePullRequest(args: any) { 255 | if (!isCreatePullRequestArgs(args)) { 256 | throw new McpError( 257 | ErrorCode.InvalidParams, 258 | 'Invalid arguments for create_pull_request' 259 | ); 260 | } 261 | 262 | const { workspace, repository, title, source_branch, destination_branch, description, reviewers, close_source_branch } = args; 263 | 264 | try { 265 | let apiPath: string; 266 | let requestBody: any; 267 | 268 | if (this.apiClient.getIsServer()) { 269 | // Bitbucket Server API 270 | apiPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests`; 271 | requestBody = { 272 | title, 273 | description: description || '', 274 | fromRef: { 275 | id: `refs/heads/${source_branch}`, 276 | repository: { 277 | slug: repository, 278 | project: { 279 | key: workspace 280 | } 281 | } 282 | }, 283 | toRef: { 284 | id: `refs/heads/${destination_branch}`, 285 | repository: { 286 | slug: repository, 287 | project: { 288 | key: workspace 289 | } 290 | } 291 | }, 292 | reviewers: reviewers?.map(r => ({ user: { name: r } })) || [] 293 | }; 294 | } else { 295 | // Bitbucket Cloud API 296 | apiPath = `/repositories/${workspace}/${repository}/pullrequests`; 297 | requestBody = { 298 | title, 299 | description: description || '', 300 | source: { 301 | branch: { 302 | name: source_branch 303 | } 304 | }, 305 | destination: { 306 | branch: { 307 | name: destination_branch 308 | } 309 | }, 310 | close_source_branch: close_source_branch || false, 311 | reviewers: reviewers?.map(r => ({ username: r })) || [] 312 | }; 313 | } 314 | 315 | const pr = await this.apiClient.makeRequest<any>('post', apiPath, requestBody); 316 | 317 | const formattedResponse = this.apiClient.getIsServer() 318 | ? formatServerResponse(pr as BitbucketServerPullRequest, undefined, this.baseUrl) 319 | : formatCloudResponse(pr as BitbucketCloudPullRequest); 320 | 321 | return { 322 | content: [ 323 | { 324 | type: 'text', 325 | text: JSON.stringify({ 326 | message: 'Pull request created successfully', 327 | pull_request: formattedResponse 328 | }, null, 2), 329 | }, 330 | ], 331 | }; 332 | } catch (error) { 333 | return this.apiClient.handleApiError(error, `creating pull request in ${workspace}/${repository}`); 334 | } 335 | } 336 | 337 | async handleUpdatePullRequest(args: any) { 338 | if (!isUpdatePullRequestArgs(args)) { 339 | throw new McpError( 340 | ErrorCode.InvalidParams, 341 | 'Invalid arguments for update_pull_request' 342 | ); 343 | } 344 | 345 | const { workspace, repository, pull_request_id, title, description, destination_branch, reviewers } = args; 346 | 347 | try { 348 | let apiPath: string; 349 | let requestBody: any = {}; 350 | 351 | if (this.apiClient.getIsServer()) { 352 | // Bitbucket Server API 353 | apiPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pull_request_id}`; 354 | 355 | // First get the current PR to get version number and existing data 356 | const currentPr = await this.apiClient.makeRequest<any>('get', apiPath); 357 | 358 | requestBody.version = currentPr.version; 359 | if (title !== undefined) requestBody.title = title; 360 | if (description !== undefined) requestBody.description = description; 361 | if (destination_branch !== undefined) { 362 | requestBody.toRef = { 363 | id: `refs/heads/${destination_branch}`, 364 | repository: { 365 | slug: repository, 366 | project: { 367 | key: workspace 368 | } 369 | } 370 | }; 371 | } 372 | 373 | // Handle reviewers: preserve existing ones if not explicitly updating 374 | if (reviewers !== undefined) { 375 | // User wants to update reviewers 376 | // Create a map of existing reviewers for preservation of approval status 377 | const existingReviewersMap = new Map( 378 | currentPr.reviewers.map((r: any) => [r.user.name, r]) 379 | ); 380 | 381 | requestBody.reviewers = reviewers.map(username => { 382 | const existing = existingReviewersMap.get(username); 383 | if (existing) { 384 | // Preserve existing reviewer's full data including approval status 385 | return existing; 386 | } else { 387 | // Add new reviewer (without approval status) 388 | return { user: { name: username } }; 389 | } 390 | }); 391 | } else { 392 | // No reviewers provided - preserve existing reviewers with their full data 393 | requestBody.reviewers = currentPr.reviewers; 394 | } 395 | } else { 396 | // Bitbucket Cloud API 397 | apiPath = `/repositories/${workspace}/${repository}/pullrequests/${pull_request_id}`; 398 | 399 | if (title !== undefined) requestBody.title = title; 400 | if (description !== undefined) requestBody.description = description; 401 | if (destination_branch !== undefined) { 402 | requestBody.destination = { 403 | branch: { 404 | name: destination_branch 405 | } 406 | }; 407 | } 408 | if (reviewers !== undefined) { 409 | requestBody.reviewers = reviewers.map(r => ({ username: r })); 410 | } 411 | } 412 | 413 | const pr = await this.apiClient.makeRequest<any>('put', apiPath, requestBody); 414 | 415 | const formattedResponse = this.apiClient.getIsServer() 416 | ? formatServerResponse(pr as BitbucketServerPullRequest, undefined, this.baseUrl) 417 | : formatCloudResponse(pr as BitbucketCloudPullRequest); 418 | 419 | return { 420 | content: [ 421 | { 422 | type: 'text', 423 | text: JSON.stringify({ 424 | message: 'Pull request updated successfully', 425 | pull_request: formattedResponse 426 | }, null, 2), 427 | }, 428 | ], 429 | }; 430 | } catch (error) { 431 | return this.apiClient.handleApiError(error, `updating pull request ${pull_request_id} in ${workspace}/${repository}`); 432 | } 433 | } 434 | 435 | async handleAddComment(args: any) { 436 | if (!isAddCommentArgs(args)) { 437 | throw new McpError( 438 | ErrorCode.InvalidParams, 439 | 'Invalid arguments for add_comment' 440 | ); 441 | } 442 | 443 | let { 444 | workspace, 445 | repository, 446 | pull_request_id, 447 | comment_text, 448 | parent_comment_id, 449 | file_path, 450 | line_number, 451 | line_type, 452 | suggestion, 453 | suggestion_end_line, 454 | code_snippet, 455 | search_context, 456 | match_strategy = 'strict' 457 | } = args; 458 | 459 | let sequentialPosition: number | undefined; 460 | if (code_snippet && !line_number && file_path) { 461 | try { 462 | const resolved = await this.resolveLineFromCode( 463 | workspace, 464 | repository, 465 | pull_request_id, 466 | file_path, 467 | code_snippet, 468 | search_context, 469 | match_strategy 470 | ); 471 | 472 | line_number = resolved.line_number; 473 | line_type = resolved.line_type; 474 | sequentialPosition = resolved.sequential_position; 475 | } catch (error) { 476 | throw error; 477 | } 478 | } 479 | 480 | if (suggestion && (!file_path || !line_number)) { 481 | throw new McpError( 482 | ErrorCode.InvalidParams, 483 | 'Suggestions require file_path and line_number to be specified' 484 | ); 485 | } 486 | 487 | const isInlineComment = file_path !== undefined && line_number !== undefined; 488 | 489 | let finalCommentText = comment_text; 490 | if (suggestion) { 491 | finalCommentText = formatSuggestionComment( 492 | comment_text, 493 | suggestion, 494 | line_number, 495 | suggestion_end_line || line_number 496 | ); 497 | } 498 | 499 | try { 500 | let apiPath: string; 501 | let requestBody: any; 502 | 503 | if (this.apiClient.getIsServer()) { 504 | // Bitbucket Server API 505 | apiPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pull_request_id}/comments`; 506 | requestBody = { 507 | text: finalCommentText 508 | }; 509 | 510 | if (parent_comment_id !== undefined) { 511 | requestBody.parent = { id: parent_comment_id }; 512 | } 513 | 514 | if (isInlineComment) { 515 | requestBody.anchor = { 516 | line: line_number, 517 | lineType: line_type || 'CONTEXT', 518 | fileType: line_type === 'REMOVED' ? 'FROM' : 'TO', 519 | path: file_path, 520 | diffType: 'EFFECTIVE' 521 | }; 522 | 523 | } 524 | } else { 525 | // Bitbucket Cloud API 526 | apiPath = `/repositories/${workspace}/${repository}/pullrequests/${pull_request_id}/comments`; 527 | requestBody = { 528 | content: { 529 | raw: finalCommentText 530 | } 531 | }; 532 | 533 | if (parent_comment_id !== undefined) { 534 | requestBody.parent = { id: parent_comment_id }; 535 | } 536 | 537 | if (isInlineComment) { 538 | requestBody.inline = { 539 | to: line_number, 540 | path: file_path 541 | }; 542 | } 543 | } 544 | 545 | const comment = await this.apiClient.makeRequest<any>('post', apiPath, requestBody); 546 | 547 | const responseMessage = suggestion 548 | ? 'Comment with code suggestion added successfully' 549 | : (isInlineComment ? 'Inline comment added successfully' : 'Comment added successfully'); 550 | 551 | return { 552 | content: [ 553 | { 554 | type: 'text', 555 | text: JSON.stringify({ 556 | message: responseMessage, 557 | comment: { 558 | id: comment.id, 559 | text: this.apiClient.getIsServer() ? comment.text : comment.content.raw, 560 | author: this.apiClient.getIsServer() ? comment.author.displayName : comment.user.display_name, 561 | created_on: this.apiClient.getIsServer() ? new Date(comment.createdDate).toLocaleString() : comment.created_on, 562 | file_path: isInlineComment ? file_path : undefined, 563 | line_number: isInlineComment ? line_number : undefined, 564 | line_type: isInlineComment ? (line_type || 'CONTEXT') : undefined, 565 | has_suggestion: !!suggestion, 566 | suggestion_lines: suggestion ? (suggestion_end_line ? `${line_number}-${suggestion_end_line}` : `${line_number}`) : undefined 567 | } 568 | }, null, 2), 569 | }, 570 | ], 571 | }; 572 | } catch (error) { 573 | return this.apiClient.handleApiError(error, `adding ${isInlineComment ? 'inline ' : ''}comment to pull request ${pull_request_id} in ${workspace}/${repository}`); 574 | } 575 | } 576 | 577 | async handleMergePullRequest(args: any) { 578 | if (!isMergePullRequestArgs(args)) { 579 | throw new McpError( 580 | ErrorCode.InvalidParams, 581 | 'Invalid arguments for merge_pull_request' 582 | ); 583 | } 584 | 585 | const { workspace, repository, pull_request_id, merge_strategy, close_source_branch, commit_message } = args; 586 | 587 | try { 588 | let apiPath: string; 589 | let requestBody: any = {}; 590 | 591 | if (this.apiClient.getIsServer()) { 592 | // Bitbucket Server API 593 | apiPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pull_request_id}/merge`; 594 | 595 | // Get current PR version 596 | const prPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pull_request_id}`; 597 | const currentPr = await this.apiClient.makeRequest<any>('get', prPath); 598 | 599 | requestBody.version = currentPr.version; 600 | if (commit_message) { 601 | requestBody.message = commit_message; 602 | } 603 | } else { 604 | // Bitbucket Cloud API 605 | apiPath = `/repositories/${workspace}/${repository}/pullrequests/${pull_request_id}/merge`; 606 | 607 | if (merge_strategy) { 608 | requestBody.merge_strategy = merge_strategy; 609 | } 610 | if (close_source_branch !== undefined) { 611 | requestBody.close_source_branch = close_source_branch; 612 | } 613 | if (commit_message) { 614 | requestBody.message = commit_message; 615 | } 616 | } 617 | 618 | const result = await this.apiClient.makeRequest<any>('post', apiPath, requestBody); 619 | 620 | return { 621 | content: [ 622 | { 623 | type: 'text', 624 | text: JSON.stringify({ 625 | message: 'Pull request merged successfully', 626 | merge_commit: this.apiClient.getIsServer() ? result.properties?.mergeCommit : result.merge_commit?.hash, 627 | pull_request_id 628 | }, null, 2), 629 | }, 630 | ], 631 | }; 632 | } catch (error) { 633 | return this.apiClient.handleApiError(error, `merging pull request ${pull_request_id} in ${workspace}/${repository}`); 634 | } 635 | } 636 | 637 | private async fetchPullRequestComments( 638 | workspace: string, 639 | repository: string, 640 | pullRequestId: number 641 | ): Promise<{ comments: FormattedComment[]; activeCount: number; totalCount: number }> { 642 | try { 643 | let comments: FormattedComment[] = []; 644 | let activeCount = 0; 645 | let totalCount = 0; 646 | 647 | if (this.apiClient.getIsServer()) { 648 | const processNestedComments = (comment: any, anchor: any): FormattedComment => { 649 | const formattedComment: FormattedComment = { 650 | id: comment.id, 651 | author: comment.author.displayName, 652 | text: comment.text, 653 | created_on: new Date(comment.createdDate).toISOString(), 654 | is_inline: !!anchor, 655 | file_path: anchor?.path, 656 | line_number: anchor?.line, 657 | state: comment.state 658 | }; 659 | 660 | if (comment.comments && comment.comments.length > 0) { 661 | formattedComment.replies = comment.comments 662 | .filter((reply: any) => { 663 | if (reply.state === 'RESOLVED') return false; 664 | if (anchor && anchor.orphaned === true) return false; 665 | return true; 666 | }) 667 | .map((reply: any) => processNestedComments(reply, anchor)); 668 | } 669 | 670 | return formattedComment; 671 | }; 672 | 673 | const countAllComments = (comment: any): number => { 674 | let count = 1; 675 | if (comment.comments && comment.comments.length > 0) { 676 | count += comment.comments.reduce((sum: number, reply: any) => sum + countAllComments(reply), 0); 677 | } 678 | return count; 679 | }; 680 | 681 | const countActiveComments = (comment: any, anchor: any): number => { 682 | let count = 0; 683 | 684 | if (comment.state !== 'RESOLVED' && (!anchor || anchor.orphaned !== true)) { 685 | count = 1; 686 | } 687 | 688 | if (comment.comments && comment.comments.length > 0) { 689 | count += comment.comments.reduce((sum: number, reply: any) => sum + countActiveComments(reply, anchor), 0); 690 | } 691 | 692 | return count; 693 | }; 694 | 695 | const apiPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pullRequestId}/activities`; 696 | const response = await this.apiClient.makeRequest<any>('get', apiPath, undefined, { 697 | params: { limit: 1000 } 698 | }); 699 | 700 | const activities = response.values || []; 701 | 702 | const commentActivities = activities.filter((a: any) => 703 | a.action === 'COMMENTED' && a.comment 704 | ); 705 | 706 | totalCount = commentActivities.reduce((sum: number, activity: any) => { 707 | return sum + countAllComments(activity.comment); 708 | }, 0); 709 | 710 | activeCount = commentActivities.reduce((sum: number, activity: any) => { 711 | return sum + countActiveComments(activity.comment, activity.commentAnchor); 712 | }, 0); 713 | 714 | const processedComments = commentActivities 715 | .filter((a: any) => { 716 | const c = a.comment; 717 | const anchor = a.commentAnchor; 718 | 719 | if (c.state === 'RESOLVED') return false; 720 | if (anchor && anchor.orphaned === true) return false; 721 | 722 | return true; 723 | }) 724 | .map((a: any) => processNestedComments(a.comment, a.commentAnchor)); 725 | 726 | comments = processedComments.slice(0, 20); 727 | } else { 728 | const apiPath = `/repositories/${workspace}/${repository}/pullrequests/${pullRequestId}/comments`; 729 | const response = await this.apiClient.makeRequest<any>('get', apiPath, undefined, { 730 | params: { pagelen: 100 } 731 | }); 732 | 733 | const allComments = response.values || []; 734 | totalCount = allComments.length; 735 | 736 | const activeComments = allComments 737 | .filter((c: BitbucketCloudComment) => !c.deleted && !c.resolved) 738 | .slice(0, 20); 739 | 740 | activeCount = allComments.filter((c: BitbucketCloudComment) => !c.deleted && !c.resolved).length; 741 | 742 | comments = activeComments.map((c: BitbucketCloudComment) => ({ 743 | id: c.id, 744 | author: c.user.display_name, 745 | text: c.content.raw, 746 | created_on: c.created_on, 747 | is_inline: !!c.inline, 748 | file_path: c.inline?.path, 749 | line_number: c.inline?.to 750 | })); 751 | } 752 | 753 | return { comments, activeCount, totalCount }; 754 | } catch (error) { 755 | console.error('Failed to fetch comments:', error); 756 | return { comments: [], activeCount: 0, totalCount: 0 }; 757 | } 758 | } 759 | 760 | private async fetchPullRequestFileChanges( 761 | workspace: string, 762 | repository: string, 763 | pullRequestId: number 764 | ): Promise<{ fileChanges: FormattedFileChange[]; summary: any }> { 765 | try { 766 | let fileChanges: FormattedFileChange[] = []; 767 | let totalLinesAdded = 0; 768 | let totalLinesRemoved = 0; 769 | 770 | if (this.apiClient.getIsServer()) { 771 | const apiPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pullRequestId}/changes`; 772 | const response = await this.apiClient.makeRequest<any>('get', apiPath, undefined, { 773 | params: { limit: 1000 } 774 | }); 775 | 776 | const changes = response.values || []; 777 | 778 | fileChanges = changes.map((change: any) => { 779 | let status: 'added' | 'modified' | 'removed' | 'renamed' = 'modified'; 780 | if (change.type === 'ADD') status = 'added'; 781 | else if (change.type === 'DELETE') status = 'removed'; 782 | else if (change.type === 'MOVE' || change.type === 'RENAME') status = 'renamed'; 783 | 784 | return { 785 | path: change.path.toString, 786 | status, 787 | old_path: change.srcPath?.toString 788 | }; 789 | }); 790 | } else { 791 | const apiPath = `/repositories/${workspace}/${repository}/pullrequests/${pullRequestId}/diffstat`; 792 | const response = await this.apiClient.makeRequest<any>('get', apiPath, undefined, { 793 | params: { pagelen: 100 } 794 | }); 795 | 796 | const diffstats = response.values || []; 797 | 798 | fileChanges = diffstats.map((stat: BitbucketCloudFileChange) => { 799 | totalLinesAdded += stat.lines_added; 800 | totalLinesRemoved += stat.lines_removed; 801 | 802 | return { 803 | path: stat.path, 804 | status: stat.type, 805 | old_path: stat.old?.path 806 | }; 807 | }); 808 | } 809 | 810 | const summary = { 811 | total_files: fileChanges.length 812 | }; 813 | 814 | return { fileChanges, summary }; 815 | } catch (error) { 816 | console.error('Failed to fetch file changes:', error); 817 | return { 818 | fileChanges: [], 819 | summary: { 820 | total_files: 0 821 | } 822 | }; 823 | } 824 | } 825 | 826 | private async resolveLineFromCode( 827 | workspace: string, 828 | repository: string, 829 | pullRequestId: number, 830 | filePath: string, 831 | codeSnippet: string, 832 | searchContext?: { before?: string[]; after?: string[] }, 833 | matchStrategy: 'strict' | 'best' = 'strict' 834 | ): Promise<{ 835 | line_number: number; 836 | line_type: 'ADDED' | 'REMOVED' | 'CONTEXT'; 837 | sequential_position?: number; 838 | hunk_info?: any; 839 | diff_context?: string; 840 | diff_content_preview?: string; 841 | calculation_details?: string; 842 | }> { 843 | try { 844 | const diffContent = await this.getFilteredPullRequestDiff(workspace, repository, pullRequestId, filePath); 845 | 846 | const parser = new DiffParser(); 847 | const sections = parser.parseDiffIntoSections(diffContent); 848 | 849 | let fileSection = sections[0]; 850 | if (!this.apiClient.getIsServer()) { 851 | fileSection = sections.find(s => s.filePath === filePath) || sections[0]; 852 | } 853 | 854 | if (!fileSection) { 855 | throw new McpError( 856 | ErrorCode.InvalidParams, 857 | `File ${filePath} not found in pull request diff` 858 | ); 859 | } 860 | 861 | const matches = this.findCodeMatches( 862 | fileSection.content, 863 | codeSnippet, 864 | searchContext 865 | ); 866 | 867 | if (matches.length === 0) { 868 | throw new McpError( 869 | ErrorCode.InvalidParams, 870 | `Code snippet not found in ${filePath}` 871 | ); 872 | } 873 | 874 | if (matches.length === 1) { 875 | return { 876 | line_number: matches[0].line_number, 877 | line_type: matches[0].line_type, 878 | sequential_position: matches[0].sequential_position, 879 | hunk_info: matches[0].hunk_info, 880 | diff_context: matches[0].preview, 881 | diff_content_preview: diffContent.split('\n').slice(0, 50).join('\n'), 882 | calculation_details: `Direct line number from diff: ${matches[0].line_number}` 883 | }; 884 | } 885 | 886 | if (matchStrategy === 'best') { 887 | const best = this.selectBestMatch(matches); 888 | 889 | return { 890 | line_number: best.line_number, 891 | line_type: best.line_type, 892 | sequential_position: best.sequential_position, 893 | hunk_info: best.hunk_info, 894 | diff_context: best.preview, 895 | diff_content_preview: diffContent.split('\n').slice(0, 50).join('\n'), 896 | calculation_details: `Best match selected from ${matches.length} matches, line: ${best.line_number}` 897 | }; 898 | } 899 | 900 | const error: MultipleMatchesError = { 901 | code: 'MULTIPLE_MATCHES_FOUND', 902 | message: `Code snippet '${codeSnippet.substring(0, 50)}...' found in ${matches.length} locations`, 903 | occurrences: matches.map(m => ({ 904 | line_number: m.line_number, 905 | file_path: filePath, 906 | preview: m.preview, 907 | confidence: m.confidence, 908 | line_type: m.line_type 909 | })), 910 | 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' 911 | }; 912 | 913 | throw new McpError( 914 | ErrorCode.InvalidParams, 915 | JSON.stringify({ error }) 916 | ); 917 | } catch (error) { 918 | if (error instanceof McpError) { 919 | throw error; 920 | } 921 | throw new McpError( 922 | ErrorCode.InternalError, 923 | `Failed to resolve line from code: ${error instanceof Error ? error.message : String(error)}` 924 | ); 925 | } 926 | } 927 | 928 | private findCodeMatches( 929 | diffContent: string, 930 | codeSnippet: string, 931 | searchContext?: { before?: string[]; after?: string[] } 932 | ): CodeMatch[] { 933 | const lines = diffContent.split('\n'); 934 | const matches: CodeMatch[] = []; 935 | let currentDestLine = 0; // Destination file line number 936 | let currentSrcLine = 0; // Source file line number 937 | let inHunk = false; 938 | let sequentialAddedCount = 0; // Track sequential ADDED lines 939 | let currentHunkIndex = -1; 940 | let currentHunkDestStart = 0; 941 | let currentHunkSrcStart = 0; 942 | let destPositionInHunk = 0; // Track position in destination file relative to hunk start 943 | let srcPositionInHunk = 0; // Track position in source file relative to hunk start 944 | 945 | for (let i = 0; i < lines.length; i++) { 946 | const line = lines[i]; 947 | 948 | if (line.startsWith('@@')) { 949 | const match = line.match(/@@ -(\d+),\d+ \+(\d+),\d+ @@/); 950 | if (match) { 951 | currentHunkSrcStart = parseInt(match[1]); 952 | currentHunkDestStart = parseInt(match[2]); 953 | currentSrcLine = currentHunkSrcStart; 954 | currentDestLine = currentHunkDestStart; 955 | inHunk = true; 956 | currentHunkIndex++; 957 | destPositionInHunk = 0; 958 | srcPositionInHunk = 0; 959 | continue; 960 | } 961 | } 962 | 963 | if (!inHunk) continue; 964 | 965 | if (line === '') { 966 | inHunk = false; 967 | continue; 968 | } 969 | 970 | let lineType: 'ADDED' | 'REMOVED' | 'CONTEXT'; 971 | let lineContent = ''; 972 | let lineNumber = 0; 973 | 974 | if (line.startsWith('+')) { 975 | lineType = 'ADDED'; 976 | lineContent = line.substring(1); 977 | lineNumber = currentHunkDestStart + destPositionInHunk; 978 | destPositionInHunk++; 979 | sequentialAddedCount++; 980 | } else if (line.startsWith('-')) { 981 | lineType = 'REMOVED'; 982 | lineContent = line.substring(1); 983 | lineNumber = currentHunkSrcStart + srcPositionInHunk; 984 | srcPositionInHunk++; 985 | } else if (line.startsWith(' ')) { 986 | lineType = 'CONTEXT'; 987 | lineContent = line.substring(1); 988 | lineNumber = currentHunkDestStart + destPositionInHunk; 989 | destPositionInHunk++; 990 | srcPositionInHunk++; 991 | } else { 992 | inHunk = false; 993 | continue; 994 | } 995 | 996 | if (lineContent.trim() === codeSnippet.trim()) { 997 | const confidence = this.calculateConfidence( 998 | lines, 999 | i, 1000 | searchContext, 1001 | lineType 1002 | ); 1003 | 1004 | matches.push({ 1005 | line_number: lineNumber, 1006 | line_type: lineType, 1007 | exact_content: codeSnippet, 1008 | preview: this.getPreview(lines, i), 1009 | confidence, 1010 | context: this.extractContext(lines, i), 1011 | sequential_position: lineType === 'ADDED' ? sequentialAddedCount : undefined, 1012 | hunk_info: { 1013 | hunk_index: currentHunkIndex, 1014 | destination_start: currentHunkDestStart, 1015 | line_in_hunk: destPositionInHunk 1016 | } 1017 | }); 1018 | } 1019 | 1020 | if (lineType === 'ADDED') { 1021 | currentDestLine++; 1022 | } else if (lineType === 'REMOVED') { 1023 | currentSrcLine++; 1024 | } else if (lineType === 'CONTEXT') { 1025 | currentSrcLine++; 1026 | currentDestLine++; 1027 | } 1028 | } 1029 | 1030 | return matches; 1031 | } 1032 | 1033 | private calculateConfidence( 1034 | lines: string[], 1035 | index: number, 1036 | searchContext?: { before?: string[]; after?: string[] }, 1037 | lineType?: 'ADDED' | 'REMOVED' | 'CONTEXT' 1038 | ): number { 1039 | let confidence = 0.5; // Base confidence 1040 | 1041 | if (!searchContext) { 1042 | return confidence; 1043 | } 1044 | 1045 | if (searchContext.before) { 1046 | let matchedBefore = 0; 1047 | for (let j = 0; j < searchContext.before.length; j++) { 1048 | const contextLine = searchContext.before[searchContext.before.length - 1 - j]; 1049 | const checkIndex = index - j - 1; 1050 | if (checkIndex >= 0) { 1051 | const checkLine = lines[checkIndex].substring(1); 1052 | if (checkLine.trim() === contextLine.trim()) { 1053 | matchedBefore++; 1054 | } 1055 | } 1056 | } 1057 | confidence += (matchedBefore / searchContext.before.length) * 0.3; 1058 | } 1059 | 1060 | if (searchContext.after) { 1061 | let matchedAfter = 0; 1062 | for (let j = 0; j < searchContext.after.length; j++) { 1063 | const contextLine = searchContext.after[j]; 1064 | const checkIndex = index + j + 1; 1065 | if (checkIndex < lines.length) { 1066 | const checkLine = lines[checkIndex].substring(1); 1067 | if (checkLine.trim() === contextLine.trim()) { 1068 | matchedAfter++; 1069 | } 1070 | } 1071 | } 1072 | confidence += (matchedAfter / searchContext.after.length) * 0.3; 1073 | } 1074 | 1075 | if (lineType === 'ADDED') { 1076 | confidence += 0.1; 1077 | } 1078 | 1079 | return Math.min(confidence, 1.0); 1080 | } 1081 | 1082 | private getPreview(lines: string[], index: number): string { 1083 | const start = Math.max(0, index - 1); 1084 | const end = Math.min(lines.length, index + 2); 1085 | const previewLines = []; 1086 | 1087 | for (let i = start; i < end; i++) { 1088 | const prefix = i === index ? '> ' : ' '; 1089 | previewLines.push(prefix + lines[i]); 1090 | } 1091 | 1092 | return previewLines.join('\n'); 1093 | } 1094 | 1095 | private extractContext(lines: string[], index: number): { lines_before: string[]; lines_after: string[] } { 1096 | const linesBefore: string[] = []; 1097 | const linesAfter: string[] = []; 1098 | 1099 | for (let i = Math.max(0, index - 2); i < index; i++) { 1100 | if (lines[i].match(/^[+\- ]/)) { 1101 | linesBefore.push(lines[i].substring(1)); 1102 | } 1103 | } 1104 | 1105 | for (let i = index + 1; i < Math.min(lines.length, index + 3); i++) { 1106 | if (lines[i].match(/^[+\- ]/)) { 1107 | linesAfter.push(lines[i].substring(1)); 1108 | } 1109 | } 1110 | 1111 | return { 1112 | lines_before: linesBefore, 1113 | lines_after: linesAfter 1114 | }; 1115 | } 1116 | 1117 | private selectBestMatch(matches: CodeMatch[]): CodeMatch { 1118 | return matches.sort((a, b) => b.confidence - a.confidence)[0]; 1119 | } 1120 | 1121 | async handleListPrCommits(args: any) { 1122 | if (!isListPrCommitsArgs(args)) { 1123 | throw new McpError( 1124 | ErrorCode.InvalidParams, 1125 | 'Invalid arguments for list_pr_commits' 1126 | ); 1127 | } 1128 | 1129 | const { workspace, repository, pull_request_id, limit = 25, start = 0, include_build_status = false } = args; 1130 | 1131 | try { 1132 | // First get the PR details to include in response 1133 | const prPath = this.apiClient.getIsServer() 1134 | ? `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pull_request_id}` 1135 | : `/repositories/${workspace}/${repository}/pullrequests/${pull_request_id}`; 1136 | 1137 | let prTitle = ''; 1138 | try { 1139 | const pr = await this.apiClient.makeRequest<any>('get', prPath); 1140 | prTitle = pr.title; 1141 | } catch (e) { 1142 | // Ignore error, PR title is optional 1143 | } 1144 | 1145 | let apiPath: string; 1146 | let params: any = {}; 1147 | let commits: FormattedCommit[] = []; 1148 | let totalCount = 0; 1149 | let nextPageStart: number | null = null; 1150 | 1151 | if (this.apiClient.getIsServer()) { 1152 | // Bitbucket Server API 1153 | apiPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pull_request_id}/commits`; 1154 | params = { 1155 | limit, 1156 | start, 1157 | withCounts: true 1158 | }; 1159 | 1160 | const response = await this.apiClient.makeRequest<any>('get', apiPath, undefined, { params }); 1161 | 1162 | // Format commits 1163 | commits = (response.values || []).map((commit: BitbucketServerCommit) => formatServerCommit(commit)); 1164 | 1165 | totalCount = response.size || commits.length; 1166 | if (!response.isLastPage && response.nextPageStart !== undefined) { 1167 | nextPageStart = response.nextPageStart; 1168 | } 1169 | } else { 1170 | // Bitbucket Cloud API 1171 | apiPath = `/repositories/${workspace}/${repository}/pullrequests/${pull_request_id}/commits`; 1172 | params = { 1173 | pagelen: limit, 1174 | page: Math.floor(start / limit) + 1 1175 | }; 1176 | 1177 | const response = await this.apiClient.makeRequest<any>('get', apiPath, undefined, { params }); 1178 | 1179 | // Format commits 1180 | commits = (response.values || []).map((commit: BitbucketCloudCommit) => formatCloudCommit(commit)); 1181 | 1182 | totalCount = response.size || commits.length; 1183 | if (response.next) { 1184 | nextPageStart = start + limit; 1185 | } 1186 | } 1187 | 1188 | // Fetch build status if requested (Server only) 1189 | if (include_build_status && this.apiClient.getIsServer() && commits.length > 0) { 1190 | try { 1191 | const commitIds = commits.map(c => c.hash); 1192 | const buildSummaries = await this.apiClient.getBuildSummaries( 1193 | workspace, 1194 | repository, 1195 | commitIds 1196 | ); 1197 | 1198 | // Enhance commits with build status 1199 | commits = commits.map(commit => { 1200 | const buildData = buildSummaries[commit.hash]; 1201 | if (buildData) { 1202 | return { 1203 | ...commit, 1204 | build_status: { 1205 | successful: buildData.successful || 0, 1206 | failed: buildData.failed || 0, 1207 | in_progress: buildData.inProgress || 0, 1208 | unknown: buildData.unknown || 0 1209 | } 1210 | }; 1211 | } 1212 | return commit; 1213 | }); 1214 | } catch (error) { 1215 | console.error('Failed to fetch build status for PR commits:', error); 1216 | // Graceful degradation - continue without build status 1217 | } 1218 | } 1219 | 1220 | return { 1221 | content: [ 1222 | { 1223 | type: 'text', 1224 | text: JSON.stringify({ 1225 | pull_request_id, 1226 | pull_request_title: prTitle, 1227 | commits, 1228 | total_count: totalCount, 1229 | start, 1230 | limit, 1231 | has_more: nextPageStart !== null, 1232 | next_start: nextPageStart 1233 | }, null, 2), 1234 | }, 1235 | ], 1236 | }; 1237 | } catch (error) { 1238 | return this.apiClient.handleApiError(error, `listing commits for pull request ${pull_request_id} in ${workspace}/${repository}`); 1239 | } 1240 | } 1241 | } 1242 | ```