From f602840c971b646730c914255d6e20cc1887d0f8 Mon Sep 17 00:00:00 2001 From: pdogra1299 Date: Fri, 27 Jun 2025 02:26:08 +0530 Subject: [PATCH] feat: Add code snippet support and suggestions to add_comment tool - Added code_snippet parameter to find line numbers automatically using code text - Added search_context parameter with before/after arrays for disambiguation - Added match_strategy parameter (strict/best) for handling multiple matches - Added suggestion and suggestion_end_line parameters for code suggestions - Created suggestion-formatter utility for formatting suggestion comments - Enhanced tool definitions with clearer descriptions and examples - Updated README with comprehensive usage guide and decision flow - Removed all debug information from responses for production readiness - Added detailed error responses when multiple code matches are found - Improved type definitions and guards for new parameters This makes the add_comment tool more intelligent and user-friendly, especially for AI-powered code review scenarios. --- CHANGELOG.md | 36 +++ README.md | 207 ++++++++++-- package-lock.json | 12 +- package.json | 4 +- src/handlers/pull-request-handlers.ts | 440 +++++++++++++++++++++++--- src/index.ts | 2 +- src/tools/definitions.ts | 45 ++- src/types/bitbucket.ts | 32 ++ src/types/guards.ts | 19 +- src/utils/suggestion-formatter.ts | 22 ++ 10 files changed, 746 insertions(+), 73 deletions(-) create mode 100644 src/utils/suggestion-formatter.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 090b71d..6c16093 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,42 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.9.0] - 2025-01-26 + +### Added +- **Code snippet support in `add_comment` tool**: + - Added `code_snippet` parameter to find line numbers automatically using code text + - Added `search_context` parameter with `before` and `after` arrays to disambiguate multiple matches + - Added `match_strategy` parameter with options: + - `"strict"` (default): Fails with detailed error when multiple matches found + - `"best"`: Auto-selects the highest confidence match + - Returns detailed error with all occurrences when multiple matches found in strict mode + - Particularly useful for AI-powered code review tools that analyze diffs +- Created comprehensive line matching algorithm that: + - Parses diffs to find exact code snippets + - Calculates confidence scores based on context matching + - Handles added, removed, and context lines appropriately + +### Changed +- Enhanced `add_comment` tool to resolve line numbers from code snippets when `line_number` is not provided +- Improved error messages to include preview and suggestions for resolving ambiguous matches + +## [0.8.0] - 2025-01-26 + +### Added +- **Code suggestions support in `add_comment` tool**: + - Added `suggestion` parameter to add code suggestions in comments + - Added `suggestion_end_line` parameter for multi-line suggestions + - Suggestions are formatted using GitHub-style markdown ````suggestion` blocks + - Works with both single-line and multi-line code replacements + - Requires `file_path` and `line_number` to be specified when using suggestions + - Compatible with both Bitbucket Cloud and Server +- Created `suggestion-formatter.ts` utility for formatting suggestion comments + +### Changed +- Enhanced `add_comment` tool to validate suggestion requirements +- Updated tool response to indicate when a comment contains a suggestion + ## [0.7.0] - 2025-01-26 ### Added diff --git a/README.md b/README.md index 6ba0ebd..1f1cfcb 100644 --- a/README.md +++ b/README.md @@ -271,9 +271,9 @@ Returns a paginated list of pull requests with: ### Add Comment -Add general comments, reply to existing comments, or add inline comments on specific lines of code: +Add a comment to a pull request, either as a general comment or inline on specific code: -```typescript +```javascript // General comment { "tool": "add_comment", @@ -281,37 +281,150 @@ Add general comments, reply to existing comments, or add inline comments on spec "workspace": "PROJ", "repository": "my-repo", "pull_request_id": 123, - "comment_text": "Great work! Just one small suggestion..." + "comment_text": "Great work on this PR!" } } -// Reply to an existing comment +// Inline comment on specific line { "tool": "add_comment", "arguments": { "workspace": "PROJ", "repository": "my-repo", "pull_request_id": 123, - "comment_text": "Thanks for the feedback! I've updated the code.", - "parent_comment_id": 456 // ID of the comment you're replying to - } -} - -// Inline comment on specific code -{ - "tool": "add_comment", - "arguments": { - "workspace": "PROJ", - "repository": "my-repo", - "pull_request_id": 123, - "comment_text": "This variable should be renamed for clarity", - "file_path": "src/main.js", + "comment_text": "Consider extracting this into a separate function", + "file_path": "src/utils/helpers.js", "line_number": 42, - "line_type": "ADDED" // ADDED, REMOVED, or CONTEXT + "line_type": "CONTEXT" // ADDED, REMOVED, or CONTEXT + } +} + +// Reply to existing comment +{ + "tool": "add_comment", + "arguments": { + "workspace": "PROJ", + "repository": "my-repo", + "pull_request_id": 123, + "comment_text": "I agree with this suggestion", + "parent_comment_id": 456 + } +} + +// Add comment with code suggestion (single line) +{ + "tool": "add_comment", + "arguments": { + "workspace": "PROJ", + "repository": "my-repo", + "pull_request_id": 123, + "comment_text": "This variable name could be more descriptive.", + "file_path": "src/utils/helpers.js", + "line_number": 42, + "line_type": "CONTEXT", + "suggestion": "const userAuthenticationToken = token;" + } +} + +// Add comment with multi-line code suggestion +{ + "tool": "add_comment", + "arguments": { + "workspace": "PROJ", + "repository": "my-repo", + "pull_request_id": 123, + "comment_text": "This function could be simplified using array methods.", + "file_path": "src/utils/calculations.js", + "line_number": 50, + "suggestion_end_line": 55, + "line_type": "CONTEXT", + "suggestion": "function calculateTotal(items) {\n return items.reduce((sum, item) => sum + item.price, 0);\n}" } } ``` +The suggestion feature formats comments using GitHub-style markdown suggestion blocks that Bitbucket can render. When adding a suggestion: +- `suggestion` is required and contains the replacement code +- `file_path` and `line_number` are required when using suggestions +- `suggestion_end_line` is optional and used for multi-line suggestions (defaults to `line_number`) +- The comment will be formatted with a ````suggestion` markdown block that may be applicable in the Bitbucket UI + +### Using Code Snippets Instead of Line Numbers + +The `add_comment` tool now supports finding line numbers automatically using code snippets. This is especially useful when AI tools analyze diffs and may struggle with exact line numbers: + +```javascript +// Add comment using code snippet +{ + "tool": "add_comment", + "arguments": { + "workspace": "PROJ", + "repository": "my-repo", + "pull_request_id": 123, + "comment_text": "This variable name could be more descriptive", + "file_path": "src/components/Button.res", + "code_snippet": "let isDisabled = false", + "search_context": { + "before": ["let onClick = () => {"], + "after": ["setLoading(true)"] + } + } +} + +// Handle multiple matches with strategy +{ + "tool": "add_comment", + "arguments": { + "workspace": "PROJ", + "repository": "my-repo", + "pull_request_id": 123, + "comment_text": "Consider extracting this", + "file_path": "src/utils/helpers.js", + "code_snippet": "return result;", + "search_context": { + "before": ["const result = calculate();"], + "after": ["}"] + }, + "match_strategy": "best" // Auto-select highest confidence match + } +} +``` + +**Code Snippet Parameters:** +- `code_snippet`: The exact code line to find (alternative to `line_number`) +- `search_context`: Optional context to disambiguate multiple matches + - `before`: Array of lines that should appear before the target + - `after`: Array of lines that should appear after the target +- `match_strategy`: How to handle multiple matches + - `"strict"` (default): Fail with error showing all matches + - `"best"`: Auto-select the highest confidence match + +**Error Response for Multiple Matches (strict mode):** +```json +{ + "error": { + "code": "MULTIPLE_MATCHES_FOUND", + "message": "Code snippet 'return result;' found in 3 locations", + "occurrences": [ + { + "line_number": 42, + "file_path": "src/utils/helpers.js", + "preview": " const result = calculate();\n> return result;\n}", + "confidence": 0.9, + "line_type": "ADDED" + }, + // ... more matches + ], + "suggestion": "To resolve, either:\n1. Add more context...\n2. Use match_strategy: 'best'...\n3. Use line_number directly" + } +} +``` + +This feature is particularly useful for: +- AI-powered code review tools that analyze diffs +- Scripts that automatically add comments based on code patterns +- Avoiding line number confusion in large diffs + **Note on comment replies:** - Use `parent_comment_id` to reply to any comment (general or inline) - In `get_pull_request` responses: @@ -327,6 +440,62 @@ Add general comments, reply to existing comments, or add inline comments on spec - `REMOVED` - For deleted lines (red in diff) - `CONTEXT` - For unchanged context lines +#### Add Comment - Complete Usage Guide + +The `add_comment` tool supports multiple scenarios. Here's when and how to use each approach: + +**1. General PR Comments (No file/line)** +- Use when: Making overall feedback about the PR +- Required params: `comment_text` only +- Example: "LGTM!", "Please update the documentation" + +**2. Reply to Existing Comments** +- Use when: Continuing a conversation thread +- Required params: `comment_text`, `parent_comment_id` +- Works for both general and inline comment replies + +**3. Inline Comments with Line Number** +- Use when: You know the exact line number from the diff +- Required params: `comment_text`, `file_path`, `line_number` +- Optional: `line_type` (defaults to CONTEXT) + +**4. Inline Comments with Code Snippet** +- Use when: You have the code but not the line number (common for AI tools) +- Required params: `comment_text`, `file_path`, `code_snippet` +- The tool will automatically find the line number +- Add `search_context` if the code appears multiple times +- Use `match_strategy: "best"` to auto-select when multiple matches exist + +**5. Code Suggestions** +- Use when: Proposing specific code changes +- Required params: `comment_text`, `file_path`, `line_number`, `suggestion` +- For multi-line: also add `suggestion_end_line` +- Creates applicable suggestion blocks in Bitbucket UI + +**Decision Flow for AI/Automated Tools:** +``` +1. Do you want to suggest code changes? + → Use suggestion with line_number + +2. Do you have the exact line number? + → Use line_number directly + +3. Do you have the code snippet but not line number? + → Use code_snippet (add search_context if needed) + +4. Is it a general comment about the PR? + → Use comment_text only + +5. Are you replying to another comment? + → Add parent_comment_id +``` + +**Common Pitfalls to Avoid:** +- Don't use both `line_number` and `code_snippet` - pick one +- Suggestions always need `file_path` and `line_number` +- Code snippets must match exactly (including whitespace) +- REMOVED lines reference the source file, ADDED/CONTEXT reference the destination + ### Merge Pull Request ```typescript diff --git a/package-lock.json b/package-lock.json index 66faeeb..a49b81f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "@nexus2520/bitbucket-mcp-server", - "version": "0.7.0", + "version": "0.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@nexus2520/bitbucket-mcp-server", - "version": "0.7.0", + "version": "0.9.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.12.1", - "axios": "^1.9.0", + "axios": "^1.10.0", "minimatch": "^9.0.3" }, "bin": { @@ -94,9 +94,9 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "node_modules/axios": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz", - "integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz", + "integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==", "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.0", diff --git a/package.json b/package.json index 17c105e..3627092 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@nexus2520/bitbucket-mcp-server", - "version": "0.7.0", + "version": "0.9.0", "description": "MCP server for Bitbucket API integration - supports both Cloud and Server", "type": "module", "main": "./build/index.js", @@ -44,7 +44,7 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.12.1", - "axios": "^1.9.0", + "axios": "^1.10.0", "minimatch": "^9.0.3" }, "devDependencies": { diff --git a/src/handlers/pull-request-handlers.ts b/src/handlers/pull-request-handlers.ts index 9682fa4..7ec337c 100644 --- a/src/handlers/pull-request-handlers.ts +++ b/src/handlers/pull-request-handlers.ts @@ -1,6 +1,8 @@ import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; import { BitbucketApiClient } from '../utils/api-client.js'; import { formatServerResponse, formatCloudResponse } from '../utils/formatters.js'; +import { formatSuggestionComment } from '../utils/suggestion-formatter.js'; +import { DiffParser } from '../utils/diff-parser.js'; import { BitbucketServerPullRequest, BitbucketCloudPullRequest, @@ -9,7 +11,9 @@ import { BitbucketCloudComment, BitbucketCloudFileChange, FormattedComment, - FormattedFileChange + FormattedFileChange, + CodeMatch, + MultipleMatchesError } from '../types/bitbucket.js'; import { isGetPullRequestArgs, @@ -27,6 +31,43 @@ export class PullRequestHandlers { private username: string ) {} + private async getFilteredPullRequestDiff( + workspace: string, + repository: string, + pullRequestId: number, + filePath: string, + contextLines: number = 3 + ): Promise { + let apiPath: string; + let config: any = {}; + + if (this.apiClient.getIsServer()) { + // Bitbucket Server API + apiPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pullRequestId}/diff`; + config.params = { contextLines }; + } else { + // Bitbucket Cloud API + apiPath = `/repositories/${workspace}/${repository}/pullrequests/${pullRequestId}/diff`; + config.params = { context: contextLines }; + } + + config.headers = { 'Accept': 'text/plain' }; + + const rawDiff = await this.apiClient.makeRequest('get', apiPath, undefined, config); + + const diffParser = new DiffParser(); + const sections = diffParser.parseDiffIntoSections(rawDiff); + + const filterOptions = { + filePath: filePath + }; + + const filteredResult = diffParser.filterSections(sections, filterOptions); + const filteredDiff = diffParser.reconstructDiff(filteredResult.sections); + + return filteredDiff; + } + async handleGetPullRequest(args: any) { if (!isGetPullRequestArgs(args)) { throw new McpError( @@ -38,7 +79,6 @@ export class PullRequestHandlers { const { workspace, repository, pull_request_id } = args; try { - // Different API paths for Server vs Cloud const apiPath = this.apiClient.getIsServer() ? `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pull_request_id}` : `/repositories/${workspace}/${repository}/pullrequests/${pull_request_id}`; @@ -47,10 +87,8 @@ export class PullRequestHandlers { let mergeInfo: MergeInfo = {}; - // For Bitbucket Server, fetch additional merge information if PR is merged if (this.apiClient.getIsServer() && pr.state === 'MERGED') { try { - // Try to get activities to find merge information const activitiesPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pull_request_id}/activities`; const activitiesResponse = await this.apiClient.makeRequest('get', activitiesPath, undefined, { params: { limit: 100 } @@ -64,25 +102,21 @@ export class PullRequestHandlers { mergeInfo.mergedBy = mergeActivity.user?.displayName || null; mergeInfo.mergedAt = new Date(mergeActivity.createdDate).toISOString(); - // Try to get commit message if we have the hash if (mergeActivity.commit?.id) { try { const commitPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/commits/${mergeActivity.commit.id}`; const commitResponse = await this.apiClient.makeRequest('get', commitPath); mergeInfo.mergeCommitMessage = commitResponse.message || null; } catch (commitError) { - // If we can't get the commit message, continue without it console.error('Failed to fetch merge commit message:', commitError); } } } } catch (activitiesError) { - // If we can't get activities, continue without merge info console.error('Failed to fetch PR activities:', activitiesError); } } - // Fetch comments and file changes in parallel let comments: FormattedComment[] = []; let activeCommentCount = 0; let totalCommentCount = 0; @@ -101,16 +135,13 @@ export class PullRequestHandlers { fileChanges = fileChangesResult.fileChanges; fileChangesSummary = fileChangesResult.summary; } catch (error) { - // Log error but continue with PR data console.error('Failed to fetch additional PR data:', error); } - // Format the response based on server type const formattedResponse = this.apiClient.getIsServer() ? formatServerResponse(pr as BitbucketServerPullRequest, mergeInfo, this.baseUrl) : formatCloudResponse(pr as BitbucketCloudPullRequest); - // Add comments and file changes to the response const enhancedResponse = { ...formattedResponse, active_comments: comments, @@ -174,7 +205,6 @@ export class PullRequestHandlers { const response = await this.apiClient.makeRequest('get', apiPath, undefined, { params }); - // Format the response let pullRequests: any[] = []; let totalCount = 0; let nextPageStart = null; @@ -386,10 +416,62 @@ export class PullRequestHandlers { ); } - const { workspace, repository, pull_request_id, comment_text, parent_comment_id, file_path, line_number, line_type } = args; + let { + workspace, + repository, + pull_request_id, + comment_text, + parent_comment_id, + file_path, + line_number, + line_type, + suggestion, + suggestion_end_line, + code_snippet, + search_context, + match_strategy = 'strict' + } = args; + + let sequentialPosition: number | undefined; + if (code_snippet && !line_number && file_path) { + try { + const resolved = await this.resolveLineFromCode( + workspace, + repository, + pull_request_id, + file_path, + code_snippet, + search_context, + match_strategy + ); + + line_number = resolved.line_number; + line_type = resolved.line_type; + sequentialPosition = resolved.sequential_position; + } catch (error) { + throw error; + } + } + + if (suggestion && (!file_path || !line_number)) { + throw new McpError( + ErrorCode.InvalidParams, + 'Suggestions require file_path and line_number to be specified' + ); + } const isInlineComment = file_path !== undefined && line_number !== undefined; + let finalCommentText = comment_text; + if (suggestion) { + finalCommentText = formatSuggestionComment( + comment_text, + suggestion, + line_number, + suggestion_end_line || line_number + ); + } + try { let apiPath: string; let requestBody: any; @@ -398,7 +480,7 @@ export class PullRequestHandlers { // Bitbucket Server API apiPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pull_request_id}/comments`; requestBody = { - text: comment_text + text: finalCommentText }; if (parent_comment_id !== undefined) { @@ -408,18 +490,19 @@ export class PullRequestHandlers { if (isInlineComment) { requestBody.anchor = { line: line_number, - lineType: line_type || 'CONTEXT', + lineType: line_type || 'CONTEXT', fileType: line_type === 'REMOVED' ? 'FROM' : 'TO', path: file_path, diffType: 'EFFECTIVE' }; + } } else { // Bitbucket Cloud API apiPath = `/repositories/${workspace}/${repository}/pullrequests/${pull_request_id}/comments`; requestBody = { content: { - raw: comment_text + raw: finalCommentText } }; @@ -437,12 +520,16 @@ export class PullRequestHandlers { const comment = await this.apiClient.makeRequest('post', apiPath, requestBody); + const responseMessage = suggestion + ? 'Comment with code suggestion added successfully' + : (isInlineComment ? 'Inline comment added successfully' : 'Comment added successfully'); + return { content: [ { type: 'text', text: JSON.stringify({ - message: isInlineComment ? 'Inline comment added successfully' : 'Comment added successfully', + message: responseMessage, comment: { id: comment.id, text: this.apiClient.getIsServer() ? comment.text : comment.content.raw, @@ -450,7 +537,9 @@ export class PullRequestHandlers { created_on: this.apiClient.getIsServer() ? new Date(comment.createdDate).toLocaleString() : comment.created_on, file_path: isInlineComment ? file_path : undefined, line_number: isInlineComment ? line_number : undefined, - line_type: isInlineComment ? (line_type || 'CONTEXT') : undefined + line_type: isInlineComment ? (line_type || 'CONTEXT') : undefined, + has_suggestion: !!suggestion, + suggestion_lines: suggestion ? (suggestion_end_line ? `${line_number}-${suggestion_end_line}` : `${line_number}`) : undefined } }, null, 2), }, @@ -532,7 +621,6 @@ export class PullRequestHandlers { let totalCount = 0; if (this.apiClient.getIsServer()) { - // Helper function to process nested comments recursively const processNestedComments = (comment: any, anchor: any): FormattedComment => { const formattedComment: FormattedComment = { id: comment.id, @@ -545,11 +633,9 @@ export class PullRequestHandlers { state: comment.state }; - // Process nested replies if (comment.comments && comment.comments.length > 0) { formattedComment.replies = comment.comments .filter((reply: any) => { - // Apply same filters to replies if (reply.state === 'RESOLVED') return false; if (anchor && anchor.orphaned === true) return false; return true; @@ -560,7 +646,6 @@ export class PullRequestHandlers { return formattedComment; }; - // Helper to count all comments including nested ones const countAllComments = (comment: any): number => { let count = 1; if (comment.comments && comment.comments.length > 0) { @@ -569,16 +654,13 @@ export class PullRequestHandlers { return count; }; - // Helper to count active comments including nested ones const countActiveComments = (comment: any, anchor: any): number => { let count = 0; - // Check if this comment is active if (comment.state !== 'RESOLVED' && (!anchor || anchor.orphaned !== true)) { count = 1; } - // Count active nested comments if (comment.comments && comment.comments.length > 0) { count += comment.comments.reduce((sum: number, reply: any) => sum + countActiveComments(reply, anchor), 0); } @@ -586,7 +668,6 @@ export class PullRequestHandlers { return count; }; - // Bitbucket Server API - fetch from activities const apiPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pullRequestId}/activities`; const response = await this.apiClient.makeRequest('get', apiPath, undefined, { params: { limit: 1000 } @@ -594,41 +675,32 @@ export class PullRequestHandlers { const activities = response.values || []; - // Filter for comment activities const commentActivities = activities.filter((a: any) => a.action === 'COMMENTED' && a.comment ); - // Count all comments including nested ones totalCount = commentActivities.reduce((sum: number, activity: any) => { return sum + countAllComments(activity.comment); }, 0); - // Count active comments including nested ones activeCount = commentActivities.reduce((sum: number, activity: any) => { return sum + countActiveComments(activity.comment, activity.commentAnchor); }, 0); - // Process top-level comments and their nested replies const processedComments = commentActivities .filter((a: any) => { const c = a.comment; const anchor = a.commentAnchor; - // Skip resolved comments if (c.state === 'RESOLVED') return false; - - // Skip orphaned inline comments if (anchor && anchor.orphaned === true) return false; return true; }) .map((a: any) => processNestedComments(a.comment, a.commentAnchor)); - // Limit to 20 top-level comments comments = processedComments.slice(0, 20); } else { - // Bitbucket Cloud API const apiPath = `/repositories/${workspace}/${repository}/pullrequests/${pullRequestId}/comments`; const response = await this.apiClient.makeRequest('get', apiPath, undefined, { params: { pagelen: 100 } @@ -637,7 +709,6 @@ export class PullRequestHandlers { const allComments = response.values || []; totalCount = allComments.length; - // Filter for active comments (not deleted or resolved) and limit to 20 const activeComments = allComments .filter((c: BitbucketCloudComment) => !c.deleted && !c.resolved) .slice(0, 20); @@ -673,7 +744,6 @@ export class PullRequestHandlers { let totalLinesRemoved = 0; if (this.apiClient.getIsServer()) { - // Bitbucket Server API - use changes endpoint const apiPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pullRequestId}/changes`; const response = await this.apiClient.makeRequest('get', apiPath, undefined, { params: { limit: 1000 } @@ -694,7 +764,6 @@ export class PullRequestHandlers { }; }); } else { - // Bitbucket Cloud API - use diffstat endpoint (has line statistics) const apiPath = `/repositories/${workspace}/${repository}/pullrequests/${pullRequestId}/diffstat`; const response = await this.apiClient.makeRequest('get', apiPath, undefined, { params: { pagelen: 100 } @@ -729,4 +798,299 @@ export class PullRequestHandlers { }; } } + + private async resolveLineFromCode( + workspace: string, + repository: string, + pullRequestId: number, + filePath: string, + codeSnippet: string, + searchContext?: { before?: string[]; after?: string[] }, + matchStrategy: 'strict' | 'best' = 'strict' + ): Promise<{ + line_number: number; + line_type: 'ADDED' | 'REMOVED' | 'CONTEXT'; + sequential_position?: number; + hunk_info?: any; + diff_context?: string; + diff_content_preview?: string; + calculation_details?: string; + }> { + try { + const diffContent = await this.getFilteredPullRequestDiff(workspace, repository, pullRequestId, filePath); + + const parser = new DiffParser(); + const sections = parser.parseDiffIntoSections(diffContent); + + let fileSection = sections[0]; + if (!this.apiClient.getIsServer()) { + fileSection = sections.find(s => s.filePath === filePath) || sections[0]; + } + + if (!fileSection) { + throw new McpError( + ErrorCode.InvalidParams, + `File ${filePath} not found in pull request diff` + ); + } + + const matches = this.findCodeMatches( + fileSection.content, + codeSnippet, + searchContext + ); + + if (matches.length === 0) { + throw new McpError( + ErrorCode.InvalidParams, + `Code snippet not found in ${filePath}` + ); + } + + if (matches.length === 1) { + return { + line_number: matches[0].line_number, + line_type: matches[0].line_type, + sequential_position: matches[0].sequential_position, + hunk_info: matches[0].hunk_info, + diff_context: matches[0].preview, + diff_content_preview: diffContent.split('\n').slice(0, 50).join('\n'), + calculation_details: `Direct line number from diff: ${matches[0].line_number}` + }; + } + + if (matchStrategy === 'best') { + const best = this.selectBestMatch(matches); + + return { + line_number: best.line_number, + line_type: best.line_type, + sequential_position: best.sequential_position, + hunk_info: best.hunk_info, + diff_context: best.preview, + diff_content_preview: diffContent.split('\n').slice(0, 50).join('\n'), + calculation_details: `Best match selected from ${matches.length} matches, line: ${best.line_number}` + }; + } + + const error: MultipleMatchesError = { + code: 'MULTIPLE_MATCHES_FOUND', + message: `Code snippet '${codeSnippet.substring(0, 50)}...' found in ${matches.length} locations`, + occurrences: matches.map(m => ({ + line_number: m.line_number, + file_path: filePath, + preview: m.preview, + confidence: m.confidence, + line_type: m.line_type + })), + suggestion: 'To resolve, either:\n1. Add more context to uniquely identify the location\n2. Use match_strategy: \'best\' to auto-select highest confidence match\n3. Use line_number directly' + }; + + throw new McpError( + ErrorCode.InvalidParams, + JSON.stringify({ error }) + ); + } catch (error) { + if (error instanceof McpError) { + throw error; + } + throw new McpError( + ErrorCode.InternalError, + `Failed to resolve line from code: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + + private findCodeMatches( + diffContent: string, + codeSnippet: string, + searchContext?: { before?: string[]; after?: string[] } + ): CodeMatch[] { + const lines = diffContent.split('\n'); + const matches: CodeMatch[] = []; + let currentDestLine = 0; // Destination file line number + let currentSrcLine = 0; // Source file line number + let inHunk = false; + let sequentialAddedCount = 0; // Track sequential ADDED lines + let currentHunkIndex = -1; + let currentHunkDestStart = 0; + let currentHunkSrcStart = 0; + let destPositionInHunk = 0; // Track position in destination file relative to hunk start + let srcPositionInHunk = 0; // Track position in source file relative to hunk start + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + if (line.startsWith('@@')) { + const match = line.match(/@@ -(\d+),\d+ \+(\d+),\d+ @@/); + if (match) { + currentHunkSrcStart = parseInt(match[1]); + currentHunkDestStart = parseInt(match[2]); + currentSrcLine = currentHunkSrcStart; + currentDestLine = currentHunkDestStart; + inHunk = true; + currentHunkIndex++; + destPositionInHunk = 0; + srcPositionInHunk = 0; + continue; + } + } + + if (!inHunk) continue; + + if (line === '') { + inHunk = false; + continue; + } + + let lineType: 'ADDED' | 'REMOVED' | 'CONTEXT'; + let lineContent = ''; + let lineNumber = 0; + + if (line.startsWith('+')) { + lineType = 'ADDED'; + lineContent = line.substring(1); + lineNumber = currentHunkDestStart + destPositionInHunk; + destPositionInHunk++; + sequentialAddedCount++; + } else if (line.startsWith('-')) { + lineType = 'REMOVED'; + lineContent = line.substring(1); + lineNumber = currentHunkSrcStart + srcPositionInHunk; + srcPositionInHunk++; + } else if (line.startsWith(' ')) { + lineType = 'CONTEXT'; + lineContent = line.substring(1); + lineNumber = currentHunkDestStart + destPositionInHunk; + destPositionInHunk++; + srcPositionInHunk++; + } else { + inHunk = false; + continue; + } + + if (lineContent.trim() === codeSnippet.trim()) { + const confidence = this.calculateConfidence( + lines, + i, + searchContext, + lineType + ); + + matches.push({ + line_number: lineNumber, + line_type: lineType, + exact_content: codeSnippet, + preview: this.getPreview(lines, i), + confidence, + context: this.extractContext(lines, i), + sequential_position: lineType === 'ADDED' ? sequentialAddedCount : undefined, + hunk_info: { + hunk_index: currentHunkIndex, + destination_start: currentHunkDestStart, + line_in_hunk: destPositionInHunk + } + }); + } + + if (lineType === 'ADDED') { + currentDestLine++; + } else if (lineType === 'REMOVED') { + currentSrcLine++; + } else if (lineType === 'CONTEXT') { + currentSrcLine++; + currentDestLine++; + } + } + + return matches; + } + + private calculateConfidence( + lines: string[], + index: number, + searchContext?: { before?: string[]; after?: string[] }, + lineType?: 'ADDED' | 'REMOVED' | 'CONTEXT' + ): number { + let confidence = 0.5; // Base confidence + + if (!searchContext) { + return confidence; + } + + if (searchContext.before) { + let matchedBefore = 0; + for (let j = 0; j < searchContext.before.length; j++) { + const contextLine = searchContext.before[searchContext.before.length - 1 - j]; + const checkIndex = index - j - 1; + if (checkIndex >= 0) { + const checkLine = lines[checkIndex].substring(1); + if (checkLine.trim() === contextLine.trim()) { + matchedBefore++; + } + } + } + confidence += (matchedBefore / searchContext.before.length) * 0.3; + } + + if (searchContext.after) { + let matchedAfter = 0; + for (let j = 0; j < searchContext.after.length; j++) { + const contextLine = searchContext.after[j]; + const checkIndex = index + j + 1; + if (checkIndex < lines.length) { + const checkLine = lines[checkIndex].substring(1); + if (checkLine.trim() === contextLine.trim()) { + matchedAfter++; + } + } + } + confidence += (matchedAfter / searchContext.after.length) * 0.3; + } + + if (lineType === 'ADDED') { + confidence += 0.1; + } + + return Math.min(confidence, 1.0); + } + + private getPreview(lines: string[], index: number): string { + const start = Math.max(0, index - 1); + const end = Math.min(lines.length, index + 2); + const previewLines = []; + + for (let i = start; i < end; i++) { + const prefix = i === index ? '> ' : ' '; + previewLines.push(prefix + lines[i]); + } + + return previewLines.join('\n'); + } + + private extractContext(lines: string[], index: number): { lines_before: string[]; lines_after: string[] } { + const linesBefore: string[] = []; + const linesAfter: string[] = []; + + for (let i = Math.max(0, index - 2); i < index; i++) { + if (lines[i].match(/^[+\- ]/)) { + linesBefore.push(lines[i].substring(1)); + } + } + + for (let i = index + 1; i < Math.min(lines.length, index + 3); i++) { + if (lines[i].match(/^[+\- ]/)) { + linesAfter.push(lines[i].substring(1)); + } + } + + return { + lines_before: linesBefore, + lines_after: linesAfter + }; + } + + private selectBestMatch(matches: CodeMatch[]): CodeMatch { + return matches.sort((a, b) => b.confidence - a.confidence)[0]; + } } diff --git a/src/index.ts b/src/index.ts index ea076f0..ddc6131 100644 --- a/src/index.ts +++ b/src/index.ts @@ -40,7 +40,7 @@ class BitbucketMCPServer { this.server = new Server( { name: 'bitbucket-mcp-server', - version: '0.7.0', + version: '0.9.0', }, { capabilities: { diff --git a/src/tools/definitions.ts b/src/tools/definitions.ts index a39c29a..5d0ff44 100644 --- a/src/tools/definitions.ts +++ b/src/tools/definitions.ts @@ -140,7 +140,7 @@ export const toolDefinitions = [ }, { name: 'add_comment', - description: 'Add a comment to a pull request (general or inline on specific code)', + description: 'Add a comment to a pull request. Supports: 1) General PR comments, 2) Replies to existing comments, 3) Inline comments on specific code lines (using line_number OR code_snippet), 4) Code suggestions for single or multi-line replacements. For inline comments, you can either provide exact line_number or use code_snippet to auto-detect the line.', inputSchema: { type: 'object', properties: { @@ -158,25 +158,58 @@ export const toolDefinitions = [ }, comment_text: { type: 'string', - description: 'Comment text', + description: 'The main comment text. For suggestions, this is the explanation before the code suggestion.', }, parent_comment_id: { type: 'number', - description: 'Parent comment ID for replies (optional)', + description: 'ID of comment to reply to. Use this to create threaded conversations (optional)', }, file_path: { type: 'string', - description: 'File path for inline comment (optional, e.g., "src/main.js")', + description: 'File path for inline comment. Required for inline comments. Example: "src/components/Button.js" (optional)', }, line_number: { type: 'number', - description: 'Line number for inline comment (optional, required with file_path)', + description: 'Exact line number in the file. Use this OR code_snippet, not both. Required with file_path unless using code_snippet (optional)', }, line_type: { type: 'string', - description: 'Type of line for inline comment: ADDED, REMOVED, or CONTEXT (optional, default: CONTEXT)', + description: 'Type of line: ADDED (green/new lines), REMOVED (red/deleted lines), or CONTEXT (unchanged lines). Default: CONTEXT', enum: ['ADDED', 'REMOVED', 'CONTEXT'], }, + suggestion: { + type: 'string', + description: 'Replacement code for a suggestion. Creates a suggestion block that can be applied in Bitbucket UI. Requires file_path and line_number. For multi-line, include newlines in the string (optional)', + }, + suggestion_end_line: { + type: 'number', + description: 'For multi-line suggestions: the last line number to replace. If not provided, only replaces the single line at line_number (optional)', + }, + code_snippet: { + type: 'string', + description: 'Exact code text from the diff to find and comment on. Use this instead of line_number for auto-detection. Must match exactly including whitespace (optional)', + }, + search_context: { + type: 'object', + properties: { + before: { + type: 'array', + items: { type: 'string' }, + description: 'Array of code lines that appear BEFORE the target line. Helps disambiguate when code_snippet appears multiple times', + }, + after: { + type: 'array', + items: { type: 'string' }, + description: 'Array of code lines that appear AFTER the target line. Helps disambiguate when code_snippet appears multiple times', + }, + }, + description: 'Additional context lines to help locate the exact position when using code_snippet. Useful when the same code appears multiple times (optional)', + }, + match_strategy: { + type: 'string', + enum: ['strict', 'best'], + description: 'How to handle multiple matches when using code_snippet. "strict": fail with detailed error showing all matches. "best": automatically pick the highest confidence match. Default: "strict"', + }, }, required: ['workspace', 'repository', 'pull_request_id', 'comment_text'], }, diff --git a/src/types/bitbucket.ts b/src/types/bitbucket.ts index 183655d..2ebb27d 100644 --- a/src/types/bitbucket.ts +++ b/src/types/bitbucket.ts @@ -352,3 +352,35 @@ export interface FormattedFileChange { status: 'added' | 'modified' | 'removed' | 'renamed'; old_path?: string; } + +// Types for code snippet matching +export interface CodeMatch { + line_number: number; + line_type: 'ADDED' | 'REMOVED' | 'CONTEXT'; + exact_content: string; + preview: string; + confidence: number; + context: { + lines_before: string[]; + lines_after: string[]; + }; + sequential_position?: number; // Position within diff (for ADDED lines) + hunk_info?: { + hunk_index: number; + destination_start: number; + line_in_hunk: number; + }; +} + +export interface MultipleMatchesError { + code: 'MULTIPLE_MATCHES_FOUND'; + message: string; + occurrences: Array<{ + line_number: number; + file_path: string; + preview: string; + confidence: number; + line_type: 'ADDED' | 'REMOVED' | 'CONTEXT'; + }>; + suggestion: string; +} diff --git a/src/types/guards.ts b/src/types/guards.ts index 0b31fc5..94fb60d 100644 --- a/src/types/guards.ts +++ b/src/types/guards.ts @@ -82,6 +82,14 @@ export const isAddCommentArgs = ( file_path?: string; line_number?: number; line_type?: 'ADDED' | 'REMOVED' | 'CONTEXT'; + suggestion?: string; + suggestion_end_line?: number; + code_snippet?: string; + search_context?: { + before?: string[]; + after?: string[]; + }; + match_strategy?: 'strict' | 'best'; } => typeof args === 'object' && args !== null && @@ -92,7 +100,16 @@ export const isAddCommentArgs = ( (args.parent_comment_id === undefined || typeof args.parent_comment_id === 'number') && (args.file_path === undefined || typeof args.file_path === 'string') && (args.line_number === undefined || typeof args.line_number === 'number') && - (args.line_type === undefined || ['ADDED', 'REMOVED', 'CONTEXT'].includes(args.line_type)); + (args.line_type === undefined || ['ADDED', 'REMOVED', 'CONTEXT'].includes(args.line_type)) && + (args.suggestion === undefined || typeof args.suggestion === 'string') && + (args.suggestion_end_line === undefined || typeof args.suggestion_end_line === 'number') && + (args.code_snippet === undefined || typeof args.code_snippet === 'string') && + (args.search_context === undefined || ( + typeof args.search_context === 'object' && + (args.search_context.before === undefined || Array.isArray(args.search_context.before)) && + (args.search_context.after === undefined || Array.isArray(args.search_context.after)) + )) && + (args.match_strategy === undefined || ['strict', 'best'].includes(args.match_strategy)); export const isMergePullRequestArgs = ( args: any diff --git a/src/utils/suggestion-formatter.ts b/src/utils/suggestion-formatter.ts new file mode 100644 index 0000000..2aef157 --- /dev/null +++ b/src/utils/suggestion-formatter.ts @@ -0,0 +1,22 @@ +/** + * Formats a comment with a code suggestion in markdown format + * that Bitbucket can render as an applicable suggestion + */ +export function formatSuggestionComment( + commentText: string, + suggestion: string, + startLine?: number, + endLine?: number +): string { + // Add line range info if it's a multi-line suggestion + const lineInfo = startLine && endLine && endLine > startLine + ? ` (lines ${startLine}-${endLine})` + : ''; + + // Format with GitHub-style suggestion markdown + return `${commentText}${lineInfo} + +\`\`\`suggestion +${suggestion} +\`\`\``; +}