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.
This commit is contained in:
parent
d649a116fe
commit
f602840c97
10 changed files with 746 additions and 73 deletions
36
CHANGELOG.md
36
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/),
|
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).
|
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
|
## [0.7.0] - 2025-01-26
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
207
README.md
207
README.md
|
@ -271,9 +271,9 @@ Returns a paginated list of pull requests with:
|
||||||
|
|
||||||
### Add Comment
|
### 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
|
// General comment
|
||||||
{
|
{
|
||||||
"tool": "add_comment",
|
"tool": "add_comment",
|
||||||
|
@ -281,37 +281,150 @@ Add general comments, reply to existing comments, or add inline comments on spec
|
||||||
"workspace": "PROJ",
|
"workspace": "PROJ",
|
||||||
"repository": "my-repo",
|
"repository": "my-repo",
|
||||||
"pull_request_id": 123,
|
"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",
|
"tool": "add_comment",
|
||||||
"arguments": {
|
"arguments": {
|
||||||
"workspace": "PROJ",
|
"workspace": "PROJ",
|
||||||
"repository": "my-repo",
|
"repository": "my-repo",
|
||||||
"pull_request_id": 123,
|
"pull_request_id": 123,
|
||||||
"comment_text": "Thanks for the feedback! I've updated the code.",
|
"comment_text": "Consider extracting this into a separate function",
|
||||||
"parent_comment_id": 456 // ID of the comment you're replying to
|
"file_path": "src/utils/helpers.js",
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inline comment on specific code
|
|
||||||
{
|
|
||||||
"tool": "add_comment",
|
|
||||||
"arguments": {
|
|
||||||
"workspace": "PROJ",
|
|
||||||
"repository": "my-repo",
|
|
||||||
"pull_request_id": 123,
|
|
||||||
"comment_text": "This variable should be renamed for clarity",
|
|
||||||
"file_path": "src/main.js",
|
|
||||||
"line_number": 42,
|
"line_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:**
|
**Note on comment replies:**
|
||||||
- Use `parent_comment_id` to reply to any comment (general or inline)
|
- Use `parent_comment_id` to reply to any comment (general or inline)
|
||||||
- In `get_pull_request` responses:
|
- 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)
|
- `REMOVED` - For deleted lines (red in diff)
|
||||||
- `CONTEXT` - For unchanged context lines
|
- `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
|
### Merge Pull Request
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
|
12
package-lock.json
generated
12
package-lock.json
generated
|
@ -1,16 +1,16 @@
|
||||||
{
|
{
|
||||||
"name": "@nexus2520/bitbucket-mcp-server",
|
"name": "@nexus2520/bitbucket-mcp-server",
|
||||||
"version": "0.7.0",
|
"version": "0.9.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@nexus2520/bitbucket-mcp-server",
|
"name": "@nexus2520/bitbucket-mcp-server",
|
||||||
"version": "0.7.0",
|
"version": "0.9.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||||
"axios": "^1.9.0",
|
"axios": "^1.10.0",
|
||||||
"minimatch": "^9.0.3"
|
"minimatch": "^9.0.3"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
|
@ -94,9 +94,9 @@
|
||||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
|
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
|
||||||
},
|
},
|
||||||
"node_modules/axios": {
|
"node_modules/axios": {
|
||||||
"version": "1.9.0",
|
"version": "1.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz",
|
||||||
"integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==",
|
"integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"follow-redirects": "^1.15.6",
|
"follow-redirects": "^1.15.6",
|
||||||
"form-data": "^4.0.0",
|
"form-data": "^4.0.0",
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@nexus2520/bitbucket-mcp-server",
|
"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",
|
"description": "MCP server for Bitbucket API integration - supports both Cloud and Server",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./build/index.js",
|
"main": "./build/index.js",
|
||||||
|
@ -44,7 +44,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||||
"axios": "^1.9.0",
|
"axios": "^1.10.0",
|
||||||
"minimatch": "^9.0.3"
|
"minimatch": "^9.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
||||||
import { BitbucketApiClient } from '../utils/api-client.js';
|
import { BitbucketApiClient } from '../utils/api-client.js';
|
||||||
import { formatServerResponse, formatCloudResponse } from '../utils/formatters.js';
|
import { formatServerResponse, formatCloudResponse } from '../utils/formatters.js';
|
||||||
|
import { formatSuggestionComment } from '../utils/suggestion-formatter.js';
|
||||||
|
import { DiffParser } from '../utils/diff-parser.js';
|
||||||
import {
|
import {
|
||||||
BitbucketServerPullRequest,
|
BitbucketServerPullRequest,
|
||||||
BitbucketCloudPullRequest,
|
BitbucketCloudPullRequest,
|
||||||
|
@ -9,7 +11,9 @@ import {
|
||||||
BitbucketCloudComment,
|
BitbucketCloudComment,
|
||||||
BitbucketCloudFileChange,
|
BitbucketCloudFileChange,
|
||||||
FormattedComment,
|
FormattedComment,
|
||||||
FormattedFileChange
|
FormattedFileChange,
|
||||||
|
CodeMatch,
|
||||||
|
MultipleMatchesError
|
||||||
} from '../types/bitbucket.js';
|
} from '../types/bitbucket.js';
|
||||||
import {
|
import {
|
||||||
isGetPullRequestArgs,
|
isGetPullRequestArgs,
|
||||||
|
@ -27,6 +31,43 @@ export class PullRequestHandlers {
|
||||||
private username: string
|
private username: string
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
private async getFilteredPullRequestDiff(
|
||||||
|
workspace: string,
|
||||||
|
repository: string,
|
||||||
|
pullRequestId: number,
|
||||||
|
filePath: string,
|
||||||
|
contextLines: number = 3
|
||||||
|
): Promise<string> {
|
||||||
|
let apiPath: string;
|
||||||
|
let config: any = {};
|
||||||
|
|
||||||
|
if (this.apiClient.getIsServer()) {
|
||||||
|
// Bitbucket Server API
|
||||||
|
apiPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pullRequestId}/diff`;
|
||||||
|
config.params = { contextLines };
|
||||||
|
} else {
|
||||||
|
// Bitbucket Cloud API
|
||||||
|
apiPath = `/repositories/${workspace}/${repository}/pullrequests/${pullRequestId}/diff`;
|
||||||
|
config.params = { context: contextLines };
|
||||||
|
}
|
||||||
|
|
||||||
|
config.headers = { 'Accept': 'text/plain' };
|
||||||
|
|
||||||
|
const rawDiff = await this.apiClient.makeRequest<string>('get', apiPath, undefined, config);
|
||||||
|
|
||||||
|
const diffParser = new DiffParser();
|
||||||
|
const sections = diffParser.parseDiffIntoSections(rawDiff);
|
||||||
|
|
||||||
|
const filterOptions = {
|
||||||
|
filePath: filePath
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredResult = diffParser.filterSections(sections, filterOptions);
|
||||||
|
const filteredDiff = diffParser.reconstructDiff(filteredResult.sections);
|
||||||
|
|
||||||
|
return filteredDiff;
|
||||||
|
}
|
||||||
|
|
||||||
async handleGetPullRequest(args: any) {
|
async handleGetPullRequest(args: any) {
|
||||||
if (!isGetPullRequestArgs(args)) {
|
if (!isGetPullRequestArgs(args)) {
|
||||||
throw new McpError(
|
throw new McpError(
|
||||||
|
@ -38,7 +79,6 @@ export class PullRequestHandlers {
|
||||||
const { workspace, repository, pull_request_id } = args;
|
const { workspace, repository, pull_request_id } = args;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Different API paths for Server vs Cloud
|
|
||||||
const apiPath = this.apiClient.getIsServer()
|
const apiPath = this.apiClient.getIsServer()
|
||||||
? `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pull_request_id}`
|
? `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pull_request_id}`
|
||||||
: `/repositories/${workspace}/${repository}/pullrequests/${pull_request_id}`;
|
: `/repositories/${workspace}/${repository}/pullrequests/${pull_request_id}`;
|
||||||
|
@ -47,10 +87,8 @@ export class PullRequestHandlers {
|
||||||
|
|
||||||
let mergeInfo: MergeInfo = {};
|
let mergeInfo: MergeInfo = {};
|
||||||
|
|
||||||
// For Bitbucket Server, fetch additional merge information if PR is merged
|
|
||||||
if (this.apiClient.getIsServer() && pr.state === 'MERGED') {
|
if (this.apiClient.getIsServer() && pr.state === 'MERGED') {
|
||||||
try {
|
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 activitiesPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pull_request_id}/activities`;
|
||||||
const activitiesResponse = await this.apiClient.makeRequest<any>('get', activitiesPath, undefined, {
|
const activitiesResponse = await this.apiClient.makeRequest<any>('get', activitiesPath, undefined, {
|
||||||
params: { limit: 100 }
|
params: { limit: 100 }
|
||||||
|
@ -64,25 +102,21 @@ export class PullRequestHandlers {
|
||||||
mergeInfo.mergedBy = mergeActivity.user?.displayName || null;
|
mergeInfo.mergedBy = mergeActivity.user?.displayName || null;
|
||||||
mergeInfo.mergedAt = new Date(mergeActivity.createdDate).toISOString();
|
mergeInfo.mergedAt = new Date(mergeActivity.createdDate).toISOString();
|
||||||
|
|
||||||
// Try to get commit message if we have the hash
|
|
||||||
if (mergeActivity.commit?.id) {
|
if (mergeActivity.commit?.id) {
|
||||||
try {
|
try {
|
||||||
const commitPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/commits/${mergeActivity.commit.id}`;
|
const commitPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/commits/${mergeActivity.commit.id}`;
|
||||||
const commitResponse = await this.apiClient.makeRequest<any>('get', commitPath);
|
const commitResponse = await this.apiClient.makeRequest<any>('get', commitPath);
|
||||||
mergeInfo.mergeCommitMessage = commitResponse.message || null;
|
mergeInfo.mergeCommitMessage = commitResponse.message || null;
|
||||||
} catch (commitError) {
|
} catch (commitError) {
|
||||||
// If we can't get the commit message, continue without it
|
|
||||||
console.error('Failed to fetch merge commit message:', commitError);
|
console.error('Failed to fetch merge commit message:', commitError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (activitiesError) {
|
} catch (activitiesError) {
|
||||||
// If we can't get activities, continue without merge info
|
|
||||||
console.error('Failed to fetch PR activities:', activitiesError);
|
console.error('Failed to fetch PR activities:', activitiesError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch comments and file changes in parallel
|
|
||||||
let comments: FormattedComment[] = [];
|
let comments: FormattedComment[] = [];
|
||||||
let activeCommentCount = 0;
|
let activeCommentCount = 0;
|
||||||
let totalCommentCount = 0;
|
let totalCommentCount = 0;
|
||||||
|
@ -101,16 +135,13 @@ export class PullRequestHandlers {
|
||||||
fileChanges = fileChangesResult.fileChanges;
|
fileChanges = fileChangesResult.fileChanges;
|
||||||
fileChangesSummary = fileChangesResult.summary;
|
fileChangesSummary = fileChangesResult.summary;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Log error but continue with PR data
|
|
||||||
console.error('Failed to fetch additional PR data:', error);
|
console.error('Failed to fetch additional PR data:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format the response based on server type
|
|
||||||
const formattedResponse = this.apiClient.getIsServer()
|
const formattedResponse = this.apiClient.getIsServer()
|
||||||
? formatServerResponse(pr as BitbucketServerPullRequest, mergeInfo, this.baseUrl)
|
? formatServerResponse(pr as BitbucketServerPullRequest, mergeInfo, this.baseUrl)
|
||||||
: formatCloudResponse(pr as BitbucketCloudPullRequest);
|
: formatCloudResponse(pr as BitbucketCloudPullRequest);
|
||||||
|
|
||||||
// Add comments and file changes to the response
|
|
||||||
const enhancedResponse = {
|
const enhancedResponse = {
|
||||||
...formattedResponse,
|
...formattedResponse,
|
||||||
active_comments: comments,
|
active_comments: comments,
|
||||||
|
@ -174,7 +205,6 @@ export class PullRequestHandlers {
|
||||||
|
|
||||||
const response = await this.apiClient.makeRequest<any>('get', apiPath, undefined, { params });
|
const response = await this.apiClient.makeRequest<any>('get', apiPath, undefined, { params });
|
||||||
|
|
||||||
// Format the response
|
|
||||||
let pullRequests: any[] = [];
|
let pullRequests: any[] = [];
|
||||||
let totalCount = 0;
|
let totalCount = 0;
|
||||||
let nextPageStart = null;
|
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;
|
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 {
|
try {
|
||||||
let apiPath: string;
|
let apiPath: string;
|
||||||
let requestBody: any;
|
let requestBody: any;
|
||||||
|
@ -398,7 +480,7 @@ export class PullRequestHandlers {
|
||||||
// Bitbucket Server API
|
// Bitbucket Server API
|
||||||
apiPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pull_request_id}/comments`;
|
apiPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pull_request_id}/comments`;
|
||||||
requestBody = {
|
requestBody = {
|
||||||
text: comment_text
|
text: finalCommentText
|
||||||
};
|
};
|
||||||
|
|
||||||
if (parent_comment_id !== undefined) {
|
if (parent_comment_id !== undefined) {
|
||||||
|
@ -408,18 +490,19 @@ export class PullRequestHandlers {
|
||||||
if (isInlineComment) {
|
if (isInlineComment) {
|
||||||
requestBody.anchor = {
|
requestBody.anchor = {
|
||||||
line: line_number,
|
line: line_number,
|
||||||
lineType: line_type || 'CONTEXT',
|
lineType: line_type || 'CONTEXT',
|
||||||
fileType: line_type === 'REMOVED' ? 'FROM' : 'TO',
|
fileType: line_type === 'REMOVED' ? 'FROM' : 'TO',
|
||||||
path: file_path,
|
path: file_path,
|
||||||
diffType: 'EFFECTIVE'
|
diffType: 'EFFECTIVE'
|
||||||
};
|
};
|
||||||
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Bitbucket Cloud API
|
// Bitbucket Cloud API
|
||||||
apiPath = `/repositories/${workspace}/${repository}/pullrequests/${pull_request_id}/comments`;
|
apiPath = `/repositories/${workspace}/${repository}/pullrequests/${pull_request_id}/comments`;
|
||||||
requestBody = {
|
requestBody = {
|
||||||
content: {
|
content: {
|
||||||
raw: comment_text
|
raw: finalCommentText
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -437,12 +520,16 @@ export class PullRequestHandlers {
|
||||||
|
|
||||||
const comment = await this.apiClient.makeRequest<any>('post', apiPath, requestBody);
|
const comment = await this.apiClient.makeRequest<any>('post', apiPath, requestBody);
|
||||||
|
|
||||||
|
const responseMessage = suggestion
|
||||||
|
? 'Comment with code suggestion added successfully'
|
||||||
|
: (isInlineComment ? 'Inline comment added successfully' : 'Comment added successfully');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
text: JSON.stringify({
|
text: JSON.stringify({
|
||||||
message: isInlineComment ? 'Inline comment added successfully' : 'Comment added successfully',
|
message: responseMessage,
|
||||||
comment: {
|
comment: {
|
||||||
id: comment.id,
|
id: comment.id,
|
||||||
text: this.apiClient.getIsServer() ? comment.text : comment.content.raw,
|
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,
|
created_on: this.apiClient.getIsServer() ? new Date(comment.createdDate).toLocaleString() : comment.created_on,
|
||||||
file_path: isInlineComment ? file_path : undefined,
|
file_path: isInlineComment ? file_path : undefined,
|
||||||
line_number: isInlineComment ? line_number : 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),
|
}, null, 2),
|
||||||
},
|
},
|
||||||
|
@ -532,7 +621,6 @@ export class PullRequestHandlers {
|
||||||
let totalCount = 0;
|
let totalCount = 0;
|
||||||
|
|
||||||
if (this.apiClient.getIsServer()) {
|
if (this.apiClient.getIsServer()) {
|
||||||
// Helper function to process nested comments recursively
|
|
||||||
const processNestedComments = (comment: any, anchor: any): FormattedComment => {
|
const processNestedComments = (comment: any, anchor: any): FormattedComment => {
|
||||||
const formattedComment: FormattedComment = {
|
const formattedComment: FormattedComment = {
|
||||||
id: comment.id,
|
id: comment.id,
|
||||||
|
@ -545,11 +633,9 @@ export class PullRequestHandlers {
|
||||||
state: comment.state
|
state: comment.state
|
||||||
};
|
};
|
||||||
|
|
||||||
// Process nested replies
|
|
||||||
if (comment.comments && comment.comments.length > 0) {
|
if (comment.comments && comment.comments.length > 0) {
|
||||||
formattedComment.replies = comment.comments
|
formattedComment.replies = comment.comments
|
||||||
.filter((reply: any) => {
|
.filter((reply: any) => {
|
||||||
// Apply same filters to replies
|
|
||||||
if (reply.state === 'RESOLVED') return false;
|
if (reply.state === 'RESOLVED') return false;
|
||||||
if (anchor && anchor.orphaned === true) return false;
|
if (anchor && anchor.orphaned === true) return false;
|
||||||
return true;
|
return true;
|
||||||
|
@ -560,7 +646,6 @@ export class PullRequestHandlers {
|
||||||
return formattedComment;
|
return formattedComment;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper to count all comments including nested ones
|
|
||||||
const countAllComments = (comment: any): number => {
|
const countAllComments = (comment: any): number => {
|
||||||
let count = 1;
|
let count = 1;
|
||||||
if (comment.comments && comment.comments.length > 0) {
|
if (comment.comments && comment.comments.length > 0) {
|
||||||
|
@ -569,16 +654,13 @@ export class PullRequestHandlers {
|
||||||
return count;
|
return count;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper to count active comments including nested ones
|
|
||||||
const countActiveComments = (comment: any, anchor: any): number => {
|
const countActiveComments = (comment: any, anchor: any): number => {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
|
|
||||||
// Check if this comment is active
|
|
||||||
if (comment.state !== 'RESOLVED' && (!anchor || anchor.orphaned !== true)) {
|
if (comment.state !== 'RESOLVED' && (!anchor || anchor.orphaned !== true)) {
|
||||||
count = 1;
|
count = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count active nested comments
|
|
||||||
if (comment.comments && comment.comments.length > 0) {
|
if (comment.comments && comment.comments.length > 0) {
|
||||||
count += comment.comments.reduce((sum: number, reply: any) => sum + countActiveComments(reply, anchor), 0);
|
count += comment.comments.reduce((sum: number, reply: any) => sum + countActiveComments(reply, anchor), 0);
|
||||||
}
|
}
|
||||||
|
@ -586,7 +668,6 @@ export class PullRequestHandlers {
|
||||||
return count;
|
return count;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Bitbucket Server API - fetch from activities
|
|
||||||
const apiPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pullRequestId}/activities`;
|
const apiPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pullRequestId}/activities`;
|
||||||
const response = await this.apiClient.makeRequest<any>('get', apiPath, undefined, {
|
const response = await this.apiClient.makeRequest<any>('get', apiPath, undefined, {
|
||||||
params: { limit: 1000 }
|
params: { limit: 1000 }
|
||||||
|
@ -594,41 +675,32 @@ export class PullRequestHandlers {
|
||||||
|
|
||||||
const activities = response.values || [];
|
const activities = response.values || [];
|
||||||
|
|
||||||
// Filter for comment activities
|
|
||||||
const commentActivities = activities.filter((a: any) =>
|
const commentActivities = activities.filter((a: any) =>
|
||||||
a.action === 'COMMENTED' && a.comment
|
a.action === 'COMMENTED' && a.comment
|
||||||
);
|
);
|
||||||
|
|
||||||
// Count all comments including nested ones
|
|
||||||
totalCount = commentActivities.reduce((sum: number, activity: any) => {
|
totalCount = commentActivities.reduce((sum: number, activity: any) => {
|
||||||
return sum + countAllComments(activity.comment);
|
return sum + countAllComments(activity.comment);
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
// Count active comments including nested ones
|
|
||||||
activeCount = commentActivities.reduce((sum: number, activity: any) => {
|
activeCount = commentActivities.reduce((sum: number, activity: any) => {
|
||||||
return sum + countActiveComments(activity.comment, activity.commentAnchor);
|
return sum + countActiveComments(activity.comment, activity.commentAnchor);
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
// Process top-level comments and their nested replies
|
|
||||||
const processedComments = commentActivities
|
const processedComments = commentActivities
|
||||||
.filter((a: any) => {
|
.filter((a: any) => {
|
||||||
const c = a.comment;
|
const c = a.comment;
|
||||||
const anchor = a.commentAnchor;
|
const anchor = a.commentAnchor;
|
||||||
|
|
||||||
// Skip resolved comments
|
|
||||||
if (c.state === 'RESOLVED') return false;
|
if (c.state === 'RESOLVED') return false;
|
||||||
|
|
||||||
// Skip orphaned inline comments
|
|
||||||
if (anchor && anchor.orphaned === true) return false;
|
if (anchor && anchor.orphaned === true) return false;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
})
|
})
|
||||||
.map((a: any) => processNestedComments(a.comment, a.commentAnchor));
|
.map((a: any) => processNestedComments(a.comment, a.commentAnchor));
|
||||||
|
|
||||||
// Limit to 20 top-level comments
|
|
||||||
comments = processedComments.slice(0, 20);
|
comments = processedComments.slice(0, 20);
|
||||||
} else {
|
} else {
|
||||||
// Bitbucket Cloud API
|
|
||||||
const apiPath = `/repositories/${workspace}/${repository}/pullrequests/${pullRequestId}/comments`;
|
const apiPath = `/repositories/${workspace}/${repository}/pullrequests/${pullRequestId}/comments`;
|
||||||
const response = await this.apiClient.makeRequest<any>('get', apiPath, undefined, {
|
const response = await this.apiClient.makeRequest<any>('get', apiPath, undefined, {
|
||||||
params: { pagelen: 100 }
|
params: { pagelen: 100 }
|
||||||
|
@ -637,7 +709,6 @@ export class PullRequestHandlers {
|
||||||
const allComments = response.values || [];
|
const allComments = response.values || [];
|
||||||
totalCount = allComments.length;
|
totalCount = allComments.length;
|
||||||
|
|
||||||
// Filter for active comments (not deleted or resolved) and limit to 20
|
|
||||||
const activeComments = allComments
|
const activeComments = allComments
|
||||||
.filter((c: BitbucketCloudComment) => !c.deleted && !c.resolved)
|
.filter((c: BitbucketCloudComment) => !c.deleted && !c.resolved)
|
||||||
.slice(0, 20);
|
.slice(0, 20);
|
||||||
|
@ -673,7 +744,6 @@ export class PullRequestHandlers {
|
||||||
let totalLinesRemoved = 0;
|
let totalLinesRemoved = 0;
|
||||||
|
|
||||||
if (this.apiClient.getIsServer()) {
|
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 apiPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pullRequestId}/changes`;
|
||||||
const response = await this.apiClient.makeRequest<any>('get', apiPath, undefined, {
|
const response = await this.apiClient.makeRequest<any>('get', apiPath, undefined, {
|
||||||
params: { limit: 1000 }
|
params: { limit: 1000 }
|
||||||
|
@ -694,7 +764,6 @@ export class PullRequestHandlers {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Bitbucket Cloud API - use diffstat endpoint (has line statistics)
|
|
||||||
const apiPath = `/repositories/${workspace}/${repository}/pullrequests/${pullRequestId}/diffstat`;
|
const apiPath = `/repositories/${workspace}/${repository}/pullrequests/${pullRequestId}/diffstat`;
|
||||||
const response = await this.apiClient.makeRequest<any>('get', apiPath, undefined, {
|
const response = await this.apiClient.makeRequest<any>('get', apiPath, undefined, {
|
||||||
params: { pagelen: 100 }
|
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];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -40,7 +40,7 @@ class BitbucketMCPServer {
|
||||||
this.server = new Server(
|
this.server = new Server(
|
||||||
{
|
{
|
||||||
name: 'bitbucket-mcp-server',
|
name: 'bitbucket-mcp-server',
|
||||||
version: '0.7.0',
|
version: '0.9.0',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
capabilities: {
|
capabilities: {
|
||||||
|
|
|
@ -140,7 +140,7 @@ export const toolDefinitions = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'add_comment',
|
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: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
|
@ -158,25 +158,58 @@ export const toolDefinitions = [
|
||||||
},
|
},
|
||||||
comment_text: {
|
comment_text: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
description: 'Comment text',
|
description: 'The main comment text. For suggestions, this is the explanation before the code suggestion.',
|
||||||
},
|
},
|
||||||
parent_comment_id: {
|
parent_comment_id: {
|
||||||
type: 'number',
|
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: {
|
file_path: {
|
||||||
type: 'string',
|
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: {
|
line_number: {
|
||||||
type: '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: {
|
line_type: {
|
||||||
type: 'string',
|
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'],
|
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'],
|
required: ['workspace', 'repository', 'pull_request_id', 'comment_text'],
|
||||||
},
|
},
|
||||||
|
|
|
@ -352,3 +352,35 @@ export interface FormattedFileChange {
|
||||||
status: 'added' | 'modified' | 'removed' | 'renamed';
|
status: 'added' | 'modified' | 'removed' | 'renamed';
|
||||||
old_path?: string;
|
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;
|
||||||
|
}
|
||||||
|
|
|
@ -82,6 +82,14 @@ export const isAddCommentArgs = (
|
||||||
file_path?: string;
|
file_path?: string;
|
||||||
line_number?: number;
|
line_number?: number;
|
||||||
line_type?: 'ADDED' | 'REMOVED' | 'CONTEXT';
|
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' &&
|
typeof args === 'object' &&
|
||||||
args !== null &&
|
args !== null &&
|
||||||
|
@ -92,7 +100,16 @@ export const isAddCommentArgs = (
|
||||||
(args.parent_comment_id === undefined || typeof args.parent_comment_id === 'number') &&
|
(args.parent_comment_id === undefined || typeof args.parent_comment_id === 'number') &&
|
||||||
(args.file_path === undefined || typeof args.file_path === 'string') &&
|
(args.file_path === undefined || typeof args.file_path === 'string') &&
|
||||||
(args.line_number === undefined || typeof args.line_number === 'number') &&
|
(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 = (
|
export const isMergePullRequestArgs = (
|
||||||
args: any
|
args: any
|
||||||
|
|
22
src/utils/suggestion-formatter.ts
Normal file
22
src/utils/suggestion-formatter.ts
Normal file
|
@ -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}
|
||||||
|
\`\`\``;
|
||||||
|
}
|
Loading…
Reference in a new issue