feat: Add support for nested comment replies in Bitbucket Server (v0.6.1)

- Enhanced FormattedComment interface with replies field for nested threads
- Updated comment fetching to recursively process nested structure
- Fixed comment counting to include all nested replies
- Updated documentation to explain reply handling differences
- Maintained backward compatibility with existing tools
This commit is contained in:
pdogra1299 2025-06-26 18:42:08 +05:30
parent c6d5b0a6f5
commit 793355bdaa
6 changed files with 420 additions and 7 deletions

View file

@ -5,6 +5,46 @@ 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.6.1] - 2025-01-26
### Added
- Support for nested comment replies in Bitbucket Server
- Added `replies` field to `FormattedComment` interface to support nested comment threads
- Comments now include nested replies that are still relevant (not orphaned or resolved)
- Total and active comment counts now include nested replies
### Changed
- Updated comment fetching logic to handle Bitbucket Server's nested comment structure
- Server uses `comments` array inside each comment object for replies
- Cloud continues to use `parent` field for reply relationships
- Improved comment filtering to exclude orphaned inline comments when code has changed
### Fixed
- Fixed missing comment replies in PR details - replies are now properly included in the response
## [0.6.0] - 2025-01-26
### Added
- **Enhanced `get_pull_request` with active comments and file changes**:
- Fetches and displays active (unresolved) comments that need attention
- Shows up to 20 most recent active comments with:
- Comment text, author, and creation date
- Inline comment details (file path and line number)
- Comment state (OPEN/RESOLVED for Server)
- Provides comment counts:
- `active_comment_count`: Total unresolved comments
- `total_comment_count`: Total comments including resolved
- Includes file change statistics:
- List of all modified files with lines added/removed
- File status (added, modified, removed, renamed)
- Summary statistics (total files, lines added/removed)
- Added new TypeScript interfaces for comments and file changes
- Added `FormattedComment` and `FormattedFileChange` types for consistent response format
### Changed
- Modified `handleGetPullRequest` to make parallel API calls for better performance
- Enhanced error handling to gracefully continue if comment/file fetching fails
## [0.5.0] - 2025-01-21
### Added

View file

@ -188,6 +188,25 @@ Returns detailed information about the pull request including:
- `merged_by`: Who performed the merge
- `merged_at`: When the merge occurred
- `merge_commit_message`: The merge commit message
- **Active comments with nested replies** (unresolved comments that need attention):
- `active_comments`: Array of active comments (up to 20 most recent top-level comments)
- Comment text and author
- Creation date
- Whether it's an inline comment (with file path and line number)
- **Nested replies** (for Bitbucket Server):
- `replies`: Array of reply comments with same structure
- Replies can be nested multiple levels deep
- **Parent reference** (for Bitbucket Cloud):
- `parent_id`: ID of the parent comment for replies
- `active_comment_count`: Total count of unresolved comments (including nested replies)
- `total_comment_count`: Total count of all comments (including resolved and replies)
- **File changes**:
- `file_changes`: Array of all files modified in the PR
- File path
- Status (added, modified, removed, or renamed)
- Old path (for renamed files)
- `file_changes_summary`: Summary statistics
- Total files changed
- And more...
### List Pull Requests
@ -252,7 +271,7 @@ Returns a paginated list of pull requests with:
### Add Comment
Add general comments or inline comments on specific lines of code:
Add general comments, reply to existing comments, or add inline comments on specific lines of code:
```typescript
// General comment
@ -262,8 +281,19 @@ Add general comments or inline comments on specific lines of code:
"workspace": "PROJ",
"repository": "my-repo",
"pull_request_id": 123,
"comment_text": "Great work! Just one small suggestion...",
"parent_comment_id": 456 // Optional - for replies
"comment_text": "Great work! Just one small suggestion..."
}
}
// Reply to an existing comment
{
"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
}
}
@ -282,6 +312,13 @@ Add general comments or inline comments on specific lines of code:
}
```
**Note on comment replies:**
- Use `parent_comment_id` to reply to any comment (general or inline)
- In `get_pull_request` responses:
- Bitbucket Server shows replies nested in a `replies` array
- Bitbucket Cloud shows a `parent_id` field for reply comments
- You can reply to replies, creating nested conversations
**Note on inline comments:**
- `file_path`: The path to the file as shown in the diff
- `line_number`: The line number as shown in the diff

View file

@ -1,6 +1,6 @@
{
"name": "@nexus2520/bitbucket-mcp-server",
"version": "0.5.0",
"version": "0.6.1",
"description": "MCP server for Bitbucket API integration - supports both Cloud and Server",
"type": "module",
"main": "./build/index.js",

View file

@ -5,7 +5,11 @@ import {
BitbucketServerPullRequest,
BitbucketCloudPullRequest,
BitbucketServerActivity,
MergeInfo
MergeInfo,
BitbucketCloudComment,
BitbucketCloudFileChange,
FormattedComment,
FormattedFileChange
} from '../types/bitbucket.js';
import {
isGetPullRequestArgs,
@ -78,16 +82,49 @@ export class PullRequestHandlers {
}
}
// Fetch comments and file changes in parallel
let comments: FormattedComment[] = [];
let activeCommentCount = 0;
let totalCommentCount = 0;
let fileChanges: FormattedFileChange[] = [];
let fileChangesSummary: any = null;
try {
const [commentsResult, fileChangesResult] = await Promise.all([
this.fetchPullRequestComments(workspace, repository, pull_request_id),
this.fetchPullRequestFileChanges(workspace, repository, pull_request_id)
]);
comments = commentsResult.comments;
activeCommentCount = commentsResult.activeCount;
totalCommentCount = commentsResult.totalCount;
fileChanges = fileChangesResult.fileChanges;
fileChangesSummary = fileChangesResult.summary;
} catch (error) {
// 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,
active_comment_count: activeCommentCount,
total_comment_count: totalCommentCount,
file_changes: fileChanges,
file_changes_summary: fileChangesSummary
};
return {
content: [
{
type: 'text',
text: JSON.stringify(formattedResponse, null, 2),
text: JSON.stringify(enhancedResponse, null, 2),
},
],
};
@ -483,4 +520,213 @@ export class PullRequestHandlers {
return this.apiClient.handleApiError(error, `merging pull request ${pull_request_id} in ${workspace}/${repository}`);
}
}
private async fetchPullRequestComments(
workspace: string,
repository: string,
pullRequestId: number
): Promise<{ comments: FormattedComment[]; activeCount: number; totalCount: number }> {
try {
let comments: FormattedComment[] = [];
let activeCount = 0;
let totalCount = 0;
if (this.apiClient.getIsServer()) {
// Helper function to process nested comments recursively
const processNestedComments = (comment: any, anchor: any): FormattedComment => {
const formattedComment: FormattedComment = {
id: comment.id,
author: comment.author.displayName,
text: comment.text,
created_on: new Date(comment.createdDate).toISOString(),
is_inline: !!anchor,
file_path: anchor?.path,
line_number: anchor?.line,
state: comment.state
};
// 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;
})
.map((reply: any) => processNestedComments(reply, anchor));
}
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) {
count += comment.comments.reduce((sum: number, reply: any) => sum + countAllComments(reply), 0);
}
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);
}
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<any>('get', apiPath, undefined, {
params: { limit: 1000 }
});
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<any>('get', apiPath, undefined, {
params: { pagelen: 100 }
});
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);
activeCount = allComments.filter((c: BitbucketCloudComment) => !c.deleted && !c.resolved).length;
comments = activeComments.map((c: BitbucketCloudComment) => ({
id: c.id,
author: c.user.display_name,
text: c.content.raw,
created_on: c.created_on,
is_inline: !!c.inline,
file_path: c.inline?.path,
line_number: c.inline?.to
}));
}
return { comments, activeCount, totalCount };
} catch (error) {
console.error('Failed to fetch comments:', error);
return { comments: [], activeCount: 0, totalCount: 0 };
}
}
private async fetchPullRequestFileChanges(
workspace: string,
repository: string,
pullRequestId: number
): Promise<{ fileChanges: FormattedFileChange[]; summary: any }> {
try {
let fileChanges: FormattedFileChange[] = [];
let totalLinesAdded = 0;
let totalLinesRemoved = 0;
if (this.apiClient.getIsServer()) {
// 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<any>('get', apiPath, undefined, {
params: { limit: 1000 }
});
const changes = response.values || [];
fileChanges = changes.map((change: any) => {
let status: 'added' | 'modified' | 'removed' | 'renamed' = 'modified';
if (change.type === 'ADD') status = 'added';
else if (change.type === 'DELETE') status = 'removed';
else if (change.type === 'MOVE' || change.type === 'RENAME') status = 'renamed';
return {
path: change.path.toString,
status,
old_path: change.srcPath?.toString
};
});
} else {
// Bitbucket Cloud API - use diffstat endpoint (has line statistics)
const apiPath = `/repositories/${workspace}/${repository}/pullrequests/${pullRequestId}/diffstat`;
const response = await this.apiClient.makeRequest<any>('get', apiPath, undefined, {
params: { pagelen: 100 }
});
const diffstats = response.values || [];
fileChanges = diffstats.map((stat: BitbucketCloudFileChange) => {
totalLinesAdded += stat.lines_added;
totalLinesRemoved += stat.lines_removed;
return {
path: stat.path,
status: stat.type,
old_path: stat.old?.path
};
});
}
const summary = {
total_files: fileChanges.length
};
return { fileChanges, summary };
} catch (error) {
console.error('Failed to fetch file changes:', error);
return {
fileChanges: [],
summary: {
total_files: 0
}
};
}
}
}

View file

@ -40,7 +40,7 @@ class BitbucketMCPServer {
this.server = new Server(
{
name: 'bitbucket-mcp-server',
version: '0.5.0',
version: '0.6.1',
},
{
capabilities: {

View file

@ -262,3 +262,93 @@ export interface MergeInfo {
mergedAt?: string;
mergeCommitMessage?: string;
}
// Comment types
export interface BitbucketServerComment {
id: number;
version: number;
text: string;
author: {
name: string;
emailAddress: string;
displayName: string;
};
createdDate: number;
updatedDate: number;
state?: 'OPEN' | 'RESOLVED';
anchor?: {
line: number;
lineType: string;
fileType: string;
path: string;
};
}
export interface BitbucketCloudComment {
id: number;
content: {
raw: string;
markup: string;
html: string;
};
user: {
display_name: string;
account_id: string;
};
created_on: string;
updated_on: string;
deleted?: boolean;
resolved?: boolean;
inline?: {
to: number;
from?: number;
path: string;
};
}
// File change types
export interface BitbucketServerFileChange {
path: {
toString: string;
};
executable: boolean;
percentUnchanged: number;
type: string;
nodeType: string;
srcPath?: {
toString: string;
};
linesAdded?: number;
linesRemoved?: number;
}
export interface BitbucketCloudFileChange {
path: string;
type: 'added' | 'modified' | 'removed' | 'renamed';
lines_added: number;
lines_removed: number;
old?: {
path: string;
};
}
// Formatted comment type for response
export interface FormattedComment {
id: number;
author: string;
text: string;
created_on: string;
is_inline: boolean;
file_path?: string;
line_number?: number;
state?: 'OPEN' | 'RESOLVED';
parent_id?: number; // For Bitbucket Cloud style replies
replies?: FormattedComment[]; // For Bitbucket Server nested replies
}
// Formatted file change type for response
export interface FormattedFileChange {
path: string;
status: 'added' | 'modified' | 'removed' | 'renamed';
old_path?: string;
}