feat: Add get_branch tool for comprehensive branch information
- Added new tool to get detailed branch information including associated PRs - Supports both Bitbucket Server and Cloud APIs - Returns branch details, open PRs, optionally merged PRs, and statistics - Useful for checking PR status before branch deletion and branch management - Added TypeScript interfaces for branch responses - Added comprehensive documentation in README
This commit is contained in:
parent
75c4192815
commit
5d38645127
6 changed files with 340 additions and 1 deletions
44
README.md
44
README.md
|
@ -18,6 +18,11 @@ An MCP (Model Context Protocol) server that provides tools for interacting with
|
|||
- `merge_pull_request` - Merge pull requests with various strategies
|
||||
- `delete_branch` - Delete branches after merge
|
||||
|
||||
#### Branch Management Tools
|
||||
- `list_branches` - List branches with filtering and pagination
|
||||
- `delete_branch` - Delete branches (with protection checks)
|
||||
- `get_branch` - Get detailed branch information including associated PRs
|
||||
|
||||
#### Code Review Tools
|
||||
- `get_pull_request_diff` - Get the diff/changes for a pull request
|
||||
- `approve_pull_request` - Approve a pull request
|
||||
|
@ -334,6 +339,45 @@ Returns a paginated list of branches with:
|
|||
|
||||
**Note**: Branch deletion requires appropriate permissions. The branch will be permanently deleted.
|
||||
|
||||
### Get Branch
|
||||
|
||||
```typescript
|
||||
{
|
||||
"tool": "get_branch",
|
||||
"arguments": {
|
||||
"workspace": "PROJ",
|
||||
"repository": "my-repo",
|
||||
"branch_name": "feature/new-feature",
|
||||
"include_merged_prs": false // Optional (default: false)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Returns comprehensive branch information including:
|
||||
- Branch details:
|
||||
- Name and ID
|
||||
- Latest commit (hash, message, author, date)
|
||||
- Default branch indicator
|
||||
- Open pull requests from this branch:
|
||||
- PR title and ID
|
||||
- Destination branch
|
||||
- Author and reviewers
|
||||
- Approval status (approved by, changes requested by, pending)
|
||||
- PR URL
|
||||
- Merged pull requests (if `include_merged_prs` is true):
|
||||
- PR title and ID
|
||||
- Merge date and who merged it
|
||||
- Statistics:
|
||||
- Total open PRs count
|
||||
- Total merged PRs count
|
||||
- Days since last commit
|
||||
|
||||
This tool is particularly useful for:
|
||||
- Checking if a branch has open PRs before deletion
|
||||
- Getting an overview of branch activity
|
||||
- Understanding PR review status
|
||||
- Identifying stale branches
|
||||
|
||||
### Get Pull Request Diff
|
||||
|
||||
```typescript
|
||||
|
|
|
@ -2,8 +2,10 @@ import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
|||
import { BitbucketApiClient } from '../utils/api-client.js';
|
||||
import {
|
||||
isListBranchesArgs,
|
||||
isDeleteBranchArgs
|
||||
isDeleteBranchArgs,
|
||||
isGetBranchArgs
|
||||
} from '../types/guards.js';
|
||||
import { BitbucketServerBranch, BitbucketCloudBranch } from '../types/bitbucket.js';
|
||||
|
||||
export class BranchHandlers {
|
||||
constructor(
|
||||
|
@ -179,4 +181,215 @@ export class BranchHandlers {
|
|||
return this.apiClient.handleApiError(error, `deleting branch '${branch_name}' in ${workspace}/${repository}`);
|
||||
}
|
||||
}
|
||||
|
||||
async handleGetBranch(args: any) {
|
||||
if (!isGetBranchArgs(args)) {
|
||||
throw new McpError(
|
||||
ErrorCode.InvalidParams,
|
||||
'Invalid arguments for get_branch'
|
||||
);
|
||||
}
|
||||
|
||||
const { workspace, repository, branch_name, include_merged_prs = false } = args;
|
||||
|
||||
try {
|
||||
// Step 1: Get branch details
|
||||
let branchInfo: any;
|
||||
let branchCommitInfo: any = {};
|
||||
|
||||
if (this.apiClient.getIsServer()) {
|
||||
// Bitbucket Server - get branch details
|
||||
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,
|
||||
details: true
|
||||
}
|
||||
});
|
||||
|
||||
// Find the exact branch
|
||||
const branch = branchesResponse.values?.find((b: BitbucketServerBranch) => b.displayId === branch_name);
|
||||
if (!branch) {
|
||||
throw new Error(`Branch '${branch_name}' not found`);
|
||||
}
|
||||
|
||||
branchInfo = {
|
||||
name: branch.displayId,
|
||||
id: branch.id,
|
||||
latest_commit: {
|
||||
id: branch.latestCommit,
|
||||
message: branch.metadata?.['com.atlassian.bitbucket.server.bitbucket-branch:latest-commit-metadata']?.message || null,
|
||||
author: branch.metadata?.['com.atlassian.bitbucket.server.bitbucket-branch:latest-commit-metadata']?.author || null,
|
||||
date: branch.metadata?.['com.atlassian.bitbucket.server.bitbucket-branch:latest-commit-metadata']?.authorTimestamp
|
||||
? new Date(branch.metadata['com.atlassian.bitbucket.server.bitbucket-branch:latest-commit-metadata'].authorTimestamp).toISOString()
|
||||
: null
|
||||
},
|
||||
is_default: branch.isDefault || false
|
||||
};
|
||||
} else {
|
||||
// Bitbucket Cloud - get branch details
|
||||
const branchPath = `/repositories/${workspace}/${repository}/refs/branches/${encodeURIComponent(branch_name)}`;
|
||||
const branch = await this.apiClient.makeRequest<BitbucketCloudBranch>('get', branchPath);
|
||||
|
||||
branchInfo = {
|
||||
name: branch.name,
|
||||
id: `refs/heads/${branch.name}`,
|
||||
latest_commit: {
|
||||
id: branch.target.hash,
|
||||
message: branch.target.message,
|
||||
author: branch.target.author.user?.display_name || branch.target.author.raw,
|
||||
date: branch.target.date
|
||||
},
|
||||
is_default: false // Will check this with default branch info
|
||||
};
|
||||
|
||||
// Check if this is the default branch
|
||||
try {
|
||||
const repoPath = `/repositories/${workspace}/${repository}`;
|
||||
const repoInfo = await this.apiClient.makeRequest<any>('get', repoPath);
|
||||
branchInfo.is_default = branch.name === repoInfo.mainbranch?.name;
|
||||
} catch (e) {
|
||||
// Ignore error, just assume not default
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Get open PRs from this branch
|
||||
let openPRs: any[] = [];
|
||||
|
||||
if (this.apiClient.getIsServer()) {
|
||||
// Bitbucket Server
|
||||
const prPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests`;
|
||||
const prResponse = await this.apiClient.makeRequest<any>('get', prPath, undefined, {
|
||||
params: {
|
||||
state: 'OPEN',
|
||||
direction: 'OUTGOING',
|
||||
at: `refs/heads/${branch_name}`,
|
||||
limit: 100
|
||||
}
|
||||
});
|
||||
|
||||
openPRs = (prResponse.values || []).map((pr: any) => ({
|
||||
id: pr.id,
|
||||
title: pr.title,
|
||||
destination_branch: pr.toRef.displayId,
|
||||
author: pr.author.user.displayName,
|
||||
created_on: new Date(pr.createdDate).toISOString(),
|
||||
reviewers: pr.reviewers.map((r: any) => r.user.displayName),
|
||||
approval_status: {
|
||||
approved_by: pr.reviewers.filter((r: any) => r.approved).map((r: any) => r.user.displayName),
|
||||
changes_requested_by: pr.reviewers.filter((r: any) => r.status === 'NEEDS_WORK').map((r: any) => r.user.displayName),
|
||||
pending: pr.reviewers.filter((r: any) => !r.approved && r.status !== 'NEEDS_WORK').map((r: any) => r.user.displayName)
|
||||
},
|
||||
url: `${this.baseUrl}/projects/${workspace}/repos/${repository}/pull-requests/${pr.id}`
|
||||
}));
|
||||
} else {
|
||||
// Bitbucket Cloud
|
||||
const prPath = `/repositories/${workspace}/${repository}/pullrequests`;
|
||||
const prResponse = await this.apiClient.makeRequest<any>('get', prPath, undefined, {
|
||||
params: {
|
||||
state: 'OPEN',
|
||||
q: `source.branch.name="${branch_name}"`,
|
||||
pagelen: 50
|
||||
}
|
||||
});
|
||||
|
||||
openPRs = (prResponse.values || []).map((pr: any) => ({
|
||||
id: pr.id,
|
||||
title: pr.title,
|
||||
destination_branch: pr.destination.branch.name,
|
||||
author: pr.author.display_name,
|
||||
created_on: pr.created_on,
|
||||
reviewers: pr.reviewers.map((r: any) => r.display_name),
|
||||
approval_status: {
|
||||
approved_by: pr.participants.filter((p: any) => p.approved).map((p: any) => p.user.display_name),
|
||||
changes_requested_by: [], // Cloud doesn't have explicit "changes requested" status
|
||||
pending: pr.reviewers.filter((r: any) => !pr.participants.find((p: any) => p.user.account_id === r.account_id && p.approved))
|
||||
.map((r: any) => r.display_name)
|
||||
},
|
||||
url: pr.links.html.href
|
||||
}));
|
||||
}
|
||||
|
||||
// Step 3: Optionally get merged PRs
|
||||
let mergedPRs: any[] = [];
|
||||
|
||||
if (include_merged_prs) {
|
||||
if (this.apiClient.getIsServer()) {
|
||||
// Bitbucket Server
|
||||
const mergedPrPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/pull-requests`;
|
||||
const mergedPrResponse = await this.apiClient.makeRequest<any>('get', mergedPrPath, undefined, {
|
||||
params: {
|
||||
state: 'MERGED',
|
||||
direction: 'OUTGOING',
|
||||
at: `refs/heads/${branch_name}`,
|
||||
limit: 25
|
||||
}
|
||||
});
|
||||
|
||||
mergedPRs = (mergedPrResponse.values || []).map((pr: any) => ({
|
||||
id: pr.id,
|
||||
title: pr.title,
|
||||
merged_at: new Date(pr.updatedDate).toISOString(), // Using updated date as merge date
|
||||
merged_by: pr.participants.find((p: any) => p.role === 'PARTICIPANT' && p.approved)?.user.displayName || 'Unknown'
|
||||
}));
|
||||
} else {
|
||||
// Bitbucket Cloud
|
||||
const mergedPrPath = `/repositories/${workspace}/${repository}/pullrequests`;
|
||||
const mergedPrResponse = await this.apiClient.makeRequest<any>('get', mergedPrPath, undefined, {
|
||||
params: {
|
||||
state: 'MERGED',
|
||||
q: `source.branch.name="${branch_name}"`,
|
||||
pagelen: 25
|
||||
}
|
||||
});
|
||||
|
||||
mergedPRs = (mergedPrResponse.values || []).map((pr: any) => ({
|
||||
id: pr.id,
|
||||
title: pr.title,
|
||||
merged_at: pr.updated_on,
|
||||
merged_by: pr.closed_by?.display_name || 'Unknown'
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Calculate statistics
|
||||
const daysSinceLastCommit = branchInfo.latest_commit.date
|
||||
? Math.floor((Date.now() - new Date(branchInfo.latest_commit.date).getTime()) / (1000 * 60 * 60 * 24))
|
||||
: null;
|
||||
|
||||
// Step 5: Format and return combined response
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
branch: branchInfo,
|
||||
open_pull_requests: openPRs,
|
||||
merged_pull_requests: mergedPRs,
|
||||
statistics: {
|
||||
total_open_prs: openPRs.length,
|
||||
total_merged_prs: mergedPRs.length,
|
||||
days_since_last_commit: daysSinceLastCommit
|
||||
}
|
||||
}, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error: any) {
|
||||
// Handle specific not found error
|
||||
if (error.message?.includes('not found')) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Branch '${branch_name}' not found in ${workspace}/${repository}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
return this.apiClient.handleApiError(error, `getting branch '${branch_name}' in ${workspace}/${repository}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -102,6 +102,8 @@ class BitbucketMCPServer {
|
|||
return this.branchHandlers.handleListBranches(request.params.arguments);
|
||||
case 'delete_branch':
|
||||
return this.branchHandlers.handleDeleteBranch(request.params.arguments);
|
||||
case 'get_branch':
|
||||
return this.branchHandlers.handleGetBranch(request.params.arguments);
|
||||
|
||||
// Code Review tools
|
||||
case 'get_pull_request_diff':
|
||||
|
|
|
@ -390,4 +390,30 @@ export const toolDefinitions = [
|
|||
required: ['workspace', 'repository', 'pull_request_id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_branch',
|
||||
description: 'Get detailed information about a branch including associated pull requests',
|
||||
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 get details for',
|
||||
},
|
||||
include_merged_prs: {
|
||||
type: 'boolean',
|
||||
description: 'Include merged PRs from this branch (default: false)',
|
||||
},
|
||||
},
|
||||
required: ['workspace', 'repository', 'branch_name'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
|
@ -95,6 +95,26 @@ export interface BitbucketServerActivity {
|
|||
};
|
||||
}
|
||||
|
||||
// Bitbucket Server Branch types
|
||||
export interface BitbucketServerBranch {
|
||||
id: string;
|
||||
displayId: string;
|
||||
type: string;
|
||||
latestCommit: string;
|
||||
latestChangeset: string;
|
||||
isDefault: boolean;
|
||||
metadata?: {
|
||||
"com.atlassian.bitbucket.server.bitbucket-branch:latest-commit-metadata": {
|
||||
author: {
|
||||
name: string;
|
||||
emailAddress: string;
|
||||
};
|
||||
authorTimestamp: number;
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// Bitbucket Cloud API response types
|
||||
export interface BitbucketCloudPullRequest {
|
||||
id: number;
|
||||
|
@ -156,6 +176,25 @@ export interface BitbucketCloudPullRequest {
|
|||
};
|
||||
}
|
||||
|
||||
// Bitbucket Cloud Branch types
|
||||
export interface BitbucketCloudBranch {
|
||||
name: string;
|
||||
target: {
|
||||
hash: string;
|
||||
type: string;
|
||||
message: string;
|
||||
author: {
|
||||
raw: string;
|
||||
user?: {
|
||||
display_name: string;
|
||||
account_id: string;
|
||||
};
|
||||
};
|
||||
date: string;
|
||||
};
|
||||
type: string;
|
||||
}
|
||||
|
||||
// Merge info type for enhanced PR details
|
||||
export interface MergeInfo {
|
||||
mergeCommitHash?: string;
|
||||
|
|
|
@ -187,3 +187,18 @@ export const isRequestChangesArgs = (
|
|||
typeof args.repository === 'string' &&
|
||||
typeof args.pull_request_id === 'number' &&
|
||||
(args.comment === undefined || typeof args.comment === 'string');
|
||||
|
||||
export const isGetBranchArgs = (
|
||||
args: any
|
||||
): args is {
|
||||
workspace: string;
|
||||
repository: string;
|
||||
branch_name: string;
|
||||
include_merged_prs?: boolean;
|
||||
} =>
|
||||
typeof args === 'object' &&
|
||||
args !== null &&
|
||||
typeof args.workspace === 'string' &&
|
||||
typeof args.repository === 'string' &&
|
||||
typeof args.branch_name === 'string' &&
|
||||
(args.include_merged_prs === undefined || typeof args.include_merged_prs === 'boolean');
|
||||
|
|
Loading…
Reference in a new issue