diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f88939..19c0b5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 44e0ad7..e85bdfc 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/package.json b/package.json index a1df7a6..27d35b4 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/handlers/pull-request-handlers.ts b/src/handlers/pull-request-handlers.ts index daf5334..9682fa4 100644 --- a/src/handlers/pull-request-handlers.ts +++ b/src/handlers/pull-request-handlers.ts @@ -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('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('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('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('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 + } + }; + } + } } diff --git a/src/index.ts b/src/index.ts index 47d163c..ca997bc 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.5.0', + version: '0.6.1', }, { capabilities: { diff --git a/src/types/bitbucket.ts b/src/types/bitbucket.ts index 72ae20c..183655d 100644 --- a/src/types/bitbucket.ts +++ b/src/types/bitbucket.ts @@ -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; +}