fix: Fix GET request parameter passing for all handlers

- Fixed API parameter passing for GET requests (was passing config as third parameter instead of fourth)
- Branch filtering now works correctly with filterText parameter for Bitbucket Server
- All GET requests with query parameters now functioning properly across all handlers
- Updated CHANGELOG.md to reflect the fixes
This commit is contained in:
pdogra1299 2025-06-05 14:49:51 +05:30
parent c72336b460
commit 75c4192815
12 changed files with 1948 additions and 1828 deletions

View file

@ -5,6 +5,36 @@ 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.3.0] - 2025-01-06
### Added
- **Enhanced merge commit details in `get_pull_request`**:
- Added `merge_commit_hash` field for both Cloud and Server
- Added `merged_by` field showing who performed the merge
- Added `merged_at` timestamp for when the merge occurred
- Added `merge_commit_message` with the merge commit message
- For Bitbucket Server: Fetches merge details from activities API when PR is merged
- For Bitbucket Cloud: Extracts merge information from existing response fields
### Changed
- **Major code refactoring for better maintainability**:
- Split monolithic `index.ts` into modular architecture
- Created separate handler classes for different tool categories:
- `PullRequestHandlers` for PR lifecycle operations
- `BranchHandlers` for branch management
- `ReviewHandlers` for code review tools
- Extracted types into dedicated files (`types/bitbucket.ts`, `types/guards.ts`)
- Created utility modules (`utils/api-client.ts`, `utils/formatters.ts`)
- Centralized tool definitions in `tools/definitions.ts`
- Improved error handling and API client abstraction
- Better separation of concerns between Cloud and Server implementations
### Fixed
- Improved handling of merge commit information retrieval failures
- Fixed API parameter passing for GET requests across all handlers (was passing config as third parameter instead of fourth)
- Updated Bitbucket Server branch listing to use `/rest/api/latest/` endpoint with proper parameters
- Branch filtering now works correctly with the `filterText` parameter for Bitbucket Server
## [0.2.0] - 2025-06-04
### Added

View file

@ -174,6 +174,11 @@ Returns detailed information about the pull request including:
- Source and destination branches
- Approval status
- Links to web UI and diff
- **Merge commit details** (when PR is merged):
- `merge_commit_hash`: The hash of the merge commit
- `merged_by`: Who performed the merge
- `merged_at`: When the merge occurred
- `merge_commit_message`: The merge commit message
- And more...
### List Pull Requests

View file

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

View file

@ -0,0 +1,182 @@
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
import { BitbucketApiClient } from '../utils/api-client.js';
import {
isListBranchesArgs,
isDeleteBranchArgs
} from '../types/guards.js';
export class BranchHandlers {
constructor(
private apiClient: BitbucketApiClient,
private baseUrl: string
) {}
async handleListBranches(args: any) {
if (!isListBranchesArgs(args)) {
throw new McpError(
ErrorCode.InvalidParams,
'Invalid arguments for list_branches'
);
}
const { workspace, repository, filter, limit = 25, start = 0 } = args;
try {
let apiPath: string;
let params: any = {};
if (this.apiClient.getIsServer()) {
// Bitbucket Server API - using latest version for better filtering support
apiPath = `/rest/api/latest/projects/${workspace}/repos/${repository}/branches`;
params = {
limit,
start,
details: true,
orderBy: 'MODIFICATION'
};
if (filter) {
params.filterText = filter;
}
} else {
// Bitbucket Cloud API
apiPath = `/repositories/${workspace}/${repository}/refs/branches`;
params = {
pagelen: limit,
page: Math.floor(start / limit) + 1,
};
if (filter) {
params.q = `name ~ "${filter}"`;
}
}
const response = await this.apiClient.makeRequest<any>('get', apiPath, undefined, { params });
// Format the response
let branches: any[] = [];
let totalCount = 0;
let nextPageStart = null;
if (this.apiClient.getIsServer()) {
// Bitbucket Server response
branches = (response.values || []).map((branch: any) => ({
name: branch.displayId,
id: branch.id,
latest_commit: branch.latestCommit,
is_default: branch.isDefault || false
}));
totalCount = response.size || 0;
if (!response.isLastPage && response.nextPageStart !== undefined) {
nextPageStart = response.nextPageStart;
}
} else {
// Bitbucket Cloud response
branches = (response.values || []).map((branch: any) => ({
name: branch.name,
target: branch.target.hash,
is_default: branch.name === 'main' || branch.name === 'master'
}));
totalCount = response.size || 0;
if (response.next) {
nextPageStart = start + limit;
}
}
return {
content: [
{
type: 'text',
text: JSON.stringify({
branches,
total_count: totalCount,
start,
limit,
has_more: nextPageStart !== null,
next_start: nextPageStart
}, null, 2),
},
],
};
} catch (error) {
return this.apiClient.handleApiError(error, `listing branches in ${workspace}/${repository}`);
}
}
async handleDeleteBranch(args: any) {
if (!isDeleteBranchArgs(args)) {
throw new McpError(
ErrorCode.InvalidParams,
'Invalid arguments for delete_branch'
);
}
const { workspace, repository, branch_name, force } = args;
try {
let apiPath: string;
if (this.apiClient.getIsServer()) {
// First, we need to get the branch details to find the latest commit
const branchesPath = `/rest/api/latest/projects/${workspace}/repos/${repository}/branches`;
const branchesResponse = await this.apiClient.makeRequest<any>('get', branchesPath, undefined, {
params: {
filterText: branch_name,
limit: 100
}
});
// Find the exact branch
const branch = branchesResponse.values?.find((b: any) => b.displayId === branch_name);
if (!branch) {
throw new Error(`Branch '${branch_name}' not found`);
}
// Now delete using branch-utils endpoint with correct format
apiPath = `/rest/branch-utils/latest/projects/${workspace}/repos/${repository}/branches`;
try {
await this.apiClient.makeRequest<any>('delete', apiPath, {
name: branch_name,
endPoint: branch.latestCommit
});
} catch (deleteError: any) {
// If the error is about empty response but status is 204 (No Content), it's successful
if (deleteError.originalError?.response?.status === 204 ||
deleteError.message?.includes('No content to map')) {
// Branch was deleted successfully
} else {
throw deleteError;
}
}
} else {
// Bitbucket Cloud API
apiPath = `/repositories/${workspace}/${repository}/refs/branches/${branch_name}`;
try {
await this.apiClient.makeRequest<any>('delete', apiPath);
} catch (deleteError: any) {
// If the error is about empty response but status is 204 (No Content), it's successful
if (deleteError.originalError?.response?.status === 204 ||
deleteError.message?.includes('No content to map')) {
// Branch was deleted successfully
} else {
throw deleteError;
}
}
}
return {
content: [
{
type: 'text',
text: JSON.stringify({
message: `Branch '${branch_name}' deleted successfully`,
branch: branch_name,
repository: `${workspace}/${repository}`
}, null, 2),
},
],
};
} catch (error) {
return this.apiClient.handleApiError(error, `deleting branch '${branch_name}' in ${workspace}/${repository}`);
}
}
}

View file

@ -0,0 +1,486 @@
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
import { BitbucketApiClient } from '../utils/api-client.js';
import { formatServerResponse, formatCloudResponse } from '../utils/formatters.js';
import {
BitbucketServerPullRequest,
BitbucketCloudPullRequest,
BitbucketServerActivity,
MergeInfo
} from '../types/bitbucket.js';
import {
isGetPullRequestArgs,
isListPullRequestsArgs,
isCreatePullRequestArgs,
isUpdatePullRequestArgs,
isAddCommentArgs,
isMergePullRequestArgs
} from '../types/guards.js';
export class PullRequestHandlers {
constructor(
private apiClient: BitbucketApiClient,
private baseUrl: string,
private username: string
) {}
async handleGetPullRequest(args: any) {
if (!isGetPullRequestArgs(args)) {
throw new McpError(
ErrorCode.InvalidParams,
'Invalid arguments for get_pull_request'
);
}
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}`;
const pr = await this.apiClient.makeRequest<any>('get', apiPath);
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<any>('get', activitiesPath, undefined, {
params: { limit: 100 }
});
const activities = activitiesResponse.values || [];
const mergeActivity = activities.find((a: BitbucketServerActivity) => a.action === 'MERGED');
if (mergeActivity) {
mergeInfo.mergeCommitHash = mergeActivity.commit?.id || null;
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<any>('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);
}
}
// Format the response based on server type
const formattedResponse = this.apiClient.getIsServer()
? formatServerResponse(pr as BitbucketServerPullRequest, mergeInfo, this.baseUrl)
: formatCloudResponse(pr as BitbucketCloudPullRequest);
return {
content: [
{
type: 'text',
text: JSON.stringify(formattedResponse, null, 2),
},
],
};
} catch (error) {
return this.apiClient.handleApiError(error, `getting pull request ${pull_request_id} in ${workspace}/${repository}`);
}
}
async handleListPullRequests(args: any) {
if (!isListPullRequestsArgs(args)) {
throw new McpError(
ErrorCode.InvalidParams,
'Invalid arguments for list_pull_requests'
);
}
const { workspace, repository, state = 'OPEN', author, limit = 25, start = 0 } = args;
try {
let apiPath: string;
let params: any = {};
if (this.apiClient.getIsServer()) {
// Bitbucket Server API
apiPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests`;
params = {
state: state === 'ALL' ? undefined : state,
limit,
start,
};
if (author) {
params['role.1'] = 'AUTHOR';
params['username.1'] = author;
}
} else {
// Bitbucket Cloud API
apiPath = `/repositories/${workspace}/${repository}/pullrequests`;
params = {
state: state === 'ALL' ? undefined : state,
pagelen: limit,
page: Math.floor(start / limit) + 1,
};
if (author) {
params['q'] = `author.username="${author}"`;
}
}
const response = await this.apiClient.makeRequest<any>('get', apiPath, undefined, { params });
// Format the response
let pullRequests: any[] = [];
let totalCount = 0;
let nextPageStart = null;
if (this.apiClient.getIsServer()) {
pullRequests = (response.values || []).map((pr: BitbucketServerPullRequest) =>
formatServerResponse(pr, undefined, this.baseUrl)
);
totalCount = response.size || 0;
if (!response.isLastPage && response.nextPageStart !== undefined) {
nextPageStart = response.nextPageStart;
}
} else {
pullRequests = (response.values || []).map((pr: BitbucketCloudPullRequest) =>
formatCloudResponse(pr)
);
totalCount = response.size || 0;
if (response.next) {
nextPageStart = start + limit;
}
}
return {
content: [
{
type: 'text',
text: JSON.stringify({
pull_requests: pullRequests,
total_count: totalCount,
start,
limit,
has_more: nextPageStart !== null,
next_start: nextPageStart,
}, null, 2),
},
],
};
} catch (error) {
return this.apiClient.handleApiError(error, `listing pull requests in ${workspace}/${repository}`);
}
}
async handleCreatePullRequest(args: any) {
if (!isCreatePullRequestArgs(args)) {
throw new McpError(
ErrorCode.InvalidParams,
'Invalid arguments for create_pull_request'
);
}
const { workspace, repository, title, source_branch, destination_branch, description, reviewers, close_source_branch } = args;
try {
let apiPath: string;
let requestBody: any;
if (this.apiClient.getIsServer()) {
// Bitbucket Server API
apiPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests`;
requestBody = {
title,
description: description || '',
fromRef: {
id: `refs/heads/${source_branch}`,
repository: {
slug: repository,
project: {
key: workspace
}
}
},
toRef: {
id: `refs/heads/${destination_branch}`,
repository: {
slug: repository,
project: {
key: workspace
}
}
},
reviewers: reviewers?.map(r => ({ user: { name: r } })) || []
};
} else {
// Bitbucket Cloud API
apiPath = `/repositories/${workspace}/${repository}/pullrequests`;
requestBody = {
title,
description: description || '',
source: {
branch: {
name: source_branch
}
},
destination: {
branch: {
name: destination_branch
}
},
close_source_branch: close_source_branch || false,
reviewers: reviewers?.map(r => ({ username: r })) || []
};
}
const pr = await this.apiClient.makeRequest<any>('post', apiPath, requestBody);
const formattedResponse = this.apiClient.getIsServer()
? formatServerResponse(pr as BitbucketServerPullRequest, undefined, this.baseUrl)
: formatCloudResponse(pr as BitbucketCloudPullRequest);
return {
content: [
{
type: 'text',
text: JSON.stringify({
message: 'Pull request created successfully',
pull_request: formattedResponse
}, null, 2),
},
],
};
} catch (error) {
return this.apiClient.handleApiError(error, `creating pull request in ${workspace}/${repository}`);
}
}
async handleUpdatePullRequest(args: any) {
if (!isUpdatePullRequestArgs(args)) {
throw new McpError(
ErrorCode.InvalidParams,
'Invalid arguments for update_pull_request'
);
}
const { workspace, repository, pull_request_id, title, description, destination_branch, reviewers } = args;
try {
let apiPath: string;
let requestBody: any = {};
if (this.apiClient.getIsServer()) {
// Bitbucket Server API
apiPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pull_request_id}`;
// First get the current PR to get version number
const currentPr = await this.apiClient.makeRequest<any>('get', apiPath);
requestBody.version = currentPr.version;
if (title !== undefined) requestBody.title = title;
if (description !== undefined) requestBody.description = description;
if (destination_branch !== undefined) {
requestBody.toRef = {
id: `refs/heads/${destination_branch}`,
repository: {
slug: repository,
project: {
key: workspace
}
}
};
}
if (reviewers !== undefined) {
requestBody.reviewers = reviewers.map(r => ({ user: { name: r } }));
}
} else {
// Bitbucket Cloud API
apiPath = `/repositories/${workspace}/${repository}/pullrequests/${pull_request_id}`;
if (title !== undefined) requestBody.title = title;
if (description !== undefined) requestBody.description = description;
if (destination_branch !== undefined) {
requestBody.destination = {
branch: {
name: destination_branch
}
};
}
if (reviewers !== undefined) {
requestBody.reviewers = reviewers.map(r => ({ username: r }));
}
}
const pr = await this.apiClient.makeRequest<any>('put', apiPath, requestBody);
const formattedResponse = this.apiClient.getIsServer()
? formatServerResponse(pr as BitbucketServerPullRequest, undefined, this.baseUrl)
: formatCloudResponse(pr as BitbucketCloudPullRequest);
return {
content: [
{
type: 'text',
text: JSON.stringify({
message: 'Pull request updated successfully',
pull_request: formattedResponse
}, null, 2),
},
],
};
} catch (error) {
return this.apiClient.handleApiError(error, `updating pull request ${pull_request_id} in ${workspace}/${repository}`);
}
}
async handleAddComment(args: any) {
if (!isAddCommentArgs(args)) {
throw new McpError(
ErrorCode.InvalidParams,
'Invalid arguments for add_comment'
);
}
const { workspace, repository, pull_request_id, comment_text, parent_comment_id, file_path, line_number, line_type } = args;
const isInlineComment = file_path !== undefined && line_number !== undefined;
try {
let apiPath: string;
let requestBody: any;
if (this.apiClient.getIsServer()) {
// Bitbucket Server API
apiPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pull_request_id}/comments`;
requestBody = {
text: comment_text
};
if (parent_comment_id !== undefined) {
requestBody.parent = { id: parent_comment_id };
}
if (isInlineComment) {
requestBody.anchor = {
line: line_number,
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
}
};
if (parent_comment_id !== undefined) {
requestBody.parent = { id: parent_comment_id };
}
if (isInlineComment) {
requestBody.inline = {
to: line_number,
path: file_path
};
}
}
const comment = await this.apiClient.makeRequest<any>('post', apiPath, requestBody);
return {
content: [
{
type: 'text',
text: JSON.stringify({
message: isInlineComment ? 'Inline comment added successfully' : 'Comment added successfully',
comment: {
id: comment.id,
text: this.apiClient.getIsServer() ? comment.text : comment.content.raw,
author: this.apiClient.getIsServer() ? comment.author.displayName : comment.user.display_name,
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
}
}, null, 2),
},
],
};
} catch (error) {
return this.apiClient.handleApiError(error, `adding ${isInlineComment ? 'inline ' : ''}comment to pull request ${pull_request_id} in ${workspace}/${repository}`);
}
}
async handleMergePullRequest(args: any) {
if (!isMergePullRequestArgs(args)) {
throw new McpError(
ErrorCode.InvalidParams,
'Invalid arguments for merge_pull_request'
);
}
const { workspace, repository, pull_request_id, merge_strategy, close_source_branch, commit_message } = args;
try {
let apiPath: string;
let requestBody: any = {};
if (this.apiClient.getIsServer()) {
// Bitbucket Server API
apiPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pull_request_id}/merge`;
// Get current PR version
const prPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pull_request_id}`;
const currentPr = await this.apiClient.makeRequest<any>('get', prPath);
requestBody.version = currentPr.version;
if (commit_message) {
requestBody.message = commit_message;
}
} else {
// Bitbucket Cloud API
apiPath = `/repositories/${workspace}/${repository}/pullrequests/${pull_request_id}/merge`;
if (merge_strategy) {
requestBody.merge_strategy = merge_strategy;
}
if (close_source_branch !== undefined) {
requestBody.close_source_branch = close_source_branch;
}
if (commit_message) {
requestBody.message = commit_message;
}
}
const result = await this.apiClient.makeRequest<any>('post', apiPath, requestBody);
return {
content: [
{
type: 'text',
text: JSON.stringify({
message: 'Pull request merged successfully',
merge_commit: this.apiClient.getIsServer() ? result.properties?.mergeCommit : result.merge_commit?.hash,
pull_request_id
}, null, 2),
},
],
};
} catch (error) {
return this.apiClient.handleApiError(error, `merging pull request ${pull_request_id} in ${workspace}/${repository}`);
}
}
}

View file

@ -0,0 +1,236 @@
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
import { BitbucketApiClient } from '../utils/api-client.js';
import {
isGetPullRequestDiffArgs,
isApprovePullRequestArgs,
isRequestChangesArgs
} from '../types/guards.js';
export class ReviewHandlers {
constructor(
private apiClient: BitbucketApiClient,
private username: string
) {}
async handleGetPullRequestDiff(args: any) {
if (!isGetPullRequestDiffArgs(args)) {
throw new McpError(
ErrorCode.InvalidParams,
'Invalid arguments for get_pull_request_diff'
);
}
const { workspace, repository, pull_request_id, context_lines = 3 } = args;
try {
let apiPath: string;
let config: any = {};
if (this.apiClient.getIsServer()) {
// Bitbucket Server API
apiPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pull_request_id}/diff`;
config.params = { contextLines: context_lines };
} else {
// Bitbucket Cloud API
apiPath = `/repositories/${workspace}/${repository}/pullrequests/${pull_request_id}/diff`;
config.params = { context: context_lines };
}
// For diff, we want the raw text response
config.headers = { 'Accept': 'text/plain' };
const diff = await this.apiClient.makeRequest<string>('get', apiPath, undefined, config);
return {
content: [
{
type: 'text',
text: JSON.stringify({
message: 'Pull request diff retrieved successfully',
pull_request_id,
diff: diff
}, null, 2),
},
],
};
} catch (error) {
return this.apiClient.handleApiError(error, `getting diff for pull request ${pull_request_id} in ${workspace}/${repository}`);
}
}
async handleApprovePullRequest(args: any) {
if (!isApprovePullRequestArgs(args)) {
throw new McpError(
ErrorCode.InvalidParams,
'Invalid arguments for approve_pull_request'
);
}
const { workspace, repository, pull_request_id } = args;
try {
let apiPath: string;
if (this.apiClient.getIsServer()) {
// Bitbucket Server API - use participants endpoint
// Convert email format: @ to _ for the API
const username = this.username.replace('@', '_');
apiPath = `/rest/api/latest/projects/${workspace}/repos/${repository}/pull-requests/${pull_request_id}/participants/${username}`;
await this.apiClient.makeRequest<any>('put', apiPath, { status: 'APPROVED' });
} else {
// Bitbucket Cloud API
apiPath = `/repositories/${workspace}/${repository}/pullrequests/${pull_request_id}/approve`;
await this.apiClient.makeRequest<any>('post', apiPath);
}
return {
content: [
{
type: 'text',
text: JSON.stringify({
message: 'Pull request approved successfully',
pull_request_id,
approved_by: this.username
}, null, 2),
},
],
};
} catch (error) {
return this.apiClient.handleApiError(error, `approving pull request ${pull_request_id} in ${workspace}/${repository}`);
}
}
async handleUnapprovePullRequest(args: any) {
if (!isApprovePullRequestArgs(args)) {
throw new McpError(
ErrorCode.InvalidParams,
'Invalid arguments for unapprove_pull_request'
);
}
const { workspace, repository, pull_request_id } = args;
try {
let apiPath: string;
if (this.apiClient.getIsServer()) {
// Bitbucket Server API - use participants endpoint
const username = this.username.replace('@', '_');
apiPath = `/rest/api/latest/projects/${workspace}/repos/${repository}/pull-requests/${pull_request_id}/participants/${username}`;
await this.apiClient.makeRequest<any>('put', apiPath, { status: 'UNAPPROVED' });
} else {
// Bitbucket Cloud API
apiPath = `/repositories/${workspace}/${repository}/pullrequests/${pull_request_id}/approve`;
await this.apiClient.makeRequest<any>('delete', apiPath);
}
return {
content: [
{
type: 'text',
text: JSON.stringify({
message: 'Pull request approval removed successfully',
pull_request_id,
unapproved_by: this.username
}, null, 2),
},
],
};
} catch (error) {
return this.apiClient.handleApiError(error, `removing approval from pull request ${pull_request_id} in ${workspace}/${repository}`);
}
}
async handleRequestChanges(args: any) {
if (!isRequestChangesArgs(args)) {
throw new McpError(
ErrorCode.InvalidParams,
'Invalid arguments for request_changes'
);
}
const { workspace, repository, pull_request_id, comment } = args;
try {
if (this.apiClient.getIsServer()) {
// Bitbucket Server API - use needs-work status
const username = this.username.replace('@', '_');
const apiPath = `/rest/api/latest/projects/${workspace}/repos/${repository}/pull-requests/${pull_request_id}/participants/${username}`;
await this.apiClient.makeRequest<any>('put', apiPath, { status: 'NEEDS_WORK' });
// Add comment if provided
if (comment) {
const commentPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests/${pull_request_id}/comments`;
await this.apiClient.makeRequest<any>('post', commentPath, { text: comment });
}
} else {
// Bitbucket Cloud API - use request-changes status
const apiPath = `/repositories/${workspace}/${repository}/pullrequests/${pull_request_id}/request-changes`;
await this.apiClient.makeRequest<any>('post', apiPath);
// Add comment if provided
if (comment) {
const commentPath = `/repositories/${workspace}/${repository}/pullrequests/${pull_request_id}/comments`;
await this.apiClient.makeRequest<any>('post', commentPath, {
content: { raw: comment }
});
}
}
return {
content: [
{
type: 'text',
text: JSON.stringify({
message: 'Changes requested on pull request',
pull_request_id,
requested_by: this.username,
comment: comment || 'No comment provided'
}, null, 2),
},
],
};
} catch (error) {
return this.apiClient.handleApiError(error, `requesting changes on pull request ${pull_request_id} in ${workspace}/${repository}`);
}
}
async handleRemoveRequestedChanges(args: any) {
if (!isApprovePullRequestArgs(args)) {
throw new McpError(
ErrorCode.InvalidParams,
'Invalid arguments for remove_requested_changes'
);
}
const { workspace, repository, pull_request_id } = args;
try {
if (this.apiClient.getIsServer()) {
// Bitbucket Server API - remove needs-work status
const username = this.username.replace('@', '_');
const apiPath = `/rest/api/latest/projects/${workspace}/repos/${repository}/pull-requests/${pull_request_id}/participants/${username}`;
await this.apiClient.makeRequest<any>('put', apiPath, { status: 'UNAPPROVED' });
} else {
// Bitbucket Cloud API
const apiPath = `/repositories/${workspace}/${repository}/pullrequests/${pull_request_id}/request-changes`;
await this.apiClient.makeRequest<any>('delete', apiPath);
}
return {
content: [
{
type: 'text',
text: JSON.stringify({
message: 'Change request removed from pull request',
pull_request_id,
removed_by: this.username
}, null, 2),
},
],
};
} catch (error) {
return this.apiClient.handleApiError(error, `removing change request from pull request ${pull_request_id} in ${workspace}/${repository}`);
}
}
}

File diff suppressed because it is too large Load diff

393
src/tools/definitions.ts Normal file
View file

@ -0,0 +1,393 @@
export const toolDefinitions = [
{
name: 'get_pull_request',
description: 'Get details of a Bitbucket pull request including merge commit information',
inputSchema: {
type: 'object',
properties: {
workspace: {
type: 'string',
description: 'Bitbucket workspace/project key (e.g., "PROJ")',
},
repository: {
type: 'string',
description: 'Repository slug (e.g., "my-repo")',
},
pull_request_id: {
type: 'number',
description: 'Pull request ID',
},
},
required: ['workspace', 'repository', 'pull_request_id'],
},
},
{
name: 'list_pull_requests',
description: 'List pull requests for a repository with optional filters',
inputSchema: {
type: 'object',
properties: {
workspace: {
type: 'string',
description: 'Bitbucket workspace/project key (e.g., "PROJ")',
},
repository: {
type: 'string',
description: 'Repository slug (e.g., "my-repo")',
},
state: {
type: 'string',
description: 'Filter by PR state: OPEN, MERGED, DECLINED, ALL (default: OPEN)',
enum: ['OPEN', 'MERGED', 'DECLINED', 'ALL'],
},
author: {
type: 'string',
description: 'Filter by author username',
},
limit: {
type: 'number',
description: 'Maximum number of PRs to return (default: 25)',
},
start: {
type: 'number',
description: 'Start index for pagination (default: 0)',
},
},
required: ['workspace', 'repository'],
},
},
{
name: 'create_pull_request',
description: 'Create a new pull request',
inputSchema: {
type: 'object',
properties: {
workspace: {
type: 'string',
description: 'Bitbucket workspace/project key (e.g., "PROJ")',
},
repository: {
type: 'string',
description: 'Repository slug (e.g., "my-repo")',
},
title: {
type: 'string',
description: 'Title of the pull request',
},
source_branch: {
type: 'string',
description: 'Source branch name',
},
destination_branch: {
type: 'string',
description: 'Destination branch name (e.g., "main", "master")',
},
description: {
type: 'string',
description: 'Description of the pull request (optional)',
},
reviewers: {
type: 'array',
items: { type: 'string' },
description: 'Array of reviewer usernames/emails (optional)',
},
close_source_branch: {
type: 'boolean',
description: 'Whether to close source branch after merge (optional, default: false)',
},
},
required: ['workspace', 'repository', 'title', 'source_branch', 'destination_branch'],
},
},
{
name: 'update_pull_request',
description: 'Update an existing pull request',
inputSchema: {
type: 'object',
properties: {
workspace: {
type: 'string',
description: 'Bitbucket workspace/project key (e.g., "PROJ")',
},
repository: {
type: 'string',
description: 'Repository slug (e.g., "my-repo")',
},
pull_request_id: {
type: 'number',
description: 'Pull request ID',
},
title: {
type: 'string',
description: 'New title (optional)',
},
description: {
type: 'string',
description: 'New description (optional)',
},
destination_branch: {
type: 'string',
description: 'New destination branch (optional)',
},
reviewers: {
type: 'array',
items: { type: 'string' },
description: 'New list of reviewer usernames/emails (optional)',
},
},
required: ['workspace', 'repository', 'pull_request_id'],
},
},
{
name: 'add_comment',
description: 'Add a comment to a pull request (general or inline on specific code)',
inputSchema: {
type: 'object',
properties: {
workspace: {
type: 'string',
description: 'Bitbucket workspace/project key (e.g., "PROJ")',
},
repository: {
type: 'string',
description: 'Repository slug (e.g., "my-repo")',
},
pull_request_id: {
type: 'number',
description: 'Pull request ID',
},
comment_text: {
type: 'string',
description: 'Comment text',
},
parent_comment_id: {
type: 'number',
description: 'Parent comment ID for replies (optional)',
},
file_path: {
type: 'string',
description: 'File path for inline comment (optional, e.g., "src/main.js")',
},
line_number: {
type: 'number',
description: 'Line number for inline comment (optional, required with file_path)',
},
line_type: {
type: 'string',
description: 'Type of line for inline comment: ADDED, REMOVED, or CONTEXT (optional, default: CONTEXT)',
enum: ['ADDED', 'REMOVED', 'CONTEXT'],
},
},
required: ['workspace', 'repository', 'pull_request_id', 'comment_text'],
},
},
{
name: 'merge_pull_request',
description: 'Merge a pull request',
inputSchema: {
type: 'object',
properties: {
workspace: {
type: 'string',
description: 'Bitbucket workspace/project key (e.g., "PROJ")',
},
repository: {
type: 'string',
description: 'Repository slug (e.g., "my-repo")',
},
pull_request_id: {
type: 'number',
description: 'Pull request ID',
},
merge_strategy: {
type: 'string',
description: 'Merge strategy: merge-commit, squash, fast-forward (optional)',
enum: ['merge-commit', 'squash', 'fast-forward'],
},
close_source_branch: {
type: 'boolean',
description: 'Whether to close source branch after merge (optional)',
},
commit_message: {
type: 'string',
description: 'Custom merge commit message (optional)',
},
},
required: ['workspace', 'repository', 'pull_request_id'],
},
},
{
name: 'list_branches',
description: 'List branches in a repository',
inputSchema: {
type: 'object',
properties: {
workspace: {
type: 'string',
description: 'Bitbucket workspace/project key (e.g., "PROJ")',
},
repository: {
type: 'string',
description: 'Repository slug (e.g., "my-repo")',
},
filter: {
type: 'string',
description: 'Filter branches by name pattern (optional)',
},
limit: {
type: 'number',
description: 'Maximum number of branches to return (default: 25)',
},
start: {
type: 'number',
description: 'Start index for pagination (default: 0)',
},
},
required: ['workspace', 'repository'],
},
},
{
name: 'delete_branch',
description: 'Delete a branch',
inputSchema: {
type: 'object',
properties: {
workspace: {
type: 'string',
description: 'Bitbucket workspace/project key (e.g., "PROJ")',
},
repository: {
type: 'string',
description: 'Repository slug (e.g., "my-repo")',
},
branch_name: {
type: 'string',
description: 'Branch name to delete',
},
force: {
type: 'boolean',
description: 'Force delete even if branch is not merged (optional, default: false)',
},
},
required: ['workspace', 'repository', 'branch_name'],
},
},
{
name: 'get_pull_request_diff',
description: 'Get the diff/changes for a pull request',
inputSchema: {
type: 'object',
properties: {
workspace: {
type: 'string',
description: 'Bitbucket workspace/project key (e.g., "PROJ")',
},
repository: {
type: 'string',
description: 'Repository slug (e.g., "my-repo")',
},
pull_request_id: {
type: 'number',
description: 'Pull request ID',
},
context_lines: {
type: 'number',
description: 'Number of context lines around changes (optional, default: 3)',
},
},
required: ['workspace', 'repository', 'pull_request_id'],
},
},
{
name: 'approve_pull_request',
description: 'Approve a pull request',
inputSchema: {
type: 'object',
properties: {
workspace: {
type: 'string',
description: 'Bitbucket workspace/project key (e.g., "PROJ")',
},
repository: {
type: 'string',
description: 'Repository slug (e.g., "my-repo")',
},
pull_request_id: {
type: 'number',
description: 'Pull request ID',
},
},
required: ['workspace', 'repository', 'pull_request_id'],
},
},
{
name: 'unapprove_pull_request',
description: 'Remove approval from a pull request',
inputSchema: {
type: 'object',
properties: {
workspace: {
type: 'string',
description: 'Bitbucket workspace/project key (e.g., "PROJ")',
},
repository: {
type: 'string',
description: 'Repository slug (e.g., "my-repo")',
},
pull_request_id: {
type: 'number',
description: 'Pull request ID',
},
},
required: ['workspace', 'repository', 'pull_request_id'],
},
},
{
name: 'request_changes',
description: 'Request changes on a pull request',
inputSchema: {
type: 'object',
properties: {
workspace: {
type: 'string',
description: 'Bitbucket workspace/project key (e.g., "PROJ")',
},
repository: {
type: 'string',
description: 'Repository slug (e.g., "my-repo")',
},
pull_request_id: {
type: 'number',
description: 'Pull request ID',
},
comment: {
type: 'string',
description: 'Comment explaining requested changes (optional)',
},
},
required: ['workspace', 'repository', 'pull_request_id'],
},
},
{
name: 'remove_requested_changes',
description: 'Remove change request from a pull request',
inputSchema: {
type: 'object',
properties: {
workspace: {
type: 'string',
description: 'Bitbucket workspace/project key (e.g., "PROJ")',
},
repository: {
type: 'string',
description: 'Repository slug (e.g., "my-repo")',
},
pull_request_id: {
type: 'number',
description: 'Pull request ID',
},
},
required: ['workspace', 'repository', 'pull_request_id'],
},
},
];

165
src/types/bitbucket.ts Normal file
View file

@ -0,0 +1,165 @@
// Bitbucket Server API response types
export interface BitbucketServerPullRequest {
id: number;
version: number;
title: string;
description?: string;
state: string;
open: boolean;
closed: boolean;
createdDate: number;
updatedDate: number;
fromRef: {
id: string;
displayId: string;
latestCommit: string;
repository: {
slug: string;
name: string;
project: {
key: string;
};
};
};
toRef: {
id: string;
displayId: string;
latestCommit: string;
repository: {
slug: string;
name: string;
project: {
key: string;
};
};
};
locked: boolean;
author: {
user: {
name: string;
emailAddress: string;
displayName: string;
};
role: string;
approved: boolean;
status: string;
};
reviewers: Array<{
user: {
name: string;
emailAddress: string;
displayName: string;
};
role: string;
approved: boolean;
status: string;
}>;
participants: Array<{
user: {
name: string;
emailAddress: string;
displayName: string;
};
role: string;
approved: boolean;
status: string;
}>;
links: {
self: Array<{
href: string;
}>;
};
properties?: {
mergeCommit?: {
id: string;
displayId: string;
};
};
}
// Bitbucket Server Activity types
export interface BitbucketServerActivity {
id: number;
createdDate: number;
user: {
name: string;
emailAddress: string;
displayName: string;
};
action: string;
comment?: any;
commit?: {
id: string;
displayId: string;
message?: string;
};
}
// Bitbucket Cloud API response types
export interface BitbucketCloudPullRequest {
id: number;
title: string;
description: string;
state: string;
author: {
display_name: string;
account_id: string;
};
source: {
branch: {
name: string;
};
repository: {
full_name: string;
};
};
destination: {
branch: {
name: string;
};
repository: {
full_name: string;
};
};
reviewers: Array<{
display_name: string;
account_id: string;
}>;
participants: Array<{
user: {
display_name: string;
account_id: string;
};
role: string;
approved: boolean;
}>;
created_on: string;
updated_on: string;
links: {
html: {
href: string;
};
self: {
href: string;
};
diff: {
href: string;
};
};
merge_commit?: {
hash: string;
};
close_source_branch: boolean;
closed_by?: {
display_name: string;
account_id: string;
};
}
// Merge info type for enhanced PR details
export interface MergeInfo {
mergeCommitHash?: string;
mergedBy?: string;
mergedAt?: string;
mergeCommitMessage?: string;
}

189
src/types/guards.ts Normal file
View file

@ -0,0 +1,189 @@
// Type guards for tool arguments
export const isGetPullRequestArgs = (
args: any
): args is { workspace: string; repository: string; pull_request_id: number } =>
typeof args === 'object' &&
args !== null &&
typeof args.workspace === 'string' &&
typeof args.repository === 'string' &&
typeof args.pull_request_id === 'number';
export const isListPullRequestsArgs = (
args: any
): args is {
workspace: string;
repository: string;
state?: string;
author?: string;
limit?: number;
start?: number;
} =>
typeof args === 'object' &&
args !== null &&
typeof args.workspace === 'string' &&
typeof args.repository === 'string' &&
(args.state === undefined || typeof args.state === 'string') &&
(args.author === undefined || typeof args.author === 'string') &&
(args.limit === undefined || typeof args.limit === 'number') &&
(args.start === undefined || typeof args.start === 'number');
export const isCreatePullRequestArgs = (
args: any
): args is {
workspace: string;
repository: string;
title: string;
source_branch: string;
destination_branch: string;
description?: string;
reviewers?: string[];
close_source_branch?: boolean;
} =>
typeof args === 'object' &&
args !== null &&
typeof args.workspace === 'string' &&
typeof args.repository === 'string' &&
typeof args.title === 'string' &&
typeof args.source_branch === 'string' &&
typeof args.destination_branch === 'string' &&
(args.description === undefined || typeof args.description === 'string') &&
(args.reviewers === undefined || Array.isArray(args.reviewers)) &&
(args.close_source_branch === undefined || typeof args.close_source_branch === 'boolean');
export const isUpdatePullRequestArgs = (
args: any
): args is {
workspace: string;
repository: string;
pull_request_id: number;
title?: string;
description?: string;
destination_branch?: string;
reviewers?: string[];
} =>
typeof args === 'object' &&
args !== null &&
typeof args.workspace === 'string' &&
typeof args.repository === 'string' &&
typeof args.pull_request_id === 'number' &&
(args.title === undefined || typeof args.title === 'string') &&
(args.description === undefined || typeof args.description === 'string') &&
(args.destination_branch === undefined || typeof args.destination_branch === 'string') &&
(args.reviewers === undefined || Array.isArray(args.reviewers));
export const isAddCommentArgs = (
args: any
): args is {
workspace: string;
repository: string;
pull_request_id: number;
comment_text: string;
parent_comment_id?: number;
file_path?: string;
line_number?: number;
line_type?: 'ADDED' | 'REMOVED' | 'CONTEXT';
} =>
typeof args === 'object' &&
args !== null &&
typeof args.workspace === 'string' &&
typeof args.repository === 'string' &&
typeof args.pull_request_id === 'number' &&
typeof args.comment_text === 'string' &&
(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));
export const isMergePullRequestArgs = (
args: any
): args is {
workspace: string;
repository: string;
pull_request_id: number;
merge_strategy?: string;
close_source_branch?: boolean;
commit_message?: string;
} =>
typeof args === 'object' &&
args !== null &&
typeof args.workspace === 'string' &&
typeof args.repository === 'string' &&
typeof args.pull_request_id === 'number' &&
(args.merge_strategy === undefined || typeof args.merge_strategy === 'string') &&
(args.close_source_branch === undefined || typeof args.close_source_branch === 'boolean') &&
(args.commit_message === undefined || typeof args.commit_message === 'string');
export const isDeleteBranchArgs = (
args: any
): args is {
workspace: string;
repository: string;
branch_name: string;
force?: boolean;
} =>
typeof args === 'object' &&
args !== null &&
typeof args.workspace === 'string' &&
typeof args.repository === 'string' &&
typeof args.branch_name === 'string' &&
(args.force === undefined || typeof args.force === 'boolean');
export const isListBranchesArgs = (
args: any
): args is {
workspace: string;
repository: string;
filter?: string;
limit?: number;
start?: number;
} =>
typeof args === 'object' &&
args !== null &&
typeof args.workspace === 'string' &&
typeof args.repository === 'string' &&
(args.filter === undefined || typeof args.filter === 'string') &&
(args.limit === undefined || typeof args.limit === 'number') &&
(args.start === undefined || typeof args.start === 'number');
export const isGetPullRequestDiffArgs = (
args: any
): args is {
workspace: string;
repository: string;
pull_request_id: number;
context_lines?: number;
} =>
typeof args === 'object' &&
args !== null &&
typeof args.workspace === 'string' &&
typeof args.repository === 'string' &&
typeof args.pull_request_id === 'number' &&
(args.context_lines === undefined || typeof args.context_lines === 'number');
export const isApprovePullRequestArgs = (
args: any
): args is {
workspace: string;
repository: string;
pull_request_id: number;
} =>
typeof args === 'object' &&
args !== null &&
typeof args.workspace === 'string' &&
typeof args.repository === 'string' &&
typeof args.pull_request_id === 'number';
export const isRequestChangesArgs = (
args: any
): args is {
workspace: string;
repository: string;
pull_request_id: number;
comment?: string;
} =>
typeof args === 'object' &&
args !== null &&
typeof args.workspace === 'string' &&
typeof args.repository === 'string' &&
typeof args.pull_request_id === 'number' &&
(args.comment === undefined || typeof args.comment === 'string');

138
src/utils/api-client.ts Normal file
View file

@ -0,0 +1,138 @@
import axios, { AxiosInstance, AxiosError } from 'axios';
export interface ApiError {
status?: number;
message: string;
isAxiosError: boolean;
originalError?: AxiosError;
}
export class BitbucketApiClient {
private axiosInstance: AxiosInstance;
private isServer: boolean;
constructor(
baseURL: string,
username: string,
password?: string,
token?: string
) {
this.isServer = !!token;
const axiosConfig: any = {
baseURL,
headers: {
'Content-Type': 'application/json',
},
};
// Use token auth for Bitbucket Server, basic auth for Cloud
if (token) {
// Bitbucket Server uses Bearer token
axiosConfig.headers['Authorization'] = `Bearer ${token}`;
} else {
// Bitbucket Cloud uses basic auth with app password
axiosConfig.auth = {
username,
password,
};
}
this.axiosInstance = axios.create(axiosConfig);
}
async makeRequest<T>(
method: 'get' | 'post' | 'put' | 'delete',
path: string,
data?: any,
config?: any
): Promise<T> {
try {
let response;
if (method === 'get') {
// For GET, config is the second parameter
response = await this.axiosInstance[method](path, config || {});
} else if (method === 'delete') {
// For DELETE, we might need to pass data in config
if (data) {
response = await this.axiosInstance[method](path, { ...config, data });
} else {
response = await this.axiosInstance[method](path, config || {});
}
} else {
// For POST and PUT, data is second, config is third
response = await this.axiosInstance[method](path, data, config);
}
return response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
const status = error.response?.status;
const message = error.response?.data?.errors?.[0]?.message ||
error.response?.data?.error?.message ||
error.response?.data?.message ||
error.message;
throw {
status,
message,
isAxiosError: true,
originalError: error
} as ApiError;
}
throw error;
}
}
handleApiError(error: any, context: string) {
if (error.isAxiosError) {
const { status, message } = error as ApiError;
if (status === 404) {
return {
content: [
{
type: 'text',
text: `Not found: ${context}`,
},
],
isError: true,
};
} else if (status === 401) {
return {
content: [
{
type: 'text',
text: `Authentication failed. Please check your ${this.isServer ? 'BITBUCKET_TOKEN' : 'BITBUCKET_USERNAME and BITBUCKET_APP_PASSWORD'}`,
},
],
isError: true,
};
} else if (status === 403) {
return {
content: [
{
type: 'text',
text: `Permission denied: ${context}. Ensure your credentials have the necessary permissions.`,
},
],
isError: true,
};
}
return {
content: [
{
type: 'text',
text: `Bitbucket API error: ${message}`,
},
],
isError: true,
};
}
throw error;
}
getIsServer(): boolean {
return this.isServer;
}
}

76
src/utils/formatters.ts Normal file
View file

@ -0,0 +1,76 @@
import { BitbucketServerPullRequest, BitbucketCloudPullRequest, MergeInfo } from '../types/bitbucket.js';
export function formatServerResponse(
pr: BitbucketServerPullRequest,
mergeInfo?: MergeInfo,
baseUrl?: string
): any {
const webUrl = `${baseUrl}/projects/${pr.toRef.repository.project.key}/repos/${pr.toRef.repository.slug}/pull-requests/${pr.id}`;
return {
id: pr.id,
title: pr.title,
description: pr.description || 'No description provided',
state: pr.state,
is_open: pr.open,
is_closed: pr.closed,
author: pr.author.user.displayName,
author_username: pr.author.user.name,
author_email: pr.author.user.emailAddress,
source_branch: pr.fromRef.displayId,
destination_branch: pr.toRef.displayId,
source_commit: pr.fromRef.latestCommit,
destination_commit: pr.toRef.latestCommit,
reviewers: pr.reviewers.map(r => ({
name: r.user.displayName,
approved: r.approved,
status: r.status,
})),
participants: pr.participants.map(p => ({
name: p.user.displayName,
role: p.role,
approved: p.approved,
status: p.status,
})),
created_on: new Date(pr.createdDate).toLocaleString(),
updated_on: new Date(pr.updatedDate).toLocaleString(),
web_url: webUrl,
api_url: pr.links.self[0]?.href || '',
is_locked: pr.locked,
// Add merge commit details
is_merged: pr.state === 'MERGED',
merge_commit_hash: mergeInfo?.mergeCommitHash || pr.properties?.mergeCommit?.id || null,
merged_by: mergeInfo?.mergedBy || null,
merged_at: mergeInfo?.mergedAt || null,
merge_commit_message: mergeInfo?.mergeCommitMessage || null,
};
}
export function formatCloudResponse(pr: BitbucketCloudPullRequest): any {
return {
id: pr.id,
title: pr.title,
description: pr.description || 'No description provided',
state: pr.state,
author: pr.author.display_name,
source_branch: pr.source.branch.name,
destination_branch: pr.destination.branch.name,
reviewers: pr.reviewers.map(r => r.display_name),
participants: pr.participants.map(p => ({
name: p.user.display_name,
role: p.role,
approved: p.approved,
})),
created_on: new Date(pr.created_on).toLocaleString(),
updated_on: new Date(pr.updated_on).toLocaleString(),
web_url: pr.links.html.href,
api_url: pr.links.self.href,
diff_url: pr.links.diff.href,
is_merged: pr.state === 'MERGED',
merge_commit_hash: pr.merge_commit?.hash || null,
merged_by: pr.closed_by?.display_name || null,
merged_at: pr.state === 'MERGED' ? pr.updated_on : null,
merge_commit_message: null, // Would need additional API call to get this
close_source_branch: pr.close_source_branch,
};
}