This is page 5 of 6. Use http://codebase.md/aashari/mcp-server-atlassian-bitbucket?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .env.example ├── .github │ ├── dependabot.yml │ └── workflows │ ├── ci-dependabot-auto-merge.yml │ ├── ci-dependency-check.yml │ └── ci-semantic-release.yml ├── .gitignore ├── .gitkeep ├── .npmignore ├── .npmrc ├── .prettierrc ├── .releaserc.json ├── .trigger-ci ├── CHANGELOG.md ├── eslint.config.mjs ├── jest.setup.js ├── package-lock.json ├── package.json ├── README.md ├── scripts │ ├── ensure-executable.js │ ├── package.json │ └── update-version.js ├── src │ ├── cli │ │ ├── atlassian.diff.cli.ts │ │ ├── atlassian.pullrequests.cli.test.ts │ │ ├── atlassian.pullrequests.cli.ts │ │ ├── atlassian.repositories.cli.test.ts │ │ ├── atlassian.repositories.cli.ts │ │ ├── atlassian.search.cli.test.ts │ │ ├── atlassian.search.cli.ts │ │ ├── atlassian.workspaces.cli.test.ts │ │ ├── atlassian.workspaces.cli.ts │ │ └── index.ts │ ├── controllers │ │ ├── atlassian.diff.controller.ts │ │ ├── atlassian.diff.formatter.ts │ │ ├── atlassian.pullrequests.approve.controller.ts │ │ ├── atlassian.pullrequests.base.controller.ts │ │ ├── atlassian.pullrequests.comments.controller.ts │ │ ├── atlassian.pullrequests.controller.test.ts │ │ ├── atlassian.pullrequests.controller.ts │ │ ├── atlassian.pullrequests.create.controller.ts │ │ ├── atlassian.pullrequests.formatter.ts │ │ ├── atlassian.pullrequests.get.controller.ts │ │ ├── atlassian.pullrequests.list.controller.ts │ │ ├── atlassian.pullrequests.reject.controller.ts │ │ ├── atlassian.pullrequests.update.controller.ts │ │ ├── atlassian.repositories.branch.controller.ts │ │ ├── atlassian.repositories.commit.controller.ts │ │ ├── atlassian.repositories.content.controller.ts │ │ ├── atlassian.repositories.controller.test.ts │ │ ├── atlassian.repositories.details.controller.ts │ │ ├── atlassian.repositories.formatter.ts │ │ ├── atlassian.repositories.list.controller.ts │ │ ├── atlassian.search.code.controller.ts │ │ ├── atlassian.search.content.controller.ts │ │ ├── atlassian.search.controller.test.ts │ │ ├── atlassian.search.controller.ts │ │ ├── atlassian.search.formatter.ts │ │ ├── atlassian.search.pullrequests.controller.ts │ │ ├── atlassian.search.repositories.controller.ts │ │ ├── atlassian.workspaces.controller.test.ts │ │ ├── atlassian.workspaces.controller.ts │ │ └── atlassian.workspaces.formatter.ts │ ├── index.ts │ ├── services │ │ ├── vendor.atlassian.pullrequests.service.ts │ │ ├── vendor.atlassian.pullrequests.test.ts │ │ ├── vendor.atlassian.pullrequests.types.ts │ │ ├── vendor.atlassian.repositories.diff.service.ts │ │ ├── vendor.atlassian.repositories.diff.types.ts │ │ ├── vendor.atlassian.repositories.service.test.ts │ │ ├── vendor.atlassian.repositories.service.ts │ │ ├── vendor.atlassian.repositories.types.ts │ │ ├── vendor.atlassian.search.service.ts │ │ ├── vendor.atlassian.search.types.ts │ │ ├── vendor.atlassian.workspaces.service.ts │ │ ├── vendor.atlassian.workspaces.test.ts │ │ └── vendor.atlassian.workspaces.types.ts │ ├── tools │ │ ├── atlassian.diff.tool.ts │ │ ├── atlassian.diff.types.ts │ │ ├── atlassian.pullrequests.tool.ts │ │ ├── atlassian.pullrequests.types.test.ts │ │ ├── atlassian.pullrequests.types.ts │ │ ├── atlassian.repositories.tool.ts │ │ ├── atlassian.repositories.types.ts │ │ ├── atlassian.search.tool.ts │ │ ├── atlassian.search.types.ts │ │ ├── atlassian.workspaces.tool.ts │ │ └── atlassian.workspaces.types.ts │ ├── types │ │ └── common.types.ts │ └── utils │ ├── adf.util.test.ts │ ├── adf.util.ts │ ├── atlassian.util.ts │ ├── bitbucket-error-detection.test.ts │ ├── cli.test.util.ts │ ├── config.util.test.ts │ ├── config.util.ts │ ├── constants.util.ts │ ├── defaults.util.ts │ ├── diff.util.ts │ ├── error-handler.util.test.ts │ ├── error-handler.util.ts │ ├── error.util.test.ts │ ├── error.util.ts │ ├── formatter.util.ts │ ├── logger.util.ts │ ├── markdown.util.test.ts │ ├── markdown.util.ts │ ├── pagination.util.ts │ ├── path.util.test.ts │ ├── path.util.ts │ ├── query.util.ts │ ├── shell.util.ts │ ├── transport.util.test.ts │ ├── transport.util.ts │ └── workspace.util.ts ├── STYLE_GUIDE.md └── tsconfig.json ``` # Files -------------------------------------------------------------------------------- /src/tools/atlassian.pullrequests.tool.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; 2 | import { z } from 'zod'; 3 | import { Logger } from '../utils/logger.util.js'; 4 | import { formatErrorForMcpTool } from '../utils/error.util.js'; 5 | import { 6 | ListPullRequestsToolArgs, 7 | type ListPullRequestsToolArgsType, 8 | GetPullRequestToolArgs, 9 | type GetPullRequestToolArgsType, 10 | ListPullRequestCommentsToolArgs, 11 | type ListPullRequestCommentsToolArgsType, 12 | CreatePullRequestCommentToolArgs, 13 | type CreatePullRequestCommentToolArgsType, 14 | CreatePullRequestToolArgs, 15 | type CreatePullRequestToolArgsType, 16 | UpdatePullRequestToolArgs, 17 | type UpdatePullRequestToolArgsType, 18 | ApprovePullRequestToolArgs, 19 | type ApprovePullRequestToolArgsType, 20 | RejectPullRequestToolArgs, 21 | type RejectPullRequestToolArgsType, 22 | } from './atlassian.pullrequests.types.js'; 23 | import atlassianPullRequestsController from '../controllers/atlassian.pullrequests.controller.js'; 24 | 25 | // Create a contextualized logger for this file 26 | const toolLogger = Logger.forContext('tools/atlassian.pullrequests.tool.ts'); 27 | 28 | // Log tool initialization 29 | toolLogger.debug('Bitbucket pull requests tool initialized'); 30 | 31 | /** 32 | * MCP Tool: List Bitbucket Pull Requests 33 | * 34 | * Lists pull requests for a specific repository with optional filtering. 35 | * Returns a formatted markdown response with pull request details. 36 | * 37 | * @param args - Tool arguments for filtering pull requests 38 | * @returns MCP response with formatted pull requests list 39 | * @throws Will return error message if pull request listing fails 40 | */ 41 | async function listPullRequests(args: Record<string, unknown>) { 42 | const methodLogger = Logger.forContext( 43 | 'tools/atlassian.pullrequests.tool.ts', 44 | 'listPullRequests', 45 | ); 46 | methodLogger.debug('Listing Bitbucket pull requests with filters:', args); 47 | 48 | try { 49 | // Pass args directly to controller without any logic 50 | const result = await atlassianPullRequestsController.list( 51 | args as ListPullRequestsToolArgsType, 52 | ); 53 | 54 | methodLogger.debug( 55 | 'Successfully retrieved pull requests from controller', 56 | ); 57 | 58 | return { 59 | content: [ 60 | { 61 | type: 'text' as const, 62 | text: result.content, 63 | }, 64 | ], 65 | }; 66 | } catch (error) { 67 | methodLogger.error('Failed to list pull requests', error); 68 | return formatErrorForMcpTool(error); 69 | } 70 | } 71 | 72 | /** 73 | * MCP Tool: Get Bitbucket Pull Request Details 74 | * 75 | * Retrieves detailed information about a specific pull request. 76 | * Returns a formatted markdown response with pull request details. 77 | * 78 | * @param args - Tool arguments containing the workspace, repository, and pull request identifiers 79 | * @returns MCP response with formatted pull request details 80 | * @throws Will return error message if pull request retrieval fails 81 | */ 82 | async function getPullRequest(args: Record<string, unknown>) { 83 | const methodLogger = Logger.forContext( 84 | 'tools/atlassian.pullrequests.tool.ts', 85 | 'getPullRequest', 86 | ); 87 | methodLogger.debug('Getting Bitbucket pull request details:', args); 88 | 89 | try { 90 | // Pass args directly to controller 91 | const result = await atlassianPullRequestsController.get( 92 | args as GetPullRequestToolArgsType, 93 | ); 94 | 95 | methodLogger.debug( 96 | 'Successfully retrieved pull request details from controller', 97 | ); 98 | 99 | return { 100 | content: [ 101 | { 102 | type: 'text' as const, 103 | text: result.content, 104 | }, 105 | ], 106 | }; 107 | } catch (error) { 108 | methodLogger.error('Failed to get pull request details', error); 109 | return formatErrorForMcpTool(error); 110 | } 111 | } 112 | 113 | /** 114 | * MCP Tool: List Bitbucket Pull Request Comments 115 | * 116 | * Lists comments for a specific pull request, including general comments and inline code comments. 117 | * Returns a formatted markdown response with comment details. 118 | * 119 | * @param args - Tool arguments containing workspace, repository, and PR identifiers 120 | * @returns MCP response with formatted pull request comments 121 | * @throws Will return error message if comment retrieval fails 122 | */ 123 | async function listPullRequestComments(args: Record<string, unknown>) { 124 | const methodLogger = Logger.forContext( 125 | 'tools/atlassian.pullrequests.tool.ts', 126 | 'listPullRequestComments', 127 | ); 128 | methodLogger.debug('Listing pull request comments:', args); 129 | 130 | try { 131 | // Pass args directly to controller 132 | const result = await atlassianPullRequestsController.listComments( 133 | args as ListPullRequestCommentsToolArgsType, 134 | ); 135 | 136 | methodLogger.debug( 137 | 'Successfully retrieved pull request comments from controller', 138 | ); 139 | 140 | return { 141 | content: [ 142 | { 143 | type: 'text' as const, 144 | text: result.content, 145 | }, 146 | ], 147 | }; 148 | } catch (error) { 149 | methodLogger.error('Failed to get pull request comments', error); 150 | return formatErrorForMcpTool(error); 151 | } 152 | } 153 | 154 | /** 155 | * MCP Tool: Add Bitbucket Pull Request Comment 156 | * 157 | * Adds a comment to a specific pull request, with support for general and inline comments. 158 | * Returns a success message as markdown. 159 | * 160 | * @param args - Tool arguments containing workspace, repository, PR ID, and comment content 161 | * @returns MCP response with formatted success message 162 | * @throws Will return error message if comment creation fails 163 | */ 164 | async function addPullRequestComment(args: Record<string, unknown>) { 165 | const methodLogger = Logger.forContext( 166 | 'tools/atlassian.pullrequests.tool.ts', 167 | 'addPullRequestComment', 168 | ); 169 | methodLogger.debug('Adding pull request comment:', { 170 | ...args, 171 | content: args.content 172 | ? `(length: ${(args.content as string).length})` 173 | : '(none)', 174 | }); 175 | 176 | try { 177 | // Pass args directly to controller 178 | const result = await atlassianPullRequestsController.addComment( 179 | args as CreatePullRequestCommentToolArgsType, 180 | ); 181 | 182 | methodLogger.debug( 183 | 'Successfully added pull request comment via controller', 184 | ); 185 | 186 | return { 187 | content: [ 188 | { 189 | type: 'text' as const, 190 | text: result.content, 191 | }, 192 | ], 193 | }; 194 | } catch (error) { 195 | methodLogger.error('Failed to add pull request comment', error); 196 | return formatErrorForMcpTool(error); 197 | } 198 | } 199 | 200 | /** 201 | * MCP Tool: Create Bitbucket Pull Request 202 | * 203 | * Creates a new pull request between two branches in a Bitbucket repository. 204 | * Returns a formatted markdown response with the newly created pull request details. 205 | * 206 | * @param args - Tool arguments containing workspace, repository, source branch, destination branch, and title 207 | * @returns MCP response with formatted pull request details 208 | * @throws Will return error message if pull request creation fails 209 | */ 210 | async function addPullRequest(args: Record<string, unknown>) { 211 | const methodLogger = Logger.forContext( 212 | 'tools/atlassian.pullrequests.tool.ts', 213 | 'addPullRequest', 214 | ); 215 | methodLogger.debug('Creating new pull request:', { 216 | ...args, 217 | description: args.description 218 | ? `(length: ${(args.description as string).length})` 219 | : '(none)', 220 | }); 221 | 222 | try { 223 | // Pass args directly to controller 224 | const result = await atlassianPullRequestsController.add( 225 | args as CreatePullRequestToolArgsType, 226 | ); 227 | 228 | methodLogger.debug('Successfully created pull request via controller'); 229 | 230 | return { 231 | content: [ 232 | { 233 | type: 'text' as const, 234 | text: result.content, 235 | }, 236 | ], 237 | }; 238 | } catch (error) { 239 | methodLogger.error('Failed to create pull request', error); 240 | return formatErrorForMcpTool(error); 241 | } 242 | } 243 | 244 | /** 245 | * MCP Tool: Update Bitbucket Pull Request 246 | * 247 | * Updates an existing pull request's title and/or description. 248 | * Returns a formatted markdown response with updated pull request details. 249 | * 250 | * @param args - Tool arguments for updating pull request 251 | * @returns MCP response with formatted updated pull request details 252 | * @throws Will return error message if pull request update fails 253 | */ 254 | async function updatePullRequest(args: Record<string, unknown>) { 255 | const methodLogger = Logger.forContext( 256 | 'tools/atlassian.pullrequests.tool.ts', 257 | 'updatePullRequest', 258 | ); 259 | methodLogger.debug('Updating pull request:', { 260 | ...args, 261 | description: args.description 262 | ? `(length: ${(args.description as string).length})` 263 | : '(unchanged)', 264 | }); 265 | 266 | try { 267 | // Pass args directly to controller 268 | const result = await atlassianPullRequestsController.update( 269 | args as UpdatePullRequestToolArgsType, 270 | ); 271 | 272 | methodLogger.debug('Successfully updated pull request via controller'); 273 | 274 | return { 275 | content: [ 276 | { 277 | type: 'text' as const, 278 | text: result.content, 279 | }, 280 | ], 281 | }; 282 | } catch (error) { 283 | methodLogger.error('Failed to update pull request', error); 284 | return formatErrorForMcpTool(error); 285 | } 286 | } 287 | 288 | /** 289 | * MCP Tool: Approve Bitbucket Pull Request 290 | * 291 | * Approves a pull request, marking it as approved by the current user. 292 | * Returns a formatted markdown response with approval confirmation. 293 | * 294 | * @param args - Tool arguments for approving pull request 295 | * @returns MCP response with formatted approval confirmation 296 | * @throws Will return error message if pull request approval fails 297 | */ 298 | async function approvePullRequest(args: Record<string, unknown>) { 299 | const methodLogger = Logger.forContext( 300 | 'tools/atlassian.pullrequests.tool.ts', 301 | 'approvePullRequest', 302 | ); 303 | methodLogger.debug('Approving pull request:', args); 304 | 305 | try { 306 | // Pass args directly to controller 307 | const result = await atlassianPullRequestsController.approve( 308 | args as ApprovePullRequestToolArgsType, 309 | ); 310 | 311 | methodLogger.debug('Successfully approved pull request via controller'); 312 | 313 | return { 314 | content: [ 315 | { 316 | type: 'text' as const, 317 | text: result.content, 318 | }, 319 | ], 320 | }; 321 | } catch (error) { 322 | methodLogger.error('Failed to approve pull request', error); 323 | return formatErrorForMcpTool(error); 324 | } 325 | } 326 | 327 | /** 328 | * MCP Tool: Request Changes on Bitbucket Pull Request 329 | * 330 | * Requests changes on a pull request, marking it as requiring changes by the current user. 331 | * Returns a formatted markdown response with rejection confirmation. 332 | * 333 | * @param args - Tool arguments for requesting changes on pull request 334 | * @returns MCP response with formatted rejection confirmation 335 | * @throws Will return error message if pull request rejection fails 336 | */ 337 | async function rejectPullRequest(args: Record<string, unknown>) { 338 | const methodLogger = Logger.forContext( 339 | 'tools/atlassian.pullrequests.tool.ts', 340 | 'rejectPullRequest', 341 | ); 342 | methodLogger.debug('Requesting changes on pull request:', args); 343 | 344 | try { 345 | // Pass args directly to controller 346 | const result = await atlassianPullRequestsController.reject( 347 | args as RejectPullRequestToolArgsType, 348 | ); 349 | 350 | methodLogger.debug( 351 | 'Successfully requested changes on pull request via controller', 352 | ); 353 | 354 | return { 355 | content: [ 356 | { 357 | type: 'text' as const, 358 | text: result.content, 359 | }, 360 | ], 361 | }; 362 | } catch (error) { 363 | methodLogger.error('Failed to request changes on pull request', error); 364 | return formatErrorForMcpTool(error); 365 | } 366 | } 367 | 368 | /** 369 | * Register Atlassian Pull Requests MCP Tools 370 | * 371 | * Registers the pull requests-related tools with the MCP server. 372 | * Each tool is registered with its schema, description, and handler function. 373 | * 374 | * @param server - The MCP server instance to register tools with 375 | */ 376 | function registerTools(server: McpServer) { 377 | const methodLogger = Logger.forContext( 378 | 'tools/atlassian.pullrequests.tool.ts', 379 | 'registerTools', 380 | ); 381 | methodLogger.debug('Registering Atlassian Pull Requests tools...'); 382 | 383 | // Register the list pull requests tool 384 | server.tool( 385 | 'bb_ls_prs', 386 | `Lists pull requests within a repository (\`repoSlug\`). If \`workspaceSlug\` is not provided, the system will use your default workspace. Filters by \`state\` (OPEN, MERGED, DECLINED, SUPERSEDED) and supports text search via \`query\`. Supports pagination via \`limit\` and \`cursor\`. Pagination details are included at the end of the text content. Returns a formatted Markdown list with each PR's title, status, author, reviewers, and creation date. Requires Bitbucket credentials to be configured.`, 387 | ListPullRequestsToolArgs.shape, 388 | listPullRequests, 389 | ); 390 | 391 | // Register the get pull request tool 392 | server.tool( 393 | 'bb_get_pr', 394 | `Retrieves detailed information about a specific pull request identified by \`prId\` within a repository (\`repoSlug\`). If \`workspaceSlug\` is not provided, the system will use your default workspace. Includes PR details, status, reviewers, and diff statistics. Set \`includeFullDiff\` to true (default) for the complete code changes. Set \`includeComments\` to true to also retrieve comments (default: false; Note: Enabling this may increase response time for pull requests with many comments). Returns rich information as formatted Markdown, including PR summary, code changes, and optionally comments. Requires Bitbucket credentials to be configured.`, 395 | GetPullRequestToolArgs.shape, 396 | getPullRequest, 397 | ); 398 | 399 | // Register the list pull request comments tool 400 | server.tool( 401 | 'bb_ls_pr_comments', 402 | `Lists comments on a specific pull request identified by \`prId\` within a repository (\`repoSlug\`). If \`workspaceSlug\` is not provided, the system will use your default workspace. Retrieves both general PR comments and inline code comments, indicating their location if applicable. Supports pagination via \`limit\` and \`cursor\`. Pagination details are included at the end of the text content. Returns a formatted Markdown list with each comment's author, timestamp, content, and location for inline comments. Requires Bitbucket credentials to be configured.`, 403 | ListPullRequestCommentsToolArgs.shape, 404 | listPullRequestComments, 405 | ); 406 | 407 | // Register the add pull request comment tool 408 | server.tool( 409 | 'bb_add_pr_comment', 410 | `Adds a comment to a specific pull request identified by \`prId\` within a repository (\`repoSlug\`). If \`workspaceSlug\` is not provided, the system will use your default workspace. The \`content\` parameter accepts Markdown-formatted text for the comment body. To reply to an existing comment, provide its ID in the \`parentId\` parameter. For inline code comments, provide both \`inline.path\` (file path) and \`inline.line\` (line number). Returns a success message as formatted Markdown. Requires Bitbucket credentials with write permissions to be configured.`, 411 | CreatePullRequestCommentToolArgs.shape, 412 | addPullRequestComment, 413 | ); 414 | 415 | // Register the create pull request tool 416 | // Note: Using prTitle instead of title to avoid MCP SDK conflict 417 | const createPrSchema = z.object({ 418 | workspaceSlug: CreatePullRequestToolArgs.shape.workspaceSlug, 419 | repoSlug: CreatePullRequestToolArgs.shape.repoSlug, 420 | prTitle: CreatePullRequestToolArgs.shape.title, // Renamed from 'title' to 'prTitle' 421 | sourceBranch: CreatePullRequestToolArgs.shape.sourceBranch, 422 | destinationBranch: CreatePullRequestToolArgs.shape.destinationBranch, 423 | description: CreatePullRequestToolArgs.shape.description, 424 | closeSourceBranch: CreatePullRequestToolArgs.shape.closeSourceBranch, 425 | }); 426 | server.tool( 427 | 'bb_add_pr', 428 | `Creates a new pull request in a repository (\`repoSlug\`). If \`workspaceSlug\` is not provided, the system will use your default workspace. Required parameters include \`prTitle\` (the PR title), \`sourceBranch\` (branch with changes), and optionally \`destinationBranch\` (target branch, defaults to main/master). The \`description\` parameter accepts Markdown-formatted text for the PR description. Set \`closeSourceBranch\` to true to automatically delete the source branch after merging. Returns the newly created pull request details as formatted Markdown. Requires Bitbucket credentials with write permissions to be configured.`, 429 | createPrSchema.shape, 430 | async (args: Record<string, unknown>) => { 431 | // Map prTitle back to title for the controller 432 | const mappedArgs = { ...args, title: args.prTitle }; 433 | delete (mappedArgs as Record<string, unknown>).prTitle; 434 | return addPullRequest(mappedArgs); 435 | }, 436 | ); 437 | 438 | // Register the update pull request tool 439 | // Note: Using prTitle instead of title to avoid MCP SDK conflict 440 | const updatePrSchema = z.object({ 441 | workspaceSlug: UpdatePullRequestToolArgs.shape.workspaceSlug, 442 | repoSlug: UpdatePullRequestToolArgs.shape.repoSlug, 443 | pullRequestId: UpdatePullRequestToolArgs.shape.pullRequestId, 444 | prTitle: UpdatePullRequestToolArgs.shape.title, // Renamed from 'title' to 'prTitle' 445 | description: UpdatePullRequestToolArgs.shape.description, 446 | }); 447 | server.tool( 448 | 'bb_update_pr', 449 | `Updates an existing pull request in a repository (\`repoSlug\`) identified by \`pullRequestId\`. If \`workspaceSlug\` is not provided, the system will use your default workspace. You can update the \`prTitle\` (the PR title) and/or \`description\` fields. At least one field must be provided. The \`description\` parameter accepts Markdown-formatted text. Returns the updated pull request details as formatted Markdown. Requires Bitbucket credentials with write permissions to be configured.`, 450 | updatePrSchema.shape, 451 | async (args: Record<string, unknown>) => { 452 | // Map prTitle back to title for the controller 453 | const mappedArgs = { ...args, title: args.prTitle }; 454 | delete (mappedArgs as Record<string, unknown>).prTitle; 455 | return updatePullRequest(mappedArgs); 456 | }, 457 | ); 458 | 459 | // Register the approve pull request tool 460 | server.tool( 461 | 'bb_approve_pr', 462 | `Approves a pull request in a repository (\`repoSlug\`) identified by \`pullRequestId\`. If \`workspaceSlug\` is not provided, the system will use your default workspace. This marks the pull request as approved by the current user, indicating that the changes are ready for merge (pending any other required approvals or checks). Returns an approval confirmation as formatted Markdown. Requires Bitbucket credentials with appropriate permissions to be configured.`, 463 | ApprovePullRequestToolArgs.shape, 464 | approvePullRequest, 465 | ); 466 | 467 | // Register the reject pull request tool 468 | server.tool( 469 | 'bb_reject_pr', 470 | `Requests changes on a pull request in a repository (\`repoSlug\`) identified by \`pullRequestId\`. If \`workspaceSlug\` is not provided, the system will use your default workspace. This marks the pull request as requiring changes by the current user, indicating that the author should address feedback before the pull request can be merged. Returns a rejection confirmation as formatted Markdown. Requires Bitbucket credentials with appropriate permissions to be configured.`, 471 | RejectPullRequestToolArgs.shape, 472 | rejectPullRequest, 473 | ); 474 | 475 | methodLogger.debug('Successfully registered Pull Requests tools'); 476 | } 477 | 478 | export default { registerTools }; 479 | ``` -------------------------------------------------------------------------------- /src/services/vendor.atlassian.repositories.service.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from 'zod'; 2 | import { 3 | createAuthMissingError, 4 | createApiError, 5 | McpError, 6 | } from '../utils/error.util.js'; 7 | import { Logger } from '../utils/logger.util.js'; 8 | import { 9 | fetchAtlassian, 10 | getAtlassianCredentials, 11 | } from '../utils/transport.util.js'; 12 | import { 13 | validatePageSize, 14 | validatePaginationLimits, 15 | } from '../utils/pagination.util.js'; 16 | import { 17 | ListRepositoriesParamsSchema, 18 | GetRepositoryParamsSchema, 19 | ListCommitsParamsSchema, 20 | RepositoriesResponseSchema, 21 | RepositorySchema, 22 | PaginatedCommitsSchema, 23 | CreateBranchParamsSchema, 24 | BranchRefSchema, 25 | GetFileContentParamsSchema, 26 | type ListRepositoriesParams, 27 | type GetRepositoryParams, 28 | type ListCommitsParams, 29 | type Repository, 30 | type CreateBranchParams, 31 | type BranchRef, 32 | type GetFileContentParams, 33 | ListBranchesParamsSchema, 34 | BranchesResponseSchema, 35 | type ListBranchesParams, 36 | type BranchesResponse, 37 | } from './vendor.atlassian.repositories.types.js'; 38 | 39 | /** 40 | * Base API path for Bitbucket REST API v2 41 | * @see https://developer.atlassian.com/cloud/bitbucket/rest/api-group-repositories/ 42 | * @constant {string} 43 | */ 44 | const API_PATH = '/2.0'; 45 | 46 | /** 47 | * @namespace VendorAtlassianRepositoriesService 48 | * @description Service for interacting with Bitbucket Repositories API. 49 | * Provides methods for listing repositories and retrieving repository details. 50 | * All methods require valid Atlassian credentials configured in the environment. 51 | */ 52 | 53 | // Create a contextualized logger for this file 54 | const serviceLogger = Logger.forContext( 55 | 'services/vendor.atlassian.repositories.service.ts', 56 | ); 57 | 58 | // Log service initialization 59 | serviceLogger.debug('Bitbucket repositories service initialized'); 60 | 61 | /** 62 | * List repositories for a workspace 63 | * @param {string} workspace - Workspace name or UUID 64 | * @param {ListRepositoriesParams} [params={}] - Optional parameters 65 | * @param {string} [params.q] - Query string to filter repositories 66 | * @param {string} [params.sort] - Property to sort by (e.g., 'name', '-created_on') 67 | * @param {number} [params.page] - Page number for pagination 68 | * @param {number} [params.pagelen] - Number of items per page 69 | * @returns {Promise<RepositoriesResponse>} Response containing repositories 70 | * @example 71 | * ```typescript 72 | * // List repositories in a workspace, filtered and sorted 73 | * const response = await listRepositories('myworkspace', { 74 | * q: 'name~"api"', 75 | * sort: 'name', 76 | * pagelen: 25 77 | * }); 78 | * ``` 79 | */ 80 | async function list( 81 | params: ListRepositoriesParams, 82 | ): Promise<z.infer<typeof RepositoriesResponseSchema>> { 83 | const methodLogger = Logger.forContext( 84 | 'services/vendor.atlassian.repositories.service.ts', 85 | 'list', 86 | ); 87 | methodLogger.debug('Listing Bitbucket repositories with params:', params); 88 | 89 | // Validate params with Zod 90 | try { 91 | ListRepositoriesParamsSchema.parse(params); 92 | } catch (error) { 93 | if (error instanceof z.ZodError) { 94 | methodLogger.error( 95 | 'Invalid parameters provided to list repositories:', 96 | error.format(), 97 | ); 98 | throw createApiError( 99 | `Invalid parameters: ${error.issues.map((e) => e.message).join(', ')}`, 100 | 400, 101 | error, 102 | ); 103 | } 104 | throw error; 105 | } 106 | 107 | const credentials = getAtlassianCredentials(); 108 | if (!credentials) { 109 | throw createAuthMissingError( 110 | 'Atlassian credentials are required for this operation', 111 | ); 112 | } 113 | 114 | // Construct query parameters 115 | const queryParams = new URLSearchParams(); 116 | 117 | // Add optional query parameters 118 | if (params.q) { 119 | queryParams.set('q', params.q); 120 | } 121 | if (params.sort) { 122 | queryParams.set('sort', params.sort); 123 | } 124 | if (params.role) { 125 | queryParams.set('role', params.role); 126 | } 127 | 128 | // Validate and enforce page size limits (CWE-770) 129 | const validatedPagelen = validatePageSize( 130 | params.pagelen, 131 | 'listRepositories', 132 | ); 133 | queryParams.set('pagelen', validatedPagelen.toString()); 134 | 135 | if (params.page) { 136 | queryParams.set('page', params.page.toString()); 137 | } 138 | 139 | const queryString = queryParams.toString() 140 | ? `?${queryParams.toString()}` 141 | : ''; 142 | const path = `${API_PATH}/repositories/${params.workspace}${queryString}`; 143 | 144 | methodLogger.debug(`Sending request to: ${path}`); 145 | try { 146 | const rawData = await fetchAtlassian(credentials, path); 147 | // Validate response with Zod schema 148 | try { 149 | const validatedData = RepositoriesResponseSchema.parse(rawData); 150 | 151 | // Validate pagination limits to prevent excessive data exposure (CWE-770) 152 | if (!validatePaginationLimits(validatedData, 'listRepositories')) { 153 | methodLogger.warn( 154 | 'Response pagination exceeds configured limits', 155 | ); 156 | } 157 | 158 | return validatedData; 159 | } catch (error) { 160 | if (error instanceof z.ZodError) { 161 | methodLogger.error( 162 | 'Invalid response from Bitbucket API:', 163 | error.format(), 164 | ); 165 | throw createApiError( 166 | 'Received invalid response format from Bitbucket API', 167 | 500, 168 | error, 169 | ); 170 | } 171 | throw error; 172 | } 173 | } catch (error) { 174 | if (error instanceof McpError) { 175 | throw error; 176 | } 177 | throw createApiError( 178 | `Failed to list repositories: ${error instanceof Error ? error.message : String(error)}`, 179 | 500, 180 | error, 181 | ); 182 | } 183 | } 184 | 185 | /** 186 | * Get detailed information about a specific Bitbucket repository 187 | * 188 | * Retrieves comprehensive details about a single repository. 189 | * 190 | * @async 191 | * @memberof VendorAtlassianRepositoriesService 192 | * @param {GetRepositoryParams} params - Parameters for the request 193 | * @param {string} params.workspace - The workspace slug 194 | * @param {string} params.repo_slug - The repository slug 195 | * @returns {Promise<Repository>} Promise containing the detailed repository information 196 | * @throws {Error} If Atlassian credentials are missing or API request fails 197 | * @example 198 | * // Get repository details 199 | * const repository = await get({ 200 | * workspace: 'my-workspace', 201 | * repo_slug: 'my-repo' 202 | * }); 203 | */ 204 | async function get(params: GetRepositoryParams): Promise<Repository> { 205 | const methodLogger = Logger.forContext( 206 | 'services/vendor.atlassian.repositories.service.ts', 207 | 'get', 208 | ); 209 | methodLogger.debug( 210 | `Getting Bitbucket repository: ${params.workspace}/${params.repo_slug}`, 211 | ); 212 | 213 | // Validate params with Zod 214 | try { 215 | GetRepositoryParamsSchema.parse(params); 216 | } catch (error) { 217 | if (error instanceof z.ZodError) { 218 | methodLogger.error( 219 | 'Invalid parameters provided to get repository:', 220 | error.format(), 221 | ); 222 | throw createApiError( 223 | `Invalid parameters: ${error.issues.map((e) => e.message).join(', ')}`, 224 | 400, 225 | error, 226 | ); 227 | } 228 | throw error; 229 | } 230 | 231 | const credentials = getAtlassianCredentials(); 232 | if (!credentials) { 233 | throw createAuthMissingError( 234 | 'Atlassian credentials are required for this operation', 235 | ); 236 | } 237 | 238 | const path = `${API_PATH}/repositories/${params.workspace}/${params.repo_slug}`; 239 | 240 | methodLogger.debug(`Sending request to: ${path}`); 241 | try { 242 | const rawData = await fetchAtlassian(credentials, path); 243 | 244 | // Validate response with Zod schema 245 | try { 246 | const validatedData = RepositorySchema.parse(rawData); 247 | return validatedData; 248 | } catch (error) { 249 | if (error instanceof z.ZodError) { 250 | // Log the detailed formatting errors but provide a clear message to users 251 | methodLogger.error( 252 | 'Bitbucket API response validation failed:', 253 | error.format(), 254 | ); 255 | 256 | // Create API error with appropriate context for validation failures 257 | throw createApiError( 258 | `Invalid response format from Bitbucket API for repository ${params.workspace}/${params.repo_slug}`, 259 | 500, // Internal server error since the API responded but with unexpected format 260 | error, // Include the Zod error as originalError for better debugging 261 | ); 262 | } 263 | throw error; // Re-throw any other errors 264 | } 265 | } catch (error) { 266 | // If it's already an McpError (from fetchAtlassian or Zod validation), just rethrow it 267 | if (error instanceof McpError) { 268 | throw error; 269 | } 270 | 271 | // Otherwise, wrap in a standard API error with context 272 | throw createApiError( 273 | `Failed to get repository details for ${params.workspace}/${params.repo_slug}: ${error instanceof Error ? error.message : String(error)}`, 274 | 500, 275 | error, 276 | ); 277 | } 278 | } 279 | 280 | /** 281 | * Lists commits for a specific repository and optional revision/path. 282 | * 283 | * @param params Parameters including workspace, repo slug, and optional filters. 284 | * @returns Promise resolving to paginated commit data. 285 | * @throws {Error} If workspace or repo_slug are missing, or if credentials are not found. 286 | */ 287 | async function listCommits( 288 | params: ListCommitsParams, 289 | ): Promise<z.infer<typeof PaginatedCommitsSchema>> { 290 | const methodLogger = Logger.forContext( 291 | 'services/vendor.atlassian.repositories.service.ts', 292 | 'listCommits', 293 | ); 294 | methodLogger.debug( 295 | `Listing commits for ${params.workspace}/${params.repo_slug}`, 296 | params, 297 | ); 298 | 299 | // Validate params with Zod 300 | try { 301 | ListCommitsParamsSchema.parse(params); 302 | } catch (error) { 303 | if (error instanceof z.ZodError) { 304 | methodLogger.error( 305 | 'Invalid parameters provided to list commits:', 306 | error.format(), 307 | ); 308 | throw createApiError( 309 | `Invalid parameters: ${error.issues.map((e) => e.message).join(', ')}`, 310 | 400, 311 | error, 312 | ); 313 | } 314 | throw error; 315 | } 316 | 317 | const credentials = getAtlassianCredentials(); 318 | if (!credentials) { 319 | throw createAuthMissingError( 320 | 'Atlassian credentials are required for this operation', 321 | ); 322 | } 323 | 324 | const queryParams = new URLSearchParams(); 325 | if (params.include) { 326 | queryParams.set('include', params.include); 327 | } 328 | if (params.exclude) { 329 | queryParams.set('exclude', params.exclude); 330 | } 331 | if (params.path) { 332 | queryParams.set('path', params.path); 333 | } 334 | if (params.pagelen) { 335 | queryParams.set('pagelen', params.pagelen.toString()); 336 | } 337 | if (params.page) { 338 | queryParams.set('page', params.page.toString()); 339 | } 340 | 341 | const queryString = queryParams.toString() 342 | ? `?${queryParams.toString()}` 343 | : ''; 344 | const path = `${API_PATH}/repositories/${params.workspace}/${params.repo_slug}/commits${queryString}`; 345 | 346 | methodLogger.debug(`Sending commit history request to: ${path}`); 347 | try { 348 | const rawData = await fetchAtlassian(credentials, path); 349 | // Validate response with Zod schema 350 | try { 351 | const validatedData = PaginatedCommitsSchema.parse(rawData); 352 | return validatedData; 353 | } catch (error) { 354 | if (error instanceof z.ZodError) { 355 | methodLogger.error( 356 | 'Invalid response from Bitbucket API:', 357 | error.format(), 358 | ); 359 | throw createApiError( 360 | 'Received invalid response format from Bitbucket API', 361 | 500, 362 | error, 363 | ); 364 | } 365 | throw error; 366 | } 367 | } catch (error) { 368 | if (error instanceof McpError) { 369 | throw error; 370 | } 371 | throw createApiError( 372 | `Failed to list commits: ${error instanceof Error ? error.message : String(error)}`, 373 | 500, 374 | error, 375 | ); 376 | } 377 | } 378 | 379 | /** 380 | * Creates a new branch in the specified repository. 381 | * 382 | * @param params Parameters including workspace, repo slug, new branch name, and source target. 383 | * @returns Promise resolving to details about the newly created branch reference. 384 | * @throws {Error} If required parameters are missing or API request fails. 385 | */ 386 | async function createBranch(params: CreateBranchParams): Promise<BranchRef> { 387 | const methodLogger = Logger.forContext( 388 | 'services/vendor.atlassian.repositories.service.ts', 389 | 'createBranch', 390 | ); 391 | methodLogger.debug( 392 | `Creating branch '${params.name}' from target '${params.target.hash}' in ${params.workspace}/${params.repo_slug}`, 393 | ); 394 | 395 | // Validate params with Zod 396 | try { 397 | CreateBranchParamsSchema.parse(params); 398 | } catch (error) { 399 | if (error instanceof z.ZodError) { 400 | methodLogger.error('Invalid parameters provided:', error.format()); 401 | throw createApiError( 402 | `Invalid parameters: ${error.issues.map((e) => e.message).join(', ')}`, 403 | 400, 404 | error, 405 | ); 406 | } 407 | throw error; 408 | } 409 | 410 | const credentials = getAtlassianCredentials(); 411 | if (!credentials) { 412 | throw createAuthMissingError( 413 | 'Atlassian credentials are required for this operation', 414 | ); 415 | } 416 | 417 | const path = `${API_PATH}/repositories/${params.workspace}/${params.repo_slug}/refs/branches`; 418 | 419 | const requestBody = { 420 | name: params.name, 421 | target: { 422 | hash: params.target.hash, 423 | }, 424 | }; 425 | 426 | methodLogger.debug(`Sending POST request to: ${path}`); 427 | try { 428 | const rawData = await fetchAtlassian<BranchRef>(credentials, path, { 429 | method: 'POST', 430 | body: requestBody, 431 | }); 432 | 433 | // Validate response with Zod schema 434 | try { 435 | const validatedData = BranchRefSchema.parse(rawData); 436 | methodLogger.debug('Branch created successfully:', validatedData); 437 | return validatedData; 438 | } catch (error) { 439 | if (error instanceof z.ZodError) { 440 | methodLogger.error( 441 | 'Invalid response from Bitbucket API:', 442 | error.format(), 443 | ); 444 | throw createApiError( 445 | 'Received invalid response format from Bitbucket API', 446 | 500, 447 | error, 448 | ); 449 | } 450 | throw error; 451 | } 452 | } catch (error) { 453 | if (error instanceof McpError) { 454 | throw error; 455 | } 456 | throw createApiError( 457 | `Failed to create branch: ${error instanceof Error ? error.message : String(error)}`, 458 | 500, 459 | error, 460 | ); 461 | } 462 | } 463 | 464 | /** 465 | * Get the content of a file from a repository. 466 | * 467 | * This retrieves the raw content of a file at the specified path from a repository at a specific commit. 468 | * 469 | * @param {GetFileContentParams} params - Parameters for the request 470 | * @param {string} params.workspace - The workspace slug or UUID 471 | * @param {string} params.repo_slug - The repository slug or UUID 472 | * @param {string} params.commit - The commit, branch name, or tag to get the file from 473 | * @param {string} params.path - The file path within the repository 474 | * @returns {Promise<string>} Promise containing the file content as a string 475 | * @throws {Error} If parameters are invalid, credentials are missing, or API request fails 476 | * @example 477 | * // Get README.md content from the main branch 478 | * const fileContent = await getFileContent({ 479 | * workspace: 'my-workspace', 480 | * repo_slug: 'my-repo', 481 | * commit: 'main', 482 | * path: 'README.md' 483 | * }); 484 | */ 485 | async function getFileContent(params: GetFileContentParams): Promise<string> { 486 | const methodLogger = Logger.forContext( 487 | 'services/vendor.atlassian.repositories.service.ts', 488 | 'getFileContent', 489 | ); 490 | methodLogger.debug( 491 | `Getting file content from ${params.workspace}/${params.repo_slug}/${params.commit}/${params.path}`, 492 | ); 493 | 494 | // Validate params with Zod 495 | try { 496 | GetFileContentParamsSchema.parse(params); 497 | } catch (error) { 498 | if (error instanceof z.ZodError) { 499 | methodLogger.error( 500 | 'Invalid parameters provided to get file content:', 501 | error.format(), 502 | ); 503 | throw createApiError( 504 | `Invalid parameters: ${error.issues.map((e) => e.message).join(', ')}`, 505 | 400, 506 | error, 507 | ); 508 | } 509 | throw error; 510 | } 511 | 512 | const credentials = getAtlassianCredentials(); 513 | if (!credentials) { 514 | throw createAuthMissingError( 515 | 'Atlassian credentials are required for this operation', 516 | ); 517 | } 518 | 519 | const path = `${API_PATH}/repositories/${params.workspace}/${params.repo_slug}/src/${params.commit}/${params.path}`; 520 | 521 | methodLogger.debug(`Sending request to: ${path}`); 522 | try { 523 | // Use fetchAtlassian to get the file content directly as string 524 | // The function already detects text/plain content type and returns it appropriately 525 | const fileContent = await fetchAtlassian<string>(credentials, path); 526 | 527 | methodLogger.debug( 528 | `Successfully retrieved file content (${fileContent.length} characters)`, 529 | ); 530 | return fileContent; 531 | } catch (error) { 532 | if (error instanceof McpError) { 533 | throw error; 534 | } 535 | 536 | // More specific error messages for common file issues 537 | if (error instanceof Error && error.message.includes('404')) { 538 | throw createApiError( 539 | `File not found: ${params.path} at ${params.commit}`, 540 | 404, 541 | error, 542 | ); 543 | } 544 | 545 | throw createApiError( 546 | `Failed to get file content: ${error instanceof Error ? error.message : String(error)}`, 547 | 500, 548 | error, 549 | ); 550 | } 551 | } 552 | 553 | /** 554 | * Lists branches for a specific repository. 555 | * 556 | * @param params Parameters including workspace, repo slug, and optional filters. 557 | * @returns Promise resolving to paginated branches data. 558 | * @throws {Error} If workspace or repo_slug are missing, or if credentials are not found. 559 | */ 560 | async function listBranches( 561 | params: ListBranchesParams, 562 | ): Promise<BranchesResponse> { 563 | const methodLogger = Logger.forContext( 564 | 'services/vendor.atlassian.repositories.service.ts', 565 | 'listBranches', 566 | ); 567 | methodLogger.debug( 568 | `Listing branches for ${params.workspace}/${params.repo_slug}`, 569 | params, 570 | ); 571 | 572 | // Validate params with Zod 573 | try { 574 | ListBranchesParamsSchema.parse(params); 575 | } catch (error) { 576 | if (error instanceof z.ZodError) { 577 | methodLogger.error( 578 | 'Invalid parameters provided to list branches:', 579 | error.format(), 580 | ); 581 | throw createApiError( 582 | `Invalid parameters: ${error.issues.map((e) => e.message).join(', ')}`, 583 | 400, 584 | error, 585 | ); 586 | } 587 | throw error; 588 | } 589 | 590 | const credentials = getAtlassianCredentials(); 591 | if (!credentials) { 592 | throw createAuthMissingError( 593 | 'Atlassian credentials are required for this operation', 594 | ); 595 | } 596 | 597 | const queryParams = new URLSearchParams(); 598 | if (params.q) { 599 | queryParams.set('q', params.q); 600 | } 601 | if (params.sort) { 602 | queryParams.set('sort', params.sort); 603 | } 604 | if (params.pagelen) { 605 | queryParams.set('pagelen', params.pagelen.toString()); 606 | } 607 | if (params.page) { 608 | queryParams.set('page', params.page.toString()); 609 | } 610 | 611 | const queryString = queryParams.toString() 612 | ? `?${queryParams.toString()}` 613 | : ''; 614 | const path = `${API_PATH}/repositories/${params.workspace}/${params.repo_slug}/refs/branches${queryString}`; 615 | 616 | methodLogger.debug(`Sending branches request to: ${path}`); 617 | try { 618 | const rawData = await fetchAtlassian(credentials, path); 619 | // Validate response with Zod schema 620 | try { 621 | const validatedData = BranchesResponseSchema.parse(rawData); 622 | return validatedData; 623 | } catch (error) { 624 | if (error instanceof z.ZodError) { 625 | methodLogger.error( 626 | 'Invalid response from Bitbucket API:', 627 | error.format(), 628 | ); 629 | throw createApiError( 630 | 'Received invalid response format from Bitbucket API', 631 | 500, 632 | error, 633 | ); 634 | } 635 | throw error; 636 | } 637 | } catch (error) { 638 | if (error instanceof McpError) { 639 | throw error; 640 | } 641 | throw createApiError( 642 | `Failed to list branches: ${error instanceof Error ? error.message : String(error)}`, 643 | 500, 644 | error, 645 | ); 646 | } 647 | } 648 | 649 | export default { 650 | list, 651 | get, 652 | listCommits, 653 | createBranch, 654 | getFileContent, 655 | listBranches, 656 | }; 657 | ``` -------------------------------------------------------------------------------- /src/cli/atlassian.pullrequests.cli.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { Command } from 'commander'; 2 | import { Logger } from '../utils/logger.util.js'; 3 | import { handleCliError } from '../utils/error.util.js'; 4 | import atlassianPullRequestsController from '../controllers/atlassian.pullrequests.controller.js'; 5 | 6 | /** 7 | * CLI module for managing Bitbucket pull requests. 8 | * Provides commands for listing, retrieving, and manipulating pull requests. 9 | * All commands require valid Atlassian credentials. 10 | */ 11 | 12 | // Create a contextualized logger for this file 13 | const cliLogger = Logger.forContext('cli/atlassian.pullrequests.cli.ts'); 14 | 15 | // Log CLI initialization 16 | cliLogger.debug('Bitbucket pull requests CLI module initialized'); 17 | 18 | /** 19 | * Register Bitbucket pull requests CLI commands with the Commander program 20 | * @param program - The Commander program instance to register commands with 21 | * @throws Error if command registration fails 22 | */ 23 | function register(program: Command): void { 24 | const methodLogger = Logger.forContext( 25 | 'cli/atlassian.pullrequests.cli.ts', 26 | 'register', 27 | ); 28 | methodLogger.debug('Registering Bitbucket Pull Requests CLI commands...'); 29 | 30 | registerListPullRequestsCommand(program); 31 | registerGetPullRequestCommand(program); 32 | registerListPullRequestCommentsCommand(program); 33 | registerAddPullRequestCommentCommand(program); 34 | registerAddPullRequestCommand(program); 35 | registerUpdatePullRequestCommand(program); 36 | registerApprovePullRequestCommand(program); 37 | registerRejectPullRequestCommand(program); 38 | 39 | methodLogger.debug('CLI commands registered successfully'); 40 | } 41 | 42 | /** 43 | * Register the command for listing Bitbucket pull requests 44 | * @param program - The Commander program instance 45 | */ 46 | function registerListPullRequestsCommand(program: Command): void { 47 | program 48 | .command('ls-prs') 49 | .description( 50 | 'List pull requests in a Bitbucket repository, with filtering and pagination.', 51 | ) 52 | .option( 53 | '-w, --workspace-slug <slug>', 54 | 'Workspace slug containing the repository. If not provided, the system will use your default workspace (either configured via BITBUCKET_DEFAULT_WORKSPACE or the first workspace in your account). Example: "myteam"', 55 | ) 56 | .requiredOption( 57 | '-r, --repo-slug <slug>', 58 | 'Repository slug containing the pull requests. This must be a valid repository in the specified workspace. Example: "project-api"', 59 | ) 60 | .option( 61 | '-s, --state <state>', 62 | 'Filter by pull request state: "OPEN", "MERGED", "DECLINED", or "SUPERSEDED". If omitted, returns pull requests in all states.', 63 | ) 64 | .option( 65 | '-q, --query <string>', 66 | 'Filter pull requests by query string. Searches pull request title and description.', 67 | ) 68 | .option( 69 | '-l, --limit <number>', 70 | 'Maximum number of items to return (1-100). Defaults to 25 if omitted.', 71 | ) 72 | .option( 73 | '-c, --cursor <string>', 74 | 'Pagination cursor for retrieving the next set of results.', 75 | ) 76 | .action(async (options) => { 77 | const actionLogger = Logger.forContext( 78 | 'cli/atlassian.pullrequests.cli.ts', 79 | 'ls-prs', 80 | ); 81 | try { 82 | actionLogger.debug('Processing command options:', options); 83 | 84 | // Map CLI options to controller params - keep only type conversions 85 | const filterOptions = { 86 | workspaceSlug: options.workspaceSlug, 87 | repoSlug: options.repoSlug, 88 | state: options.state as 89 | | 'OPEN' 90 | | 'MERGED' 91 | | 'DECLINED' 92 | | 'SUPERSEDED' 93 | | undefined, 94 | query: options.query, 95 | limit: options.limit 96 | ? parseInt(options.limit, 10) 97 | : undefined, 98 | cursor: options.cursor, 99 | }; 100 | 101 | actionLogger.debug( 102 | 'Fetching pull requests with filters:', 103 | filterOptions, 104 | ); 105 | const result = 106 | await atlassianPullRequestsController.list(filterOptions); 107 | actionLogger.debug('Successfully retrieved pull requests'); 108 | 109 | // Display the content which now includes pagination information 110 | console.log(result.content); 111 | } catch (error) { 112 | actionLogger.error('Operation failed:', error); 113 | handleCliError(error); 114 | } 115 | }); 116 | } 117 | 118 | /** 119 | * Register the command for retrieving a specific Bitbucket pull request 120 | * @param program - The Commander program instance 121 | */ 122 | function registerGetPullRequestCommand(program: Command): void { 123 | program 124 | .command('get-pr') 125 | .description( 126 | 'Get detailed information about a specific Bitbucket pull request.', 127 | ) 128 | .option( 129 | '-w, --workspace-slug <slug>', 130 | 'Workspace slug containing the repository. If not provided, uses your default workspace. Example: "myteam"', 131 | ) 132 | .requiredOption( 133 | '-r, --repo-slug <slug>', 134 | 'Repository slug containing the pull request. Must be a valid repository in the specified workspace. Example: "project-api"', 135 | ) 136 | .requiredOption( 137 | '-p, --pr-id <id>', 138 | 'Numeric ID of the pull request to retrieve. Must be a valid pull request ID in the specified repository. Example: "42"', 139 | ) 140 | .option( 141 | '--include-full-diff', 142 | 'Retrieve the full diff content instead of just the summary. Default: true (rich output by default)', 143 | true, 144 | ) 145 | .option( 146 | '--include-comments', 147 | 'Retrieve comments for the pull request. Default: false. Note: Enabling this may increase response time for pull requests with many comments due to additional API calls', 148 | false, 149 | ) 150 | .action(async (options) => { 151 | const actionLogger = Logger.forContext( 152 | 'cli/atlassian.pullrequests.cli.ts', 153 | 'get-pr', 154 | ); 155 | try { 156 | actionLogger.debug('Processing command options:', options); 157 | 158 | // Map CLI options to controller params 159 | const params = { 160 | workspaceSlug: options.workspaceSlug, 161 | repoSlug: options.repoSlug, 162 | prId: options.prId, 163 | includeFullDiff: options.includeFullDiff, 164 | includeComments: options.includeComments, 165 | }; 166 | 167 | actionLogger.debug('Fetching pull request:', params); 168 | const result = 169 | await atlassianPullRequestsController.get(params); 170 | actionLogger.debug('Successfully retrieved pull request'); 171 | 172 | console.log(result.content); 173 | } catch (error) { 174 | actionLogger.error('Operation failed:', error); 175 | handleCliError(error); 176 | } 177 | }); 178 | } 179 | 180 | /** 181 | * Register the command for listing comments on a Bitbucket pull request 182 | * @param program - The Commander program instance 183 | */ 184 | function registerListPullRequestCommentsCommand(program: Command): void { 185 | program 186 | .command('ls-pr-comments') 187 | .description( 188 | 'List comments on a specific Bitbucket pull request, with pagination.', 189 | ) 190 | .option( 191 | '-w, --workspace-slug <slug>', 192 | 'Workspace slug containing the repository. If not provided, uses your default workspace. Example: "myteam"', 193 | ) 194 | .requiredOption( 195 | '-r, --repo-slug <slug>', 196 | 'Repository slug containing the pull request. Must be a valid repository in the specified workspace. Example: "project-api"', 197 | ) 198 | .requiredOption( 199 | '-p, --pr-id <id>', 200 | 'Numeric ID of the pull request to retrieve comments from. Must be a valid pull request ID in the specified repository. Example: "42"', 201 | ) 202 | .option( 203 | '-l, --limit <number>', 204 | 'Maximum number of items to return (1-100). Defaults to 25 if omitted.', 205 | ) 206 | .option( 207 | '-c, --cursor <string>', 208 | 'Pagination cursor for retrieving the next set of results.', 209 | ) 210 | .action(async (options) => { 211 | const actionLogger = Logger.forContext( 212 | 'cli/atlassian.pullrequests.cli.ts', 213 | 'ls-pr-comments', 214 | ); 215 | try { 216 | actionLogger.debug('Processing command options:', options); 217 | 218 | // Map CLI options to controller params - keep only type conversions 219 | const params = { 220 | workspaceSlug: options.workspaceSlug, 221 | repoSlug: options.repoSlug, 222 | prId: options.prId, 223 | limit: options.limit 224 | ? parseInt(options.limit, 10) 225 | : undefined, 226 | cursor: options.cursor, 227 | }; 228 | 229 | actionLogger.debug('Fetching pull request comments:', params); 230 | const result = 231 | await atlassianPullRequestsController.listComments(params); 232 | actionLogger.debug( 233 | 'Successfully retrieved pull request comments', 234 | ); 235 | 236 | // Display the content which now includes pagination information 237 | console.log(result.content); 238 | } catch (error) { 239 | actionLogger.error('Operation failed:', error); 240 | handleCliError(error); 241 | } 242 | }); 243 | } 244 | 245 | /** 246 | * Register the command for adding a comment to a pull request 247 | * @param program - The Commander program instance 248 | */ 249 | function registerAddPullRequestCommentCommand(program: Command): void { 250 | program 251 | .command('add-pr-comment') 252 | .description('Add a comment to a Bitbucket pull request.') 253 | .option( 254 | '-w, --workspace-slug <slug>', 255 | 'Workspace slug containing the repository. If not provided, uses your default workspace. Example: "myteam"', 256 | ) 257 | .requiredOption( 258 | '-r, --repo-slug <slug>', 259 | 'Repository slug containing the pull request. Must be a valid repository in the specified workspace. Example: "project-api"', 260 | ) 261 | .requiredOption( 262 | '-p, --pr-id <id>', 263 | 'Numeric ID of the pull request to add a comment to. Must be a valid pull request ID in the specified repository. Example: "42"', 264 | ) 265 | .requiredOption( 266 | '-m, --content <text>', 267 | 'The content of the comment to add to the pull request. Can include markdown formatting.', 268 | ) 269 | .option( 270 | '-f, --path <file-path>', 271 | 'Optional: The file path to add an inline comment to.', 272 | ) 273 | .option( 274 | '-L, --line <line-number>', 275 | 'Optional: The line number to add the inline comment to.', 276 | parseInt, 277 | ) 278 | .option( 279 | '--parent-id <id>', 280 | 'Optional: The ID of the parent comment to reply to. If provided, this comment will be a reply to the specified comment.', 281 | ) 282 | .action(async (options) => { 283 | const actionLogger = Logger.forContext( 284 | 'cli/atlassian.pullrequests.cli.ts', 285 | 'add-pr-comment', 286 | ); 287 | try { 288 | actionLogger.debug('Processing command options:', options); 289 | 290 | // Build parameters object 291 | const params: { 292 | workspaceSlug?: string; 293 | repoSlug: string; 294 | prId: string; 295 | content: string; 296 | inline?: { 297 | path: string; 298 | line: number; 299 | }; 300 | parentId?: string; 301 | } = { 302 | workspaceSlug: options.workspaceSlug, 303 | repoSlug: options.repoSlug, 304 | prId: options.prId, 305 | content: options.content, 306 | }; 307 | 308 | // Add inline comment details if both path and line are provided 309 | if (options.path && options.line) { 310 | params.inline = { 311 | path: options.path, 312 | line: options.line, 313 | }; 314 | } else if (options.path || options.line) { 315 | throw new Error( 316 | 'Both -f/--path and -L/--line are required for inline comments', 317 | ); 318 | } 319 | 320 | // Add parent ID if provided 321 | if (options.parentId) { 322 | params.parentId = options.parentId; 323 | } 324 | 325 | actionLogger.debug('Creating pull request comment:', { 326 | ...params, 327 | content: '(content length: ' + options.content.length + ')', 328 | }); 329 | const result = 330 | await atlassianPullRequestsController.addComment(params); 331 | actionLogger.debug('Successfully created pull request comment'); 332 | 333 | console.log(result.content); 334 | } catch (error) { 335 | actionLogger.error('Operation failed:', error); 336 | handleCliError(error); 337 | } 338 | }); 339 | } 340 | 341 | /** 342 | * Register the command for adding a new pull request 343 | * @param program - The Commander program instance 344 | */ 345 | function registerAddPullRequestCommand(program: Command): void { 346 | program 347 | .command('add-pr') 348 | .description('Add a new pull request in a Bitbucket repository.') 349 | .option( 350 | '-w, --workspace-slug <slug>', 351 | 'Workspace slug containing the repository. If not provided, uses your default workspace. Example: "myteam"', 352 | ) 353 | .requiredOption( 354 | '-r, --repo-slug <slug>', 355 | 'Repository slug to create the pull request in. Must be a valid repository in the specified workspace. Example: "project-api"', 356 | ) 357 | .requiredOption( 358 | '-t, --title <title>', 359 | 'Title for the pull request. Example: "Add new feature"', 360 | ) 361 | .requiredOption( 362 | '-s, --source-branch <branch>', 363 | 'Source branch name (the branch containing your changes). Example: "feature/new-login"', 364 | ) 365 | .option( 366 | '-d, --destination-branch <branch>', 367 | 'Destination branch name (the branch you want to merge into, defaults to main). Example: "develop"', 368 | ) 369 | .option( 370 | '--description <text>', 371 | 'Optional description for the pull request.', 372 | ) 373 | .option( 374 | '--close-source-branch', 375 | 'Whether to close the source branch after the pull request is merged. Default: false', 376 | false, 377 | ) 378 | .action(async (options) => { 379 | const actionLogger = Logger.forContext( 380 | 'cli/atlassian.pullrequests.cli.ts', 381 | 'add-pr', 382 | ); 383 | try { 384 | actionLogger.debug('Processing command options:', options); 385 | 386 | // Map CLI options to controller params 387 | const params = { 388 | workspaceSlug: options.workspaceSlug, 389 | repoSlug: options.repoSlug, 390 | title: options.title, 391 | sourceBranch: options.sourceBranch, 392 | destinationBranch: options.destinationBranch, 393 | description: options.description, 394 | closeSourceBranch: options.closeSourceBranch, 395 | }; 396 | 397 | actionLogger.debug('Creating pull request:', { 398 | ...params, 399 | description: options.description 400 | ? '(description provided)' 401 | : '(no description)', 402 | }); 403 | const result = 404 | await atlassianPullRequestsController.add(params); 405 | actionLogger.debug('Successfully created pull request'); 406 | 407 | console.log(result.content); 408 | } catch (error) { 409 | actionLogger.error('Operation failed:', error); 410 | handleCliError(error); 411 | } 412 | }); 413 | } 414 | 415 | /** 416 | * Register the command for updating a Bitbucket pull request 417 | * @param program - The Commander program instance 418 | */ 419 | function registerUpdatePullRequestCommand(program: Command): void { 420 | program 421 | .command('update-pr') 422 | .description( 423 | 'Update an existing pull request in a Bitbucket repository.', 424 | ) 425 | .option( 426 | '-w, --workspace-slug <slug>', 427 | 'Workspace slug containing the repository (optional, uses default workspace if not provided). Example: "myteam"', 428 | ) 429 | .requiredOption( 430 | '-r, --repo-slug <slug>', 431 | 'Repository slug containing the pull request. Example: "project-api"', 432 | ) 433 | .requiredOption( 434 | '-p, --pull-request-id <id>', 435 | 'Pull request ID to update. Example: 123', 436 | parseInt, 437 | ) 438 | .option( 439 | '-t, --title <title>', 440 | 'Updated title for the pull request. Example: "Updated Feature Implementation"', 441 | ) 442 | .option( 443 | '--description <text>', 444 | 'Updated description for the pull request.', 445 | ) 446 | .action(async (options) => { 447 | const actionLogger = Logger.forContext( 448 | 'cli/atlassian.pullrequests.cli.ts', 449 | 'update-pr', 450 | ); 451 | try { 452 | actionLogger.debug('Processing command options:', options); 453 | 454 | // Validate that at least one field to update is provided 455 | if (!options.title && !options.description) { 456 | throw new Error( 457 | 'At least one field to update (title or description) must be provided', 458 | ); 459 | } 460 | 461 | // Map CLI options to controller params 462 | const params = { 463 | workspaceSlug: options.workspaceSlug, 464 | repoSlug: options.repoSlug, 465 | pullRequestId: options.pullRequestId, 466 | title: options.title, 467 | description: options.description, 468 | }; 469 | 470 | actionLogger.debug('Updating pull request:', { 471 | ...params, 472 | description: options.description 473 | ? '(description provided)' 474 | : '(no description)', 475 | }); 476 | const result = 477 | await atlassianPullRequestsController.update(params); 478 | actionLogger.debug('Successfully updated pull request'); 479 | 480 | console.log(result.content); 481 | } catch (error) { 482 | actionLogger.error('Operation failed:', error); 483 | handleCliError(error); 484 | } 485 | }); 486 | } 487 | 488 | /** 489 | * Register the command for approving a Bitbucket pull request 490 | * @param program - The Commander program instance 491 | */ 492 | function registerApprovePullRequestCommand(program: Command): void { 493 | program 494 | .command('approve-pr') 495 | .description('Approve a pull request in a Bitbucket repository.') 496 | .option( 497 | '-w, --workspace-slug <slug>', 498 | 'Workspace slug containing the repository (optional, uses default workspace if not provided). Example: "myteam"', 499 | ) 500 | .requiredOption( 501 | '-r, --repo-slug <slug>', 502 | 'Repository slug containing the pull request. Example: "project-api"', 503 | ) 504 | .requiredOption( 505 | '-p, --pull-request-id <id>', 506 | 'Pull request ID to approve. Example: 123', 507 | parseInt, 508 | ) 509 | .action(async (options) => { 510 | const actionLogger = Logger.forContext( 511 | 'cli/atlassian.pullrequests.cli.ts', 512 | 'approve-pr', 513 | ); 514 | try { 515 | actionLogger.debug('Processing command options:', options); 516 | 517 | // Map CLI options to controller params 518 | const params = { 519 | workspaceSlug: options.workspaceSlug, 520 | repoSlug: options.repoSlug, 521 | pullRequestId: options.pullRequestId, 522 | }; 523 | 524 | actionLogger.debug('Approving pull request:', params); 525 | const result = 526 | await atlassianPullRequestsController.approve(params); 527 | actionLogger.debug('Successfully approved pull request'); 528 | 529 | console.log(result.content); 530 | } catch (error) { 531 | actionLogger.error('Operation failed:', error); 532 | handleCliError(error); 533 | } 534 | }); 535 | } 536 | 537 | /** 538 | * Register the command for requesting changes on a Bitbucket pull request 539 | * @param program - The Commander program instance 540 | */ 541 | function registerRejectPullRequestCommand(program: Command): void { 542 | program 543 | .command('reject-pr') 544 | .description( 545 | 'Request changes on a pull request in a Bitbucket repository.', 546 | ) 547 | .option( 548 | '-w, --workspace-slug <slug>', 549 | 'Workspace slug containing the repository (optional, uses default workspace if not provided). Example: "myteam"', 550 | ) 551 | .requiredOption( 552 | '-r, --repo-slug <slug>', 553 | 'Repository slug containing the pull request. Example: "project-api"', 554 | ) 555 | .requiredOption( 556 | '-p, --pull-request-id <id>', 557 | 'Pull request ID to request changes on. Example: 123', 558 | parseInt, 559 | ) 560 | .action(async (options) => { 561 | const actionLogger = Logger.forContext( 562 | 'cli/atlassian.pullrequests.cli.ts', 563 | 'reject-pr', 564 | ); 565 | try { 566 | actionLogger.debug('Processing command options:', options); 567 | 568 | // Map CLI options to controller params 569 | const params = { 570 | workspaceSlug: options.workspaceSlug, 571 | repoSlug: options.repoSlug, 572 | pullRequestId: options.pullRequestId, 573 | }; 574 | 575 | actionLogger.debug( 576 | 'Requesting changes on pull request:', 577 | params, 578 | ); 579 | const result = 580 | await atlassianPullRequestsController.reject(params); 581 | actionLogger.debug( 582 | 'Successfully requested changes on pull request', 583 | ); 584 | 585 | console.log(result.content); 586 | } catch (error) { 587 | actionLogger.error('Operation failed:', error); 588 | handleCliError(error); 589 | } 590 | }); 591 | } 592 | 593 | export default { register }; 594 | ``` -------------------------------------------------------------------------------- /src/cli/atlassian.pullrequests.cli.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { CliTestUtil } from '../utils/cli.test.util.js'; 2 | import { getAtlassianCredentials } from '../utils/transport.util.js'; 3 | import { config } from '../utils/config.util.js'; 4 | 5 | describe('Atlassian Pull Requests CLI Commands', () => { 6 | // Load configuration and check for credentials before all tests 7 | beforeAll(() => { 8 | // Load configuration from all sources 9 | config.load(); 10 | 11 | // Log warning if credentials aren't available 12 | const credentials = getAtlassianCredentials(); 13 | if (!credentials) { 14 | console.warn( 15 | 'Skipping Atlassian Pull Requests CLI tests: No credentials available', 16 | ); 17 | } 18 | }); 19 | 20 | // Helper function to skip tests when credentials are missing 21 | const skipIfNoCredentials = () => { 22 | const credentials = getAtlassianCredentials(); 23 | if (!credentials) { 24 | return true; 25 | } 26 | return false; 27 | }; 28 | 29 | // Helper to get workspace and repository for testing 30 | async function getWorkspaceAndRepo(): Promise<{ 31 | workspace: string; 32 | repository: string; 33 | } | null> { 34 | // First, get a list of workspaces 35 | const workspacesResult = await CliTestUtil.runCommand([ 36 | 'ls-workspaces', 37 | ]); 38 | 39 | // Skip if no workspaces are available 40 | if ( 41 | workspacesResult.stdout.includes('No Bitbucket workspaces found.') 42 | ) { 43 | return null; // Skip silently for this helper function 44 | } 45 | 46 | // Extract a workspace slug from the output 47 | const slugMatch = workspacesResult.stdout.match( 48 | /\*\*Slug\*\*:\s+([^\n]+)/, 49 | ); 50 | if (!slugMatch || !slugMatch[1]) { 51 | return null; // Skip silently for this helper function 52 | } 53 | 54 | const workspaceSlug = slugMatch[1].trim(); 55 | 56 | // Get repositories for this workspace 57 | const reposResult = await CliTestUtil.runCommand([ 58 | 'ls-repos', 59 | '--workspace-slug', 60 | workspaceSlug, 61 | ]); 62 | 63 | // Skip if no repositories are available 64 | if (reposResult.stdout.includes('No repositories found')) { 65 | return null; // Skip silently for this helper function 66 | } 67 | 68 | // Extract a repository slug from the output 69 | const repoMatch = reposResult.stdout.match(/\*\*Name\*\*:\s+([^\n]+)/); 70 | if (!repoMatch || !repoMatch[1]) { 71 | return null; // Skip silently for this helper function 72 | } 73 | 74 | const repoSlug = repoMatch[1].trim(); 75 | 76 | return { 77 | workspace: workspaceSlug, 78 | repository: repoSlug, 79 | }; 80 | } 81 | 82 | describe('ls-prs command', () => { 83 | it('should list pull requests for a repository', async () => { 84 | if (skipIfNoCredentials()) { 85 | return; 86 | } 87 | 88 | // Get a valid workspace and repository 89 | const repoInfo = await getWorkspaceAndRepo(); 90 | if (!repoInfo) { 91 | return; // Skip if no valid workspace/repo found 92 | } 93 | 94 | // Run the CLI command 95 | const result = await CliTestUtil.runCommand([ 96 | 'ls-prs', 97 | '--workspace-slug', 98 | repoInfo.workspace, 99 | '--repo-slug', 100 | repoInfo.repository, 101 | ]); 102 | 103 | // Instead of expecting success, handle both success and failure 104 | if (result.exitCode !== 0) { 105 | console.warn( 106 | 'Skipping test validation: Could not list pull requests', 107 | ); 108 | return; 109 | } 110 | 111 | // Verify the output format 112 | if (!result.stdout.includes('No pull requests found')) { 113 | // Validate expected Markdown structure 114 | CliTestUtil.validateOutputContains(result.stdout, [ 115 | '# Bitbucket Pull Requests', 116 | '**ID**', 117 | '**State**', 118 | '**Author**', 119 | ]); 120 | 121 | // Validate Markdown formatting 122 | CliTestUtil.validateMarkdownOutput(result.stdout); 123 | } 124 | }, 30000); // Increased timeout for API calls 125 | 126 | it('should support filtering by state', async () => { 127 | if (skipIfNoCredentials()) { 128 | return; 129 | } 130 | 131 | // Get a valid workspace and repository 132 | const repoInfo = await getWorkspaceAndRepo(); 133 | if (!repoInfo) { 134 | return; // Skip if no valid workspace/repo found 135 | } 136 | 137 | // States to test 138 | const states = ['OPEN', 'MERGED', 'DECLINED']; 139 | let testsPassed = false; 140 | 141 | for (const state of states) { 142 | // Run the CLI command with state filter 143 | const result = await CliTestUtil.runCommand([ 144 | 'ls-prs', 145 | '--workspace-slug', 146 | repoInfo.workspace, 147 | '--repo-slug', 148 | repoInfo.repository, 149 | '--state', 150 | state, 151 | ]); 152 | 153 | // If any command succeeds, consider the test as passed 154 | if (result.exitCode === 0) { 155 | testsPassed = true; 156 | 157 | // Verify the output includes state if PRs are found 158 | if (!result.stdout.includes('No pull requests found')) { 159 | expect(result.stdout.toUpperCase()).toContain(state); 160 | } 161 | } 162 | } 163 | 164 | // If all commands failed, log a warning and consider the test skipped 165 | if (!testsPassed) { 166 | console.warn( 167 | 'Skipping test validation: Could not list pull requests with state filter', 168 | ); 169 | } 170 | }, 45000); // Increased timeout for multiple API calls 171 | 172 | it('should support pagination with --limit flag', async () => { 173 | if (skipIfNoCredentials()) { 174 | return; 175 | } 176 | 177 | // Get a valid workspace and repository 178 | const repoInfo = await getWorkspaceAndRepo(); 179 | if (!repoInfo) { 180 | return; // Skip if no valid workspace/repo found 181 | } 182 | 183 | // Run the CLI command with limit 184 | const result = await CliTestUtil.runCommand([ 185 | 'ls-prs', 186 | '--workspace-slug', 187 | repoInfo.workspace, 188 | '--repo-slug', 189 | repoInfo.repository, 190 | '--limit', 191 | '1', 192 | ]); 193 | 194 | // Instead of expecting success, handle both success and failure 195 | if (result.exitCode !== 0) { 196 | console.warn( 197 | 'Skipping test validation: Could not list pull requests with pagination', 198 | ); 199 | return; 200 | } 201 | 202 | // If there are multiple PRs, pagination section should be present 203 | if ( 204 | !result.stdout.includes('No pull requests found') && 205 | result.stdout.includes('items remaining') 206 | ) { 207 | CliTestUtil.validateOutputContains(result.stdout, [ 208 | 'Pagination', 209 | 'Next cursor:', 210 | ]); 211 | } 212 | }, 30000); 213 | }); 214 | 215 | describe('get-pr command', () => { 216 | it('should retrieve details for a specific pull request', async () => { 217 | if (skipIfNoCredentials()) { 218 | return; 219 | } 220 | 221 | // Get a valid workspace and repository 222 | const repoInfo = await getWorkspaceAndRepo(); 223 | if (!repoInfo) { 224 | return; // Skip if no valid workspace/repo found 225 | } 226 | 227 | // First, list pull requests to find a valid ID 228 | const listResult = await CliTestUtil.runCommand([ 229 | 'ls-prs', 230 | '--workspace-slug', 231 | repoInfo.workspace, 232 | '--repo-slug', 233 | repoInfo.repository, 234 | ]); 235 | 236 | // Skip if no pull requests are available 237 | if (listResult.stdout.includes('No pull requests found')) { 238 | return; // Skip silently - no pull requests available 239 | } 240 | 241 | // Extract a pull request ID from the output 242 | const prMatch = listResult.stdout.match(/\*\*ID\*\*:\s+(\d+)/); 243 | if (!prMatch || !prMatch[1]) { 244 | console.warn( 245 | 'Skipping test: Could not extract pull request ID', 246 | ); 247 | return; 248 | } 249 | 250 | const prId = prMatch[1].trim(); 251 | 252 | // Skip the full validation since we can't guarantee PRs exist 253 | // Just verify that the test can find a valid ID and run the command 254 | if (prId) { 255 | // Run the get-pr command 256 | const getResult = await CliTestUtil.runCommand([ 257 | 'get-pr', 258 | '--workspace-slug', 259 | repoInfo.workspace, 260 | '--repo-slug', 261 | repoInfo.repository, 262 | '--pr-id', 263 | prId, 264 | ]); 265 | 266 | // The test may pass or fail depending on if the PR exists 267 | // Just check that we get a result back 268 | expect(getResult).toBeDefined(); 269 | } else { 270 | // Skip test if no PR ID found 271 | return; // Skip silently - no pull request ID available 272 | } 273 | }, 45000); // Increased timeout for multiple API calls 274 | 275 | it('should handle missing required parameters', async () => { 276 | if (skipIfNoCredentials()) { 277 | return; 278 | } 279 | 280 | // Test without workspace parameter (now optional) 281 | const missingWorkspace = await CliTestUtil.runCommand([ 282 | 'get-pr', 283 | '--repo-slug', 284 | 'some-repo', 285 | '--pr-id', 286 | '1', 287 | ]); 288 | 289 | // Now that workspace is optional, we should get a different error 290 | // (repository not found), but not a missing parameter error 291 | expect(missingWorkspace.exitCode).not.toBe(0); 292 | expect(missingWorkspace.stderr).not.toContain('workspace-slug'); 293 | 294 | // Test without repository parameter 295 | const missingRepo = await CliTestUtil.runCommand([ 296 | 'get-pr', 297 | '--workspace-slug', 298 | 'some-workspace', 299 | '--pr-id', 300 | '1', 301 | ]); 302 | 303 | // Should fail with non-zero exit code 304 | expect(missingRepo.exitCode).not.toBe(0); 305 | expect(missingRepo.stderr).toContain('required option'); 306 | 307 | // Test without pull-request parameter 308 | const missingPR = await CliTestUtil.runCommand([ 309 | 'get-pr', 310 | '--workspace-slug', 311 | 'some-workspace', 312 | '--repo-slug', 313 | 'some-repo', 314 | ]); 315 | 316 | // Should fail with non-zero exit code 317 | expect(missingPR.exitCode).not.toBe(0); 318 | expect(missingPR.stderr).toContain('required option'); 319 | }, 15000); 320 | }); 321 | 322 | describe('ls-pr-comments command', () => { 323 | it('should list comments for a specific pull request', async () => { 324 | if (skipIfNoCredentials()) { 325 | return; 326 | } 327 | 328 | // Get a valid workspace and repository 329 | const repoInfo = await getWorkspaceAndRepo(); 330 | if (!repoInfo) { 331 | return; // Skip if no valid workspace/repo found 332 | } 333 | 334 | // First, list pull requests to find a valid ID 335 | const listResult = await CliTestUtil.runCommand([ 336 | 'ls-prs', 337 | '--workspace-slug', 338 | repoInfo.workspace, 339 | '--repo-slug', 340 | repoInfo.repository, 341 | ]); 342 | 343 | // Skip if no pull requests are available 344 | if (listResult.stdout.includes('No pull requests found')) { 345 | return; // Skip silently - no pull requests available 346 | } 347 | 348 | // Extract a pull request ID from the output 349 | const prMatch = listResult.stdout.match(/\*\*ID\*\*:\s+(\d+)/); 350 | if (!prMatch || !prMatch[1]) { 351 | console.warn( 352 | 'Skipping test: Could not extract pull request ID', 353 | ); 354 | return; 355 | } 356 | 357 | const prId = prMatch[1].trim(); 358 | 359 | // Skip the full validation since we can't guarantee PRs exist 360 | // Just verify that the test can find a valid ID and run the command 361 | if (prId) { 362 | // Run the ls-pr-comments command 363 | const result = await CliTestUtil.runCommand([ 364 | 'ls-pr-comments', 365 | '--workspace-slug', 366 | repoInfo.workspace, 367 | '--repo-slug', 368 | repoInfo.repository, 369 | '--pr-id', 370 | prId, 371 | ]); 372 | 373 | // The test may pass or fail depending on if the PR exists 374 | // Just check that we get a result back 375 | expect(result).toBeDefined(); 376 | } else { 377 | // Skip test if no PR ID found 378 | return; // Skip silently - no pull request ID available 379 | } 380 | }, 45000); // Increased timeout for multiple API calls 381 | 382 | it('should handle missing required parameters', async () => { 383 | if (skipIfNoCredentials()) { 384 | return; 385 | } 386 | 387 | // Test without workspace parameter (now optional) 388 | const missingWorkspace = await CliTestUtil.runCommand([ 389 | 'ls-pr-comments', 390 | '--repo-slug', 391 | 'some-repo', 392 | '--pr-id', 393 | '1', 394 | ]); 395 | 396 | // Now that workspace is optional, we should get a different error 397 | // (repository not found), but not a missing parameter error 398 | expect(missingWorkspace.exitCode).not.toBe(0); 399 | expect(missingWorkspace.stderr).not.toContain('workspace-slug'); 400 | 401 | // Test without repository parameter 402 | const missingRepo = await CliTestUtil.runCommand([ 403 | 'ls-pr-comments', 404 | '--workspace-slug', 405 | 'some-workspace', 406 | '--pr-id', 407 | '1', 408 | ]); 409 | 410 | // Should fail with non-zero exit code 411 | expect(missingRepo.exitCode).not.toBe(0); 412 | expect(missingRepo.stderr).toContain('required option'); 413 | 414 | // Test without pull-request parameter 415 | const missingPR = await CliTestUtil.runCommand([ 416 | 'ls-pr-comments', 417 | '--workspace-slug', 418 | 'some-workspace', 419 | '--repo-slug', 420 | 'some-repo', 421 | ]); 422 | 423 | // Should fail with non-zero exit code 424 | expect(missingPR.exitCode).not.toBe(0); 425 | expect(missingPR.stderr).toContain('required option'); 426 | }, 15000); 427 | }); 428 | 429 | describe('ls-pr-comments with pagination', () => { 430 | it('should list comments for a specific pull request with pagination', async () => { 431 | if (skipIfNoCredentials()) { 432 | return; 433 | } 434 | 435 | // Get a valid workspace and repository 436 | const repoInfo = await getWorkspaceAndRepo(); 437 | if (!repoInfo) { 438 | return; // Skip if no valid workspace/repo found 439 | } 440 | 441 | // First, list pull requests to find a valid ID 442 | const listResult = await CliTestUtil.runCommand([ 443 | 'ls-prs', 444 | '--workspace-slug', 445 | repoInfo.workspace, 446 | '--repo-slug', 447 | repoInfo.repository, 448 | ]); 449 | 450 | // Skip if no pull requests are available 451 | if (listResult.stdout.includes('No pull requests found')) { 452 | return; // Skip silently - no pull requests available 453 | } 454 | 455 | // Extract a pull request ID from the output 456 | const prMatch = listResult.stdout.match(/\*\*ID\*\*:\s+(\d+)/); 457 | if (!prMatch || !prMatch[1]) { 458 | console.warn( 459 | 'Skipping test: Could not extract pull request ID', 460 | ); 461 | return; 462 | } 463 | 464 | const prId = prMatch[1].trim(); 465 | 466 | // Skip the full validation since we can't guarantee PRs exist 467 | // Just verify that the test can find a valid ID and run the command 468 | if (prId) { 469 | // Run with pagination limit 470 | const limitResult = await CliTestUtil.runCommand([ 471 | 'ls-pr-comments', 472 | '--workspace-slug', 473 | repoInfo.workspace, 474 | '--repo-slug', 475 | repoInfo.repository, 476 | '--pr-id', 477 | prId, 478 | '--limit', 479 | '1', 480 | ]); 481 | 482 | // The test may pass or fail depending on if the PR exists 483 | // Just check that we get a result back 484 | expect(limitResult).toBeDefined(); 485 | } else { 486 | // Skip test if no PR ID found 487 | return; // Skip silently - no pull request ID available 488 | } 489 | }, 45000); // Increased timeout for multiple API calls 490 | }); 491 | 492 | describe('add-pr-comment command', () => { 493 | it('should display help information', async () => { 494 | const result = await CliTestUtil.runCommand([ 495 | 'add-pr-comment', 496 | '--help', 497 | ]); 498 | expect(result.exitCode).toBe(0); 499 | expect(result.stdout).toContain( 500 | 'Add a comment to a Bitbucket pull request.', 501 | ); 502 | expect(result.stdout).toContain('--workspace-slug'); 503 | expect(result.stdout).toContain('--repo-slug'); 504 | expect(result.stdout).toContain('--pr-id'); 505 | expect(result.stdout).toContain('--content'); 506 | expect(result.stdout).toContain('--path'); 507 | expect(result.stdout).toContain('--line'); 508 | expect(result.stdout).toContain('--parent-id'); 509 | }); 510 | 511 | it('should require repo-slug parameter', async () => { 512 | const result = await CliTestUtil.runCommand([ 513 | 'add-pr-comment', 514 | '--workspace-slug', 515 | 'codapayments', 516 | ]); 517 | expect(result.exitCode).not.toBe(0); 518 | expect(result.stderr).toContain('required option'); 519 | expect(result.stderr).toContain('repo-slug'); 520 | }); 521 | 522 | it('should use default workspace when workspace is not provided', async () => { 523 | const result = await CliTestUtil.runCommand(['add-pr-comment']); 524 | expect(result.exitCode).not.toBe(0); 525 | expect(result.stderr).toContain('required option'); 526 | // Should require repo-slug but not workspace-slug 527 | expect(result.stderr).toContain('repo-slug'); 528 | expect(result.stderr).not.toContain('workspace-slug'); 529 | }); 530 | 531 | it('should require pr-id parameter', async () => { 532 | const result = await CliTestUtil.runCommand([ 533 | 'add-pr-comment', 534 | '--workspace-slug', 535 | 'codapayments', 536 | '--repo-slug', 537 | 'repo-1', 538 | ]); 539 | expect(result.exitCode).not.toBe(0); 540 | expect(result.stderr).toContain('required option'); 541 | expect(result.stderr).toContain('pr-id'); 542 | }); 543 | 544 | it('should require content parameter', async () => { 545 | const result = await CliTestUtil.runCommand([ 546 | 'add-pr-comment', 547 | '--workspace-slug', 548 | 'codapayments', 549 | '--repo-slug', 550 | 'repo-1', 551 | '--pr-id', 552 | '1', 553 | ]); 554 | expect(result.exitCode).not.toBe(0); 555 | expect(result.stderr).toContain('required option'); 556 | expect(result.stderr).toContain('content'); 557 | }); 558 | 559 | it('should detect incomplete inline comment parameters', async () => { 560 | const result = await CliTestUtil.runCommand([ 561 | 'add-pr-comment', 562 | '--workspace-slug', 563 | 'codapayments', 564 | '--repo-slug', 565 | 'repo-1', 566 | '--pr-id', 567 | '1', 568 | '--content', 569 | 'Test', 570 | '--path', 571 | 'README.md', 572 | ]); 573 | expect(result.exitCode).not.toBe(0); 574 | expect(result.stderr).toContain( 575 | 'Both -f/--path and -L/--line are required for inline comments', 576 | ); 577 | }); 578 | 579 | it('should accept valid parentId parameter for comment replies', async () => { 580 | const result = await CliTestUtil.runCommand([ 581 | 'add-pr-comment', 582 | '--workspace-slug', 583 | 'codapayments', 584 | '--repo-slug', 585 | 'repo-1', 586 | '--pr-id', 587 | '1', 588 | '--content', 589 | 'This is a reply', 590 | '--parent-id', 591 | '123', 592 | ]); 593 | // Should fail due to invalid repo/PR, but not due to parentId parameter validation 594 | expect(result.exitCode).not.toBe(0); 595 | // Should NOT contain parameter validation errors for parentId 596 | expect(result.stderr).not.toContain('parent-id'); 597 | }); 598 | 599 | // Note: API call test has been removed to avoid creating comments on real PRs during tests 600 | }); 601 | 602 | describe('add-pr command', () => { 603 | it('should display help information', async () => { 604 | const result = await CliTestUtil.runCommand(['add-pr', '--help']); 605 | expect(result.exitCode).toBe(0); 606 | expect(result.stdout).toContain( 607 | 'Add a new pull request in a Bitbucket repository.', 608 | ); 609 | expect(result.stdout).toContain('--workspace-slug'); 610 | expect(result.stdout).toContain('--repo-slug'); 611 | expect(result.stdout).toContain('--title'); 612 | expect(result.stdout).toContain('--source-branch'); 613 | expect(result.stdout).toContain('--destination-branch'); 614 | expect(result.stdout).toContain('--description'); 615 | expect(result.stdout).toContain('--close-source-branch'); 616 | }); 617 | 618 | it('should require repo-slug parameter', async () => { 619 | const result = await CliTestUtil.runCommand([ 620 | 'add-pr', 621 | '--workspace-slug', 622 | 'test-ws', 623 | ]); 624 | expect(result.exitCode).not.toBe(0); 625 | expect(result.stderr).toContain('required option'); 626 | expect(result.stderr).toContain('repo-slug'); 627 | }); 628 | 629 | it('should use default workspace when workspace is not provided', async () => { 630 | const result = await CliTestUtil.runCommand(['add-pr']); 631 | expect(result.exitCode).not.toBe(0); 632 | expect(result.stderr).toContain('required option'); 633 | // Should require repo-slug but not workspace-slug 634 | expect(result.stderr).toContain('repo-slug'); 635 | expect(result.stderr).not.toContain('workspace-slug'); 636 | }); 637 | 638 | it('should require title parameter', async () => { 639 | const result = await CliTestUtil.runCommand([ 640 | 'add-pr', 641 | '--workspace-slug', 642 | 'test-ws', 643 | '--repo-slug', 644 | 'test-repo', 645 | ]); 646 | expect(result.exitCode).not.toBe(0); 647 | expect(result.stderr).toContain('required option'); 648 | expect(result.stderr).toContain('title'); 649 | }); 650 | 651 | it('should require source-branch parameter', async () => { 652 | const result = await CliTestUtil.runCommand([ 653 | 'add-pr', 654 | '--workspace-slug', 655 | 'test-ws', 656 | '--repo-slug', 657 | 'test-repo', 658 | '--title', 659 | 'Test PR', 660 | ]); 661 | expect(result.exitCode).not.toBe(0); 662 | expect(result.stderr).toContain('required option'); 663 | expect(result.stderr).toContain('source-branch'); 664 | }); 665 | 666 | // Note: API call test has been removed to avoid creating PRs on real repos during tests 667 | }); 668 | }); 669 | ``` -------------------------------------------------------------------------------- /src/controllers/atlassian.pullrequests.controller.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { McpError, ErrorType } from '../utils/error.util.js'; 2 | import atlassianPullRequestsController from './atlassian.pullrequests.controller.js'; 3 | import { getAtlassianCredentials } from '../utils/transport.util.js'; 4 | import atlassianPullRequestsService from '../services/vendor.atlassian.pullrequests.service.js'; 5 | import atlassianWorkspacesService from '../services/vendor.atlassian.workspaces.service.js'; 6 | import atlassianRepositoriesService from '../services/vendor.atlassian.repositories.service.js'; 7 | 8 | describe('Atlassian Pull Requests Controller', () => { 9 | const skipIfNoCredentials = () => !getAtlassianCredentials(); 10 | 11 | async function getRepositoryInfo(): Promise<{ 12 | workspaceSlug: string; 13 | repoSlug: string; 14 | } | null> { 15 | // Skip if no credentials 16 | if (skipIfNoCredentials()) return null; 17 | 18 | try { 19 | // Try to get workspaces 20 | const workspacesResponse = await atlassianWorkspacesService.list({ 21 | pagelen: 1, 22 | }); 23 | 24 | if ( 25 | !workspacesResponse.values || 26 | workspacesResponse.values.length === 0 27 | ) { 28 | console.warn('No workspaces found for test account'); 29 | return null; 30 | } 31 | 32 | const workspace = workspacesResponse.values[0]; 33 | // The workspace slug might be in different locations depending on the response structure 34 | // Try to access it safely from possible locations 35 | const workspaceSlug = 36 | workspace.workspace?.slug || 37 | (workspace as any).slug || 38 | (workspace as any).name || 39 | null; 40 | 41 | if (!workspaceSlug) { 42 | console.warn( 43 | 'Could not determine workspace slug from response', 44 | ); 45 | return null; 46 | } 47 | 48 | // Get repositories for this workspace 49 | const reposResponse = await atlassianRepositoriesService.list({ 50 | workspace: workspaceSlug, 51 | pagelen: 1, 52 | }); 53 | 54 | if (!reposResponse.values || reposResponse.values.length === 0) { 55 | console.warn( 56 | `No repositories found in workspace ${workspaceSlug}`, 57 | ); 58 | return null; 59 | } 60 | 61 | const repo = reposResponse.values[0]; 62 | // The repo slug might be in different locations or named differently 63 | const repoSlug = 64 | (repo as any).slug || 65 | (repo as any).name || 66 | (repo.full_name?.split('/').pop() as string) || 67 | null; 68 | 69 | if (!repoSlug) { 70 | console.warn( 71 | 'Could not determine repository slug from response', 72 | ); 73 | return null; 74 | } 75 | 76 | return { 77 | workspaceSlug, 78 | repoSlug, 79 | }; 80 | } catch (error) { 81 | console.warn('Failed to get repository info for tests:', error); 82 | return null; 83 | } 84 | } 85 | 86 | async function getFirstPullRequestId(): Promise<{ 87 | workspaceSlug: string; 88 | repoSlug: string; 89 | prId: string; 90 | } | null> { 91 | const repoInfo = await getRepositoryInfo(); 92 | if (!repoInfo) return null; 93 | 94 | try { 95 | // List PRs 96 | const prsResponse = await atlassianPullRequestsService.list({ 97 | workspace: repoInfo.workspaceSlug, 98 | repo_slug: repoInfo.repoSlug, 99 | pagelen: 1, 100 | }); 101 | 102 | if (!prsResponse.values || prsResponse.values.length === 0) { 103 | console.warn( 104 | `No pull requests found in ${repoInfo.workspaceSlug}/${repoInfo.repoSlug}`, 105 | ); 106 | return null; 107 | } 108 | 109 | // Return the first PR's info 110 | return { 111 | workspaceSlug: repoInfo.workspaceSlug, 112 | repoSlug: repoInfo.repoSlug, 113 | prId: prsResponse.values[0].id.toString(), 114 | }; 115 | } catch (error) { 116 | console.warn('Failed to get pull request ID for tests:', error); 117 | return null; 118 | } 119 | } 120 | 121 | async function getPullRequestInfo(): Promise<{ 122 | workspaceSlug: string; 123 | repoSlug: string; 124 | prId: string; 125 | } | null> { 126 | // Attempt to get a first PR ID 127 | return await getFirstPullRequestId(); 128 | } 129 | 130 | // List tests 131 | describe('list', () => { 132 | it('should list pull requests for a repository', async () => { 133 | if (skipIfNoCredentials()) return; 134 | 135 | const repoInfo = await getRepositoryInfo(); 136 | if (!repoInfo) { 137 | console.warn('Skipping test: No repository info found.'); 138 | return; 139 | } 140 | 141 | const result = await atlassianPullRequestsController.list({ 142 | workspaceSlug: repoInfo.workspaceSlug, 143 | repoSlug: repoInfo.repoSlug, 144 | }); 145 | 146 | // Verify the response structure 147 | expect(result).toHaveProperty('content'); 148 | expect(typeof result.content).toBe('string'); 149 | 150 | // Check for expected format based on the formatter output 151 | expect(result.content).toMatch(/^# Pull Requests/); 152 | }, 30000); 153 | 154 | it('should handle query parameters correctly', async () => { 155 | if (skipIfNoCredentials()) return; 156 | 157 | const repoInfo = await getRepositoryInfo(); 158 | if (!repoInfo) { 159 | console.warn('Skipping test: No repository info found.'); 160 | return; 161 | } 162 | 163 | // A query term that should not match any PRs in any normal repo 164 | const uniqueQuery = 'z9x8c7vb6nm5'; 165 | 166 | const result = await atlassianPullRequestsController.list({ 167 | workspaceSlug: repoInfo.workspaceSlug, 168 | repoSlug: repoInfo.repoSlug, 169 | query: uniqueQuery, 170 | }); 171 | 172 | // Even with no matches, we expect a formatted empty list 173 | expect(result).toHaveProperty('content'); 174 | expect(result.content).toMatch(/^# Pull Requests/); 175 | expect(result.content).toContain('No pull requests found'); 176 | }, 30000); 177 | 178 | it('should handle pagination options (limit)', async () => { 179 | if (skipIfNoCredentials()) return; 180 | 181 | const repoInfo = await getRepositoryInfo(); 182 | if (!repoInfo) { 183 | console.warn('Skipping test: No repository info found.'); 184 | return; 185 | } 186 | 187 | // Fetch with a small limit 188 | const result = await atlassianPullRequestsController.list({ 189 | workspaceSlug: repoInfo.workspaceSlug, 190 | repoSlug: repoInfo.repoSlug, 191 | limit: 1, 192 | }); 193 | 194 | // Verify the response structure 195 | expect(result).toHaveProperty('content'); 196 | 197 | // Check if the result contains only one PR or none 198 | // First check if there are no PRs 199 | if (result.content.includes('No pull requests found')) { 200 | console.warn('No pull requests found for pagination test.'); 201 | return; 202 | } 203 | 204 | // Extract count 205 | const countMatch = result.content.match(/\*Showing (\d+) item/); 206 | if (countMatch && countMatch[1]) { 207 | const count = parseInt(countMatch[1], 10); 208 | expect(count).toBeLessThanOrEqual(1); 209 | } 210 | }, 30000); 211 | 212 | it('should handle authorization errors gracefully', async () => { 213 | if (skipIfNoCredentials()) return; 214 | 215 | const repoInfo = await getRepositoryInfo(); 216 | if (!repoInfo) { 217 | console.warn('Skipping test: No repository info found.'); 218 | return; 219 | } 220 | 221 | // Create mocked credentials error by using intentionally invalid credentials 222 | jest.spyOn( 223 | atlassianPullRequestsService, 224 | 'list', 225 | ).mockRejectedValueOnce( 226 | new McpError('Unauthorized', ErrorType.AUTH_INVALID, 401), 227 | ); 228 | 229 | // Expect to throw a standardized error 230 | await expect( 231 | atlassianPullRequestsController.list({ 232 | workspaceSlug: repoInfo.workspaceSlug, 233 | repoSlug: repoInfo.repoSlug, 234 | }), 235 | ).rejects.toThrow(McpError); 236 | 237 | // Verify it was called 238 | expect(atlassianPullRequestsService.list).toHaveBeenCalled(); 239 | 240 | // Cleanup mock 241 | jest.restoreAllMocks(); 242 | }, 30000); 243 | 244 | it('should require workspace and repository slugs', async () => { 245 | if (skipIfNoCredentials()) return; 246 | 247 | // Missing both slugs 248 | await expect( 249 | atlassianPullRequestsController.list({} as any), 250 | ).rejects.toThrow( 251 | /workspace slug and repository slug are required/i, 252 | ); 253 | 254 | // Missing repo slug 255 | await expect( 256 | atlassianPullRequestsController.list({ 257 | workspaceSlug: 'some-workspace', 258 | } as any), 259 | ).rejects.toThrow( 260 | /workspace slug and repository slug are required/i, 261 | ); 262 | 263 | // Missing workspace slug but should try to use default 264 | // This will fail with a different error if no default workspace can be found 265 | try { 266 | await atlassianPullRequestsController.list({ 267 | repoSlug: 'some-repo', 268 | } as any); 269 | } catch (error) { 270 | expect(error).toBeInstanceOf(Error); 271 | // Either it will fail with "could not determine default" or 272 | // it may succeed with a default workspace but fail with a different error 273 | } 274 | }, 10000); 275 | 276 | it('should apply default parameters correctly', async () => { 277 | if (skipIfNoCredentials()) return; 278 | 279 | const repoInfo = await getRepositoryInfo(); 280 | if (!repoInfo) { 281 | console.warn('Skipping test: No repository info found.'); 282 | return; 283 | } 284 | 285 | const serviceSpy = jest.spyOn(atlassianPullRequestsService, 'list'); 286 | 287 | try { 288 | await atlassianPullRequestsController.list({ 289 | workspaceSlug: repoInfo.workspaceSlug, 290 | repoSlug: repoInfo.repoSlug, 291 | // No limit provided, should use default 292 | }); 293 | 294 | // Check that the service was called with the default limit 295 | const calls = serviceSpy.mock.calls; 296 | expect(calls.length).toBeGreaterThan(0); 297 | const lastCall = calls[calls.length - 1]; 298 | expect(lastCall[0]).toHaveProperty('pagelen'); 299 | expect(lastCall[0].pagelen).toBeGreaterThan(0); // Should have some positive default value 300 | } finally { 301 | serviceSpy.mockRestore(); 302 | } 303 | }, 30000); 304 | 305 | it('should format pagination information correctly', async () => { 306 | if (skipIfNoCredentials()) return; 307 | 308 | const repoInfo = await getRepositoryInfo(); 309 | if (!repoInfo) { 310 | console.warn('Skipping test: No repository info found.'); 311 | return; 312 | } 313 | 314 | // Mock a response with pagination 315 | const mockResponse = { 316 | values: [ 317 | { 318 | id: 1, 319 | title: 'Test PR', 320 | state: 'OPEN', 321 | created_on: new Date().toISOString(), 322 | updated_on: new Date().toISOString(), 323 | author: { 324 | display_name: 'Test User', 325 | }, 326 | source: { 327 | branch: { name: 'feature-branch' }, 328 | }, 329 | destination: { 330 | branch: { name: 'main' }, 331 | }, 332 | links: {}, 333 | }, 334 | ], 335 | page: 1, 336 | size: 1, 337 | pagelen: 1, 338 | next: 'https://api.bitbucket.org/2.0/repositories/workspace/repo/pullrequests?page=2', 339 | }; 340 | 341 | jest.spyOn( 342 | atlassianPullRequestsService, 343 | 'list', 344 | ).mockResolvedValueOnce(mockResponse as any); 345 | 346 | const result = await atlassianPullRequestsController.list({ 347 | workspaceSlug: repoInfo.workspaceSlug, 348 | repoSlug: repoInfo.repoSlug, 349 | limit: 1, 350 | }); 351 | 352 | // Verify pagination info is included 353 | expect(result.content).toContain('*Showing 1 item'); 354 | expect(result.content).toContain('More results are available'); 355 | expect(result.content).toContain('Next cursor: `2`'); 356 | 357 | // Cleanup mock 358 | jest.restoreAllMocks(); 359 | }, 10000); 360 | }); 361 | 362 | // Get PR tests 363 | describe('get', () => { 364 | it('should retrieve detailed pull request information', async () => { 365 | if (skipIfNoCredentials()) return; 366 | 367 | const prInfo = await getPullRequestInfo(); 368 | if (!prInfo) { 369 | console.warn('Skipping test: No pull request info found.'); 370 | return; 371 | } 372 | 373 | const result = await atlassianPullRequestsController.get({ 374 | workspaceSlug: prInfo.workspaceSlug, 375 | repoSlug: prInfo.repoSlug, 376 | prId: prInfo.prId, 377 | includeFullDiff: false, 378 | includeComments: false, 379 | }); 380 | 381 | // Verify the response structure 382 | expect(result).toHaveProperty('content'); 383 | expect(typeof result.content).toBe('string'); 384 | 385 | // Check for expected format based on the formatter output 386 | expect(result.content).toMatch(/^# Pull Request/); 387 | expect(result.content).toContain('**ID**:'); 388 | expect(result.content).toContain('**Title**:'); 389 | expect(result.content).toContain('**State**:'); 390 | expect(result.content).toContain('**Author**:'); 391 | expect(result.content).toContain('**Created**:'); 392 | }, 30000); 393 | 394 | it('should handle errors for non-existent pull requests', async () => { 395 | if (skipIfNoCredentials()) return; 396 | 397 | const repoInfo = await getRepositoryInfo(); 398 | if (!repoInfo) { 399 | console.warn('Skipping test: No repository info found.'); 400 | return; 401 | } 402 | 403 | // Use a PR ID that is very unlikely to exist 404 | const nonExistentPrId = '999999999'; 405 | 406 | await expect( 407 | atlassianPullRequestsController.get({ 408 | workspaceSlug: repoInfo.workspaceSlug, 409 | repoSlug: repoInfo.repoSlug, 410 | prId: nonExistentPrId, 411 | includeFullDiff: false, 412 | includeComments: false, 413 | }), 414 | ).rejects.toThrow(McpError); 415 | 416 | // Test that the error has the right status code and type 417 | try { 418 | await atlassianPullRequestsController.get({ 419 | workspaceSlug: repoInfo.workspaceSlug, 420 | repoSlug: repoInfo.repoSlug, 421 | prId: nonExistentPrId, 422 | includeFullDiff: false, 423 | includeComments: false, 424 | }); 425 | } catch (error) { 426 | expect(error).toBeInstanceOf(McpError); 427 | expect((error as McpError).statusCode).toBe(404); 428 | expect((error as McpError).message).toContain('not found'); 429 | } 430 | }, 30000); 431 | 432 | it('should require all necessary parameters', async () => { 433 | if (skipIfNoCredentials()) return; 434 | 435 | // No parameters should throw 436 | await expect( 437 | atlassianPullRequestsController.get({} as any), 438 | ).rejects.toThrow(); 439 | 440 | // Missing PR ID should throw 441 | const repoInfo = await getRepositoryInfo(); 442 | if (repoInfo) { 443 | await expect( 444 | atlassianPullRequestsController.get({ 445 | workspaceSlug: repoInfo.workspaceSlug, 446 | repoSlug: repoInfo.repoSlug, 447 | // prId: missing 448 | } as any), 449 | ).rejects.toThrow(); 450 | } 451 | }, 10000); 452 | }); 453 | 454 | // List comments tests 455 | describe('listComments', () => { 456 | it('should return formatted pull request comments in Markdown', async () => { 457 | if (skipIfNoCredentials()) return; 458 | 459 | const prInfo = await getFirstPullRequestId(); 460 | if (!prInfo) { 461 | console.warn('Skipping test: No pull request ID found.'); 462 | return; 463 | } 464 | 465 | const result = await atlassianPullRequestsController.listComments({ 466 | workspaceSlug: prInfo.workspaceSlug, 467 | repoSlug: prInfo.repoSlug, 468 | prId: prInfo.prId, 469 | }); 470 | 471 | // Verify the response structure 472 | expect(result).toHaveProperty('content'); 473 | expect(typeof result.content).toBe('string'); 474 | 475 | // Basic Markdown content checks 476 | if (result.content !== 'No comments found on this pull request.') { 477 | expect(result.content).toMatch(/^# Comments on Pull Request/m); 478 | expect(result.content).toContain('**Author**:'); 479 | expect(result.content).toContain('**Updated**:'); 480 | expect(result.content).toMatch( 481 | /---\s*\*Showing \d+ items?\.\*/, 482 | ); 483 | } 484 | }, 30000); 485 | 486 | it('should handle pagination options (limit/cursor)', async () => { 487 | if (skipIfNoCredentials()) return; 488 | 489 | const prInfo = await getFirstPullRequestId(); 490 | if (!prInfo) { 491 | console.warn('Skipping test: No pull request ID found.'); 492 | return; 493 | } 494 | 495 | // Fetch first page with limit 1 496 | const result1 = await atlassianPullRequestsController.listComments({ 497 | workspaceSlug: prInfo.workspaceSlug, 498 | repoSlug: prInfo.repoSlug, 499 | prId: prInfo.prId, 500 | limit: 1, 501 | }); 502 | 503 | // Extract pagination info from content 504 | const countMatch1 = result1.content.match( 505 | /\*Showing (\d+) items?\.\*/, 506 | ); 507 | const count1 = countMatch1 ? parseInt(countMatch1[1], 10) : 0; 508 | expect(count1).toBeLessThanOrEqual(1); 509 | 510 | // Extract cursor from content 511 | const cursorMatch1 = result1.content.match( 512 | /\*Next cursor: `([^`]+)`\*/, 513 | ); 514 | const nextCursor = cursorMatch1 ? cursorMatch1[1] : null; 515 | 516 | // Check if pagination indicates more results 517 | const hasMoreResults = result1.content.includes( 518 | 'More results are available.', 519 | ); 520 | 521 | // If there's a next page, fetch it 522 | if (hasMoreResults && nextCursor) { 523 | const result2 = 524 | await atlassianPullRequestsController.listComments({ 525 | workspaceSlug: prInfo.workspaceSlug, 526 | repoSlug: prInfo.repoSlug, 527 | prId: prInfo.prId, 528 | limit: 1, 529 | cursor: nextCursor, 530 | }); 531 | 532 | // Extract count from result2 533 | const countMatch2 = result2.content.match( 534 | /\*Showing (\d+) items?\.\*/, 535 | ); 536 | const count2 = countMatch2 ? parseInt(countMatch2[1], 10) : 0; 537 | expect(count2).toBeLessThanOrEqual(1); 538 | 539 | // Ensure content is different (or handle case where only 1 comment exists) 540 | if ( 541 | result1.content !== 542 | 'No comments found on this pull request.' && 543 | result2.content !== 544 | 'No comments found on this pull request.' && 545 | count1 > 0 && 546 | count2 > 0 547 | ) { 548 | // Only compare if we actually have multiple comments 549 | expect(result1.content).not.toEqual(result2.content); 550 | } 551 | } else { 552 | console.warn( 553 | 'Skipping cursor part of pagination test: Only one page of comments found or no comments available.', 554 | ); 555 | } 556 | }, 30000); 557 | 558 | it('should handle empty result scenario', async () => { 559 | if (skipIfNoCredentials()) return; 560 | 561 | // First get a PR without comments, or use a non-existent but valid format PR ID 562 | const repoInfo = await getRepositoryInfo(); 563 | if (!repoInfo) { 564 | console.warn('Skipping test: No repository info found.'); 565 | return; 566 | } 567 | 568 | // Try to find a PR without comments 569 | try { 570 | // First check if we can access the repository 571 | const repoResult = await atlassianPullRequestsController.list({ 572 | workspaceSlug: repoInfo.workspaceSlug, 573 | repoSlug: repoInfo.repoSlug, 574 | limit: 1, 575 | }); 576 | 577 | // Extract PR ID from the content if we have any PRs 578 | const prIdMatch = 579 | repoResult.content.match(/\*\*ID\*\*:\s+(\d+)/); 580 | if (!prIdMatch || !prIdMatch[1]) { 581 | console.warn( 582 | 'Skipping empty result test: No pull requests available.', 583 | ); 584 | return; 585 | } 586 | 587 | const prId = prIdMatch[1]; 588 | 589 | // Get comments for this PR 590 | const commentsResult = 591 | await atlassianPullRequestsController.listComments({ 592 | workspaceSlug: repoInfo.workspaceSlug, 593 | repoSlug: repoInfo.repoSlug, 594 | prId, 595 | }); 596 | 597 | // Check if there are no comments 598 | if ( 599 | commentsResult.content === 600 | 'No comments found on this pull request.' 601 | ) { 602 | // Verify the expected message for empty results 603 | expect(commentsResult.content).toBe( 604 | 'No comments found on this pull request.', 605 | ); 606 | } else { 607 | console.warn( 608 | 'Skipping empty result test: All PRs have comments.', 609 | ); 610 | } 611 | } catch (error) { 612 | console.warn('Error during empty result test:', error); 613 | } 614 | }, 30000); 615 | 616 | it('should throw an McpError for a non-existent pull request ID', async () => { 617 | if (skipIfNoCredentials()) return; 618 | 619 | const repoInfo = await getRepositoryInfo(); 620 | if (!repoInfo) { 621 | console.warn('Skipping test: No repository info found.'); 622 | return; 623 | } 624 | 625 | const nonExistentId = '999999999'; // Very high number unlikely to exist 626 | 627 | // Expect the controller call to reject with an McpError 628 | await expect( 629 | atlassianPullRequestsController.listComments({ 630 | workspaceSlug: repoInfo.workspaceSlug, 631 | repoSlug: repoInfo.repoSlug, 632 | prId: nonExistentId, 633 | }), 634 | ).rejects.toThrow(McpError); 635 | 636 | // Check the status code via the error handler's behavior 637 | try { 638 | await atlassianPullRequestsController.listComments({ 639 | workspaceSlug: repoInfo.workspaceSlug, 640 | repoSlug: repoInfo.repoSlug, 641 | prId: nonExistentId, 642 | }); 643 | } catch (e) { 644 | expect(e).toBeInstanceOf(McpError); 645 | expect((e as McpError).statusCode).toBe(404); // Expecting Not Found 646 | expect((e as McpError).message).toContain('not found'); 647 | } 648 | }, 30000); 649 | 650 | it('should require all necessary parameters', async () => { 651 | if (skipIfNoCredentials()) return; 652 | 653 | // No parameters 654 | await expect( 655 | atlassianPullRequestsController.listComments({} as any), 656 | ).rejects.toThrow(); 657 | 658 | // Missing PR ID 659 | const repoInfo = await getRepositoryInfo(); 660 | if (repoInfo) { 661 | await expect( 662 | atlassianPullRequestsController.listComments({ 663 | workspaceSlug: repoInfo.workspaceSlug, 664 | repoSlug: repoInfo.repoSlug, 665 | // prId: missing 666 | } as any), 667 | ).rejects.toThrow(); 668 | } 669 | }, 10000); 670 | }); 671 | 672 | // Note: addComment test suite has been removed to avoid creating comments on real PRs during tests 673 | }); 674 | ``` -------------------------------------------------------------------------------- /src/services/vendor.atlassian.pullrequests.service.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { createAuthMissingError } from '../utils/error.util.js'; 2 | import { Logger } from '../utils/logger.util.js'; 3 | import { 4 | fetchAtlassian, 5 | getAtlassianCredentials, 6 | } from '../utils/transport.util.js'; 7 | import { 8 | PullRequestDetailed, 9 | PullRequestsResponse, 10 | ListPullRequestsParams, 11 | GetPullRequestParams, 12 | GetPullRequestCommentsParams, 13 | PullRequestCommentsResponse, 14 | PullRequestComment, 15 | CreatePullRequestCommentParams, 16 | CreatePullRequestParams, 17 | UpdatePullRequestParams, 18 | ApprovePullRequestParams, 19 | RejectPullRequestParams, 20 | PullRequestParticipant, 21 | DiffstatResponse, 22 | } from './vendor.atlassian.pullrequests.types.js'; 23 | 24 | /** 25 | * Base API path for Bitbucket REST API v2 26 | * @see https://developer.atlassian.com/cloud/bitbucket/rest/api-group-pullrequests/ 27 | * @constant {string} 28 | */ 29 | const API_PATH = '/2.0'; 30 | 31 | /** 32 | * @namespace VendorAtlassianPullRequestsService 33 | * @description Service for interacting with Bitbucket Pull Requests API. 34 | * Provides methods for listing pull requests and retrieving pull request details. 35 | * All methods require valid Atlassian credentials configured in the environment. 36 | */ 37 | 38 | // Create a contextualized logger for this file 39 | const serviceLogger = Logger.forContext( 40 | 'services/vendor.atlassian.pullrequests.service.ts', 41 | ); 42 | 43 | // Log service initialization 44 | serviceLogger.debug('Bitbucket pull requests service initialized'); 45 | 46 | /** 47 | * List pull requests for a repository 48 | * @param {ListPullRequestsParams} params - Parameters for the request 49 | * @param {string} params.workspace - The workspace slug or UUID 50 | * @param {string} params.repo_slug - The repository slug or UUID 51 | * @param {PullRequestState | PullRequestState[]} [params.state] - Filter by pull request state (default: 'OPEN') 52 | * @param {string} [params.q] - Query string to filter pull requests 53 | * @param {string} [params.sort] - Property to sort by (e.g., 'created_on', '-updated_on') 54 | * @param {number} [params.page] - Page number for pagination 55 | * @param {number} [params.pagelen] - Number of items per page 56 | * @returns {Promise<PullRequestsResponse>} Response containing pull requests 57 | * @example 58 | * ```typescript 59 | * // List open pull requests in a repository, sorted by creation date 60 | * const response = await list({ 61 | * workspace: 'myworkspace', 62 | * repo_slug: 'myrepo', 63 | * sort: '-created_on', 64 | * pagelen: 25 65 | * }); 66 | * ``` 67 | */ 68 | async function list( 69 | params: ListPullRequestsParams, 70 | ): Promise<PullRequestsResponse> { 71 | const methodLogger = Logger.forContext( 72 | 'services/vendor.atlassian.pullrequests.service.ts', 73 | 'list', 74 | ); 75 | methodLogger.debug('Listing Bitbucket pull requests with params:', params); 76 | 77 | if (!params.workspace || !params.repo_slug) { 78 | throw new Error('Both workspace and repo_slug parameters are required'); 79 | } 80 | 81 | const credentials = getAtlassianCredentials(); 82 | if (!credentials) { 83 | throw createAuthMissingError( 84 | 'Atlassian credentials are required for this operation', 85 | ); 86 | } 87 | 88 | // Construct query parameters 89 | const queryParams = new URLSearchParams(); 90 | 91 | // Add state parameter(s) - default to OPEN if not specified 92 | if (params.state) { 93 | if (Array.isArray(params.state)) { 94 | // For multiple states, repeat the parameter 95 | params.state.forEach((state) => { 96 | queryParams.append('state', state); 97 | }); 98 | } else { 99 | queryParams.set('state', params.state); 100 | } 101 | } 102 | 103 | // Add optional query parameters 104 | if (params.q) { 105 | queryParams.set('q', params.q); 106 | } 107 | if (params.sort) { 108 | queryParams.set('sort', params.sort); 109 | } 110 | if (params.pagelen) { 111 | queryParams.set('pagelen', params.pagelen.toString()); 112 | } 113 | if (params.page) { 114 | queryParams.set('page', params.page.toString()); 115 | } 116 | 117 | const queryString = queryParams.toString() 118 | ? `?${queryParams.toString()}` 119 | : ''; 120 | const path = `${API_PATH}/repositories/${params.workspace}/${params.repo_slug}/pullrequests${queryString}`; 121 | 122 | methodLogger.debug(`Sending request to: ${path}`); 123 | return fetchAtlassian<PullRequestsResponse>(credentials, path); 124 | } 125 | 126 | /** 127 | * Get detailed information about a specific Bitbucket pull request 128 | * 129 | * Retrieves comprehensive details about a single pull request. 130 | * 131 | * @async 132 | * @memberof VendorAtlassianPullRequestsService 133 | * @param {GetPullRequestParams} params - Parameters for the request 134 | * @param {string} params.workspace - The workspace slug or UUID 135 | * @param {string} params.repo_slug - The repository slug or UUID 136 | * @param {number} params.pull_request_id - The ID of the pull request 137 | * @returns {Promise<PullRequestDetailed>} Promise containing the detailed pull request information 138 | * @throws {Error} If Atlassian credentials are missing or API request fails 139 | * @example 140 | * // Get pull request details 141 | * const pullRequest = await get({ 142 | * workspace: 'my-workspace', 143 | * repo_slug: 'my-repo', 144 | * pull_request_id: 123 145 | * }); 146 | */ 147 | async function get(params: GetPullRequestParams): Promise<PullRequestDetailed> { 148 | const methodLogger = Logger.forContext( 149 | 'services/vendor.atlassian.pullrequests.service.ts', 150 | 'get', 151 | ); 152 | methodLogger.debug( 153 | `Getting Bitbucket pull request: ${params.workspace}/${params.repo_slug}/${params.pull_request_id}`, 154 | ); 155 | 156 | // Validate pull_request_id is a positive integer 157 | const prId = Number(params.pull_request_id); 158 | if (isNaN(prId) || prId <= 0 || !Number.isInteger(prId)) { 159 | throw new Error( 160 | `Invalid pull request ID: ${params.pull_request_id}. Pull request ID must be a positive integer.`, 161 | ); 162 | } 163 | 164 | if (!params.workspace || !params.repo_slug || !params.pull_request_id) { 165 | throw new Error( 166 | 'workspace, repo_slug, and pull_request_id parameters are all required', 167 | ); 168 | } 169 | 170 | const credentials = getAtlassianCredentials(); 171 | if (!credentials) { 172 | throw createAuthMissingError( 173 | 'Atlassian credentials are required for this operation', 174 | ); 175 | } 176 | 177 | const path = `${API_PATH}/repositories/${params.workspace}/${params.repo_slug}/pullrequests/${params.pull_request_id}`; 178 | 179 | methodLogger.debug(`Sending request to: ${path}`); 180 | return fetchAtlassian<PullRequestDetailed>(credentials, path); 181 | } 182 | 183 | /** 184 | * Get comments for a specific Bitbucket pull request 185 | * 186 | * Retrieves all comments on a specific pull request, including general comments and 187 | * inline code review comments. Supports pagination. 188 | * 189 | * @async 190 | * @memberof VendorAtlassianPullRequestsService 191 | * @param {GetPullRequestCommentsParams} params - Parameters for the request 192 | * @param {string} params.workspace - The workspace slug or UUID 193 | * @param {string} params.repo_slug - The repository slug or UUID 194 | * @param {number} params.pull_request_id - The ID of the pull request 195 | * @param {number} [params.page] - Page number for pagination 196 | * @param {number} [params.pagelen] - Number of items per page 197 | * @returns {Promise<PullRequestCommentsResponse>} Promise containing the pull request comments 198 | * @throws {Error} If Atlassian credentials are missing or API request fails 199 | * @example 200 | * // Get comments for a pull request 201 | * const comments = await getComments({ 202 | * workspace: 'my-workspace', 203 | * repo_slug: 'my-repo', 204 | * pull_request_id: 123, 205 | * pagelen: 25 206 | * }); 207 | */ 208 | async function getComments( 209 | params: GetPullRequestCommentsParams, 210 | ): Promise<PullRequestCommentsResponse> { 211 | const methodLogger = Logger.forContext( 212 | 'services/vendor.atlassian.pullrequests.service.ts', 213 | 'getComments', 214 | ); 215 | methodLogger.debug( 216 | `Getting comments for Bitbucket pull request: ${params.workspace}/${params.repo_slug}/${params.pull_request_id}`, 217 | ); 218 | 219 | // Validate pull_request_id is a positive integer 220 | const prId = Number(params.pull_request_id); 221 | if (isNaN(prId) || prId <= 0 || !Number.isInteger(prId)) { 222 | throw new Error( 223 | `Invalid pull request ID: ${params.pull_request_id}. Pull request ID must be a positive integer.`, 224 | ); 225 | } 226 | 227 | if (!params.workspace || !params.repo_slug || !params.pull_request_id) { 228 | throw new Error( 229 | 'workspace, repo_slug, and pull_request_id parameters are all required', 230 | ); 231 | } 232 | 233 | const credentials = getAtlassianCredentials(); 234 | if (!credentials) { 235 | throw createAuthMissingError( 236 | 'Atlassian credentials are required for this operation', 237 | ); 238 | } 239 | 240 | // Build query parameters 241 | const queryParams = new URLSearchParams(); 242 | 243 | // Add pagination parameters if provided 244 | if (params.pagelen) { 245 | queryParams.set('pagelen', params.pagelen.toString()); 246 | } 247 | if (params.page) { 248 | queryParams.set('page', params.page.toString()); 249 | } 250 | // Add sort parameter if provided 251 | if (params.sort) { 252 | queryParams.set('sort', params.sort); 253 | } 254 | 255 | const queryString = queryParams.toString() 256 | ? `?${queryParams.toString()}` 257 | : ''; 258 | 259 | const path = `${API_PATH}/repositories/${params.workspace}/${params.repo_slug}/pullrequests/${params.pull_request_id}/comments${queryString}`; 260 | 261 | methodLogger.debug(`Sending request to: ${path}`); 262 | return fetchAtlassian<PullRequestCommentsResponse>(credentials, path); 263 | } 264 | 265 | /** 266 | * Add a comment to a specific Bitbucket pull request 267 | * 268 | * Creates a new comment on a pull request, either as a general comment or 269 | * as an inline code comment attached to a specific file and line. 270 | * 271 | * @async 272 | * @memberof VendorAtlassianPullRequestsService 273 | * @param {CreatePullRequestCommentParams} params - Parameters for the request 274 | * @param {string} params.workspace - The workspace slug or UUID 275 | * @param {string} params.repo_slug - The repository slug or UUID 276 | * @param {number} params.pull_request_id - The ID of the pull request 277 | * @param {Object} params.content - The content of the comment 278 | * @param {string} params.content.raw - The raw text of the comment 279 | * @param {Object} [params.inline] - Optional inline comment location 280 | * @param {string} params.inline.path - The file path for the inline comment 281 | * @param {number} params.inline.to - The line number in the file 282 | * @returns {Promise<PullRequestComment>} Promise containing the created comment 283 | * @throws {Error} If Atlassian credentials are missing or API request fails 284 | * @example 285 | * // Add a general comment to a pull request 286 | * const comment = await addComment({ 287 | * workspace: 'my-workspace', 288 | * repo_slug: 'my-repo', 289 | * pull_request_id: 123, 290 | * content: { raw: "This looks good to me!" } 291 | * }); 292 | * 293 | * // Add an inline code comment 294 | * const comment = await addComment({ 295 | * workspace: 'my-workspace', 296 | * repo_slug: 'my-repo', 297 | * pull_request_id: 123, 298 | * content: { raw: "Consider using a constant here instead." }, 299 | * inline: { path: "src/main.js", to: 42 } 300 | * }); 301 | */ 302 | async function createComment( 303 | params: CreatePullRequestCommentParams, 304 | ): Promise<PullRequestComment> { 305 | const methodLogger = Logger.forContext( 306 | 'services/vendor.atlassian.pullrequests.service.ts', 307 | 'createComment', 308 | ); 309 | methodLogger.debug( 310 | `Creating comment on Bitbucket pull request: ${params.workspace}/${params.repo_slug}/${params.pull_request_id}`, 311 | ); 312 | 313 | if (!params.workspace || !params.repo_slug || !params.pull_request_id) { 314 | throw new Error( 315 | 'workspace, repo_slug, and pull_request_id parameters are all required', 316 | ); 317 | } 318 | 319 | if (!params.content || !params.content.raw) { 320 | throw new Error('Comment content is required'); 321 | } 322 | 323 | const credentials = getAtlassianCredentials(); 324 | if (!credentials) { 325 | throw createAuthMissingError( 326 | 'Atlassian credentials are required for this operation', 327 | ); 328 | } 329 | 330 | const path = `${API_PATH}/repositories/${params.workspace}/${params.repo_slug}/pullrequests/${params.pull_request_id}/comments`; 331 | 332 | methodLogger.debug(`Sending POST request to: ${path}`); 333 | return fetchAtlassian<PullRequestComment>(credentials, path, { 334 | method: 'POST', 335 | body: { 336 | content: params.content, 337 | inline: params.inline, 338 | parent: params.parent, 339 | }, 340 | }); 341 | } 342 | 343 | /** 344 | * Create a new pull request 345 | * @param {CreatePullRequestParams} params - Parameters for the request 346 | * @param {string} params.workspace - The workspace slug or UUID 347 | * @param {string} params.repo_slug - The repository slug or UUID 348 | * @param {string} params.title - Title of the pull request 349 | * @param {string} params.source.branch.name - Source branch name 350 | * @param {string} params.destination.branch.name - Destination branch name (defaults to main/master) 351 | * @param {string} [params.description] - Optional description for the pull request 352 | * @param {boolean} [params.close_source_branch] - Whether to close the source branch after merge (default: false) 353 | * @returns {Promise<PullRequestDetailed>} Detailed information about the created pull request 354 | * @example 355 | * ```typescript 356 | * // Create a new pull request 357 | * const pullRequest = await create({ 358 | * workspace: 'myworkspace', 359 | * repo_slug: 'myrepo', 360 | * title: 'Add new feature', 361 | * source: { 362 | * branch: { 363 | * name: 'feature/new-feature' 364 | * } 365 | * }, 366 | * destination: { 367 | * branch: { 368 | * name: 'main' 369 | * } 370 | * }, 371 | * description: 'This PR adds a new feature...', 372 | * close_source_branch: true 373 | * }); 374 | * ``` 375 | */ 376 | async function create( 377 | params: CreatePullRequestParams, 378 | ): Promise<PullRequestDetailed> { 379 | const methodLogger = Logger.forContext( 380 | 'services/vendor.atlassian.pullrequests.service.ts', 381 | 'create', 382 | ); 383 | methodLogger.debug( 384 | 'Creating new Bitbucket pull request with params:', 385 | params, 386 | ); 387 | 388 | if (!params.workspace || !params.repo_slug) { 389 | throw new Error('Both workspace and repo_slug parameters are required'); 390 | } 391 | 392 | if (!params.title) { 393 | throw new Error('Pull request title is required'); 394 | } 395 | 396 | if (!params.source || !params.source.branch || !params.source.branch.name) { 397 | throw new Error('Source branch name is required'); 398 | } 399 | 400 | // Destination branch is required but may have a default 401 | if ( 402 | !params.destination || 403 | !params.destination.branch || 404 | !params.destination.branch.name 405 | ) { 406 | throw new Error('Destination branch name is required'); 407 | } 408 | 409 | const credentials = getAtlassianCredentials(); 410 | if (!credentials) { 411 | throw createAuthMissingError( 412 | 'Atlassian credentials are required for this operation', 413 | ); 414 | } 415 | 416 | const path = `${API_PATH}/repositories/${params.workspace}/${params.repo_slug}/pullrequests`; 417 | 418 | // Construct request body with only the fields needed by the API 419 | const requestBody = { 420 | title: params.title, 421 | source: { 422 | branch: { 423 | name: params.source.branch.name, 424 | }, 425 | }, 426 | destination: { 427 | branch: { 428 | name: params.destination.branch.name, 429 | }, 430 | }, 431 | description: params.description || '', 432 | close_source_branch: !!params.close_source_branch, 433 | }; 434 | 435 | methodLogger.debug(`Sending POST request to: ${path}`); 436 | return fetchAtlassian<PullRequestDetailed>(credentials, path, { 437 | method: 'POST', 438 | body: requestBody, 439 | }); 440 | } 441 | 442 | /** 443 | * Get raw diff content for a specific Bitbucket pull request 444 | * 445 | * Retrieves the raw diff content showing actual code changes in the pull request. 446 | * The diff is returned in a standard unified diff format. 447 | * 448 | * @async 449 | * @memberof VendorAtlassianPullRequestsService 450 | * @param {GetPullRequestParams} params - Parameters for the request 451 | * @param {string} params.workspace - The workspace slug or UUID 452 | * @param {string} params.repo_slug - The repository slug or UUID 453 | * @param {number} params.pull_request_id - The ID of the pull request 454 | * @returns {Promise<string>} Promise containing the raw diff content 455 | * @throws {Error} If Atlassian credentials are missing or API request fails 456 | * @example 457 | * // Get raw diff content for a pull request 458 | * const diffContent = await getRawDiff({ 459 | * workspace: 'my-workspace', 460 | * repo_slug: 'my-repo', 461 | * pull_request_id: 123 462 | * }); 463 | */ 464 | async function getRawDiff(params: GetPullRequestParams): Promise<string> { 465 | const methodLogger = Logger.forContext( 466 | 'services/vendor.atlassian.pullrequests.service.ts', 467 | 'getRawDiff', 468 | ); 469 | methodLogger.debug( 470 | `Getting raw diff for Bitbucket pull request: ${params.workspace}/${params.repo_slug}/${params.pull_request_id}`, 471 | ); 472 | 473 | if (!params.workspace || !params.repo_slug || !params.pull_request_id) { 474 | throw new Error( 475 | 'workspace, repo_slug, and pull_request_id parameters are all required', 476 | ); 477 | } 478 | 479 | const credentials = getAtlassianCredentials(); 480 | if (!credentials) { 481 | throw createAuthMissingError( 482 | 'Atlassian credentials are required for this operation', 483 | ); 484 | } 485 | 486 | // Use the diff endpoint directly 487 | const path = `${API_PATH}/repositories/${params.workspace}/${params.repo_slug}/pullrequests/${params.pull_request_id}/diff`; 488 | 489 | methodLogger.debug(`Sending request to: ${path}`); 490 | 491 | // Override the Accept header to get raw diff content instead of JSON 492 | // The transport utility handles the text response automatically 493 | return fetchAtlassian<string>(credentials, path, { 494 | headers: { 495 | Accept: 'text/plain', 496 | }, 497 | }); 498 | } 499 | 500 | /** 501 | * Get the diffstat information for a pull request 502 | * 503 | * Returns summary statistics about the changes in a pull request, 504 | * including files changed, insertions, and deletions. 505 | * 506 | * @async 507 | * @memberof VendorAtlassianPullRequestsService 508 | * @param {GetPullRequestParams} params - Parameters for the request 509 | * @returns {Promise<DiffstatResponse>} Promise containing the diffstat response 510 | */ 511 | async function getDiffstat( 512 | params: GetPullRequestParams, 513 | ): Promise<DiffstatResponse> { 514 | const methodLogger = Logger.forContext( 515 | 'services/vendor.atlassian.pullrequests.service.ts', 516 | 'getDiffstat', 517 | ); 518 | methodLogger.debug( 519 | `Getting diffstat for Bitbucket pull request: ${params.workspace}/${params.repo_slug}/${params.pull_request_id}`, 520 | ); 521 | 522 | if (!params.workspace || !params.repo_slug || !params.pull_request_id) { 523 | throw new Error( 524 | 'workspace, repo_slug, and pull_request_id parameters are all required', 525 | ); 526 | } 527 | 528 | const credentials = getAtlassianCredentials(); 529 | if (!credentials) { 530 | throw createAuthMissingError( 531 | 'Atlassian credentials are required for this operation', 532 | ); 533 | } 534 | 535 | const path = `${API_PATH}/repositories/${params.workspace}/${params.repo_slug}/pullrequests/${params.pull_request_id}/diffstat`; 536 | 537 | methodLogger.debug(`Sending request to: ${path}`); 538 | return fetchAtlassian<DiffstatResponse>(credentials, path); 539 | } 540 | 541 | /** 542 | * Fetches the raw diff content from a specific Bitbucket API URL. 543 | * Used primarily for fetching file-specific diffs linked from inline comments. 544 | * 545 | * @async 546 | * @param {string} url - The exact Bitbucket API URL to fetch the diff from. 547 | * @returns {Promise<string>} Promise containing the raw diff content as a string. 548 | * @throws {Error} If Atlassian credentials are missing or the API request fails. 549 | */ 550 | async function getDiffForUrl(url: string): Promise<string> { 551 | const methodLogger = Logger.forContext( 552 | 'services/vendor.atlassian.pullrequests.service.ts', 553 | 'getDiffForUrl', 554 | ); 555 | methodLogger.debug(`Getting raw diff from URL: ${url}`); 556 | 557 | if (!url) { 558 | throw new Error('URL parameter is required'); 559 | } 560 | 561 | const credentials = getAtlassianCredentials(); 562 | if (!credentials) { 563 | throw createAuthMissingError( 564 | 'Atlassian credentials are required for this operation', 565 | ); 566 | } 567 | 568 | // Parse the provided URL to extract the path and query string 569 | let urlPath = ''; 570 | try { 571 | const parsedUrl = new URL(url); 572 | urlPath = parsedUrl.pathname + parsedUrl.search; 573 | methodLogger.debug(`Parsed path and query from URL: ${urlPath}`); 574 | } catch (parseError) { 575 | methodLogger.error(`Failed to parse URL: ${url}`, parseError); 576 | throw new Error(`Invalid URL provided for diff fetching: ${url}`); 577 | } 578 | 579 | // Pass only the path part to fetchAtlassian 580 | // Ensure the Accept header requests plain text for the raw diff. 581 | return fetchAtlassian<string>(credentials, urlPath, { 582 | headers: { 583 | Accept: 'text/plain', 584 | }, 585 | }); 586 | } 587 | 588 | /** 589 | * Update an existing pull request 590 | * @param {UpdatePullRequestParams} params - Parameters for the request 591 | * @param {string} params.workspace - The workspace slug or UUID 592 | * @param {string} params.repo_slug - The repository slug or UUID 593 | * @param {number} params.pull_request_id - The ID of the pull request to update 594 | * @param {string} [params.title] - Updated title of the pull request 595 | * @param {string} [params.description] - Updated description for the pull request 596 | * @returns {Promise<PullRequestDetailed>} Updated pull request information 597 | * @example 598 | * ```typescript 599 | * // Update a pull request's title and description 600 | * const updatedPR = await update({ 601 | * workspace: 'myworkspace', 602 | * repo_slug: 'myrepo', 603 | * pull_request_id: 123, 604 | * title: 'Updated Feature Implementation', 605 | * description: 'This PR now includes additional improvements...' 606 | * }); 607 | * ``` 608 | */ 609 | async function update( 610 | params: UpdatePullRequestParams, 611 | ): Promise<PullRequestDetailed> { 612 | const methodLogger = Logger.forContext( 613 | 'services/vendor.atlassian.pullrequests.service.ts', 614 | 'update', 615 | ); 616 | methodLogger.debug('Updating Bitbucket pull request with params:', params); 617 | 618 | if (!params.workspace || !params.repo_slug || !params.pull_request_id) { 619 | throw new Error( 620 | 'workspace, repo_slug, and pull_request_id parameters are all required', 621 | ); 622 | } 623 | 624 | // At least one field to update must be provided 625 | if (!params.title && !params.description) { 626 | throw new Error( 627 | 'At least one field to update (title or description) must be provided', 628 | ); 629 | } 630 | 631 | const credentials = getAtlassianCredentials(); 632 | if (!credentials) { 633 | throw createAuthMissingError( 634 | 'Atlassian credentials are required for this operation', 635 | ); 636 | } 637 | 638 | const path = `${API_PATH}/repositories/${params.workspace}/${params.repo_slug}/pullrequests/${params.pull_request_id}`; 639 | 640 | // Construct request body with only the fields to update 641 | const requestBody: Record<string, unknown> = {}; 642 | if (params.title !== undefined) { 643 | requestBody.title = params.title; 644 | } 645 | if (params.description !== undefined) { 646 | requestBody.description = params.description; 647 | } 648 | 649 | methodLogger.debug(`Sending PUT request to: ${path}`); 650 | return fetchAtlassian<PullRequestDetailed>(credentials, path, { 651 | method: 'PUT', 652 | body: requestBody, 653 | }); 654 | } 655 | 656 | /** 657 | * Approve a pull request 658 | * @param {ApprovePullRequestParams} params - Parameters for the request 659 | * @param {string} params.workspace - The workspace slug or UUID 660 | * @param {string} params.repo_slug - The repository slug or UUID 661 | * @param {number} params.pull_request_id - The ID of the pull request to approve 662 | * @returns {Promise<PullRequestParticipant>} Participant information showing approval status 663 | * @example 664 | * ```typescript 665 | * // Approve a pull request 666 | * const approval = await approve({ 667 | * workspace: 'myworkspace', 668 | * repo_slug: 'myrepo', 669 | * pull_request_id: 123 670 | * }); 671 | * ``` 672 | */ 673 | async function approve( 674 | params: ApprovePullRequestParams, 675 | ): Promise<PullRequestParticipant> { 676 | const methodLogger = Logger.forContext( 677 | 'services/vendor.atlassian.pullrequests.service.ts', 678 | 'approve', 679 | ); 680 | methodLogger.debug( 681 | `Approving Bitbucket pull request: ${params.workspace}/${params.repo_slug}/${params.pull_request_id}`, 682 | ); 683 | 684 | if (!params.workspace || !params.repo_slug || !params.pull_request_id) { 685 | throw new Error( 686 | 'workspace, repo_slug, and pull_request_id parameters are all required', 687 | ); 688 | } 689 | 690 | const credentials = getAtlassianCredentials(); 691 | if (!credentials) { 692 | throw createAuthMissingError( 693 | 'Atlassian credentials are required for this operation', 694 | ); 695 | } 696 | 697 | const path = `${API_PATH}/repositories/${params.workspace}/${params.repo_slug}/pullrequests/${params.pull_request_id}/approve`; 698 | 699 | methodLogger.debug(`Sending POST request to: ${path}`); 700 | return fetchAtlassian<PullRequestParticipant>(credentials, path, { 701 | method: 'POST', 702 | }); 703 | } 704 | 705 | /** 706 | * Request changes on a pull request (reject) 707 | * @param {RejectPullRequestParams} params - Parameters for the request 708 | * @param {string} params.workspace - The workspace slug or UUID 709 | * @param {string} params.repo_slug - The repository slug or UUID 710 | * @param {number} params.pull_request_id - The ID of the pull request to request changes on 711 | * @returns {Promise<PullRequestParticipant>} Participant information showing rejection status 712 | * @example 713 | * ```typescript 714 | * // Request changes on a pull request 715 | * const rejection = await reject({ 716 | * workspace: 'myworkspace', 717 | * repo_slug: 'myrepo', 718 | * pull_request_id: 123 719 | * }); 720 | * ``` 721 | */ 722 | async function reject( 723 | params: RejectPullRequestParams, 724 | ): Promise<PullRequestParticipant> { 725 | const methodLogger = Logger.forContext( 726 | 'services/vendor.atlassian.pullrequests.service.ts', 727 | 'reject', 728 | ); 729 | methodLogger.debug( 730 | `Requesting changes on Bitbucket pull request: ${params.workspace}/${params.repo_slug}/${params.pull_request_id}`, 731 | ); 732 | 733 | if (!params.workspace || !params.repo_slug || !params.pull_request_id) { 734 | throw new Error( 735 | 'workspace, repo_slug, and pull_request_id parameters are all required', 736 | ); 737 | } 738 | 739 | const credentials = getAtlassianCredentials(); 740 | if (!credentials) { 741 | throw createAuthMissingError( 742 | 'Atlassian credentials are required for this operation', 743 | ); 744 | } 745 | 746 | const path = `${API_PATH}/repositories/${params.workspace}/${params.repo_slug}/pullrequests/${params.pull_request_id}/request-changes`; 747 | 748 | methodLogger.debug(`Sending POST request to: ${path}`); 749 | return fetchAtlassian<PullRequestParticipant>(credentials, path, { 750 | method: 'POST', 751 | }); 752 | } 753 | 754 | export default { 755 | list, 756 | get, 757 | getComments, 758 | createComment, 759 | create, 760 | update, 761 | approve, 762 | reject, 763 | getRawDiff, 764 | getDiffstat, 765 | getDiffForUrl, 766 | }; 767 | ```