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
|
- `merge_pull_request` - Merge pull requests with various strategies
|
||||||
- `delete_branch` - Delete branches after merge
|
- `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
|
#### Code Review Tools
|
||||||
- `get_pull_request_diff` - Get the diff/changes for a pull request
|
- `get_pull_request_diff` - Get the diff/changes for a pull request
|
||||||
- `approve_pull_request` - Approve 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.
|
**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
|
### Get Pull Request Diff
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
|
|
@ -2,8 +2,10 @@ import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
||||||
import { BitbucketApiClient } from '../utils/api-client.js';
|
import { BitbucketApiClient } from '../utils/api-client.js';
|
||||||
import {
|
import {
|
||||||
isListBranchesArgs,
|
isListBranchesArgs,
|
||||||
isDeleteBranchArgs
|
isDeleteBranchArgs,
|
||||||
|
isGetBranchArgs
|
||||||
} from '../types/guards.js';
|
} from '../types/guards.js';
|
||||||
|
import { BitbucketServerBranch, BitbucketCloudBranch } from '../types/bitbucket.js';
|
||||||
|
|
||||||
export class BranchHandlers {
|
export class BranchHandlers {
|
||||||
constructor(
|
constructor(
|
||||||
|
@ -179,4 +181,215 @@ export class BranchHandlers {
|
||||||
return this.apiClient.handleApiError(error, `deleting branch '${branch_name}' in ${workspace}/${repository}`);
|
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);
|
return this.branchHandlers.handleListBranches(request.params.arguments);
|
||||||
case 'delete_branch':
|
case 'delete_branch':
|
||||||
return this.branchHandlers.handleDeleteBranch(request.params.arguments);
|
return this.branchHandlers.handleDeleteBranch(request.params.arguments);
|
||||||
|
case 'get_branch':
|
||||||
|
return this.branchHandlers.handleGetBranch(request.params.arguments);
|
||||||
|
|
||||||
// Code Review tools
|
// Code Review tools
|
||||||
case 'get_pull_request_diff':
|
case 'get_pull_request_diff':
|
||||||
|
|
|
@ -390,4 +390,30 @@ export const toolDefinitions = [
|
||||||
required: ['workspace', 'repository', 'pull_request_id'],
|
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
|
// Bitbucket Cloud API response types
|
||||||
export interface BitbucketCloudPullRequest {
|
export interface BitbucketCloudPullRequest {
|
||||||
id: number;
|
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
|
// Merge info type for enhanced PR details
|
||||||
export interface MergeInfo {
|
export interface MergeInfo {
|
||||||
mergeCommitHash?: string;
|
mergeCommitHash?: string;
|
||||||
|
|
|
@ -187,3 +187,18 @@ export const isRequestChangesArgs = (
|
||||||
typeof args.repository === 'string' &&
|
typeof args.repository === 'string' &&
|
||||||
typeof args.pull_request_id === 'number' &&
|
typeof args.pull_request_id === 'number' &&
|
||||||
(args.comment === undefined || typeof args.comment === 'string');
|
(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