From 5d386451270fb32153a57879fe5e65048e8f6702 Mon Sep 17 00:00:00 2001 From: pdogra1299 Date: Sat, 21 Jun 2025 16:20:15 +0530 Subject: [PATCH] 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 --- README.md | 44 +++++++ src/handlers/branch-handlers.ts | 215 +++++++++++++++++++++++++++++++- src/index.ts | 2 + src/tools/definitions.ts | 26 ++++ src/types/bitbucket.ts | 39 ++++++ src/types/guards.ts | 15 +++ 6 files changed, 340 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 658538e..069a916 100644 --- a/README.md +++ b/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 diff --git a/src/handlers/branch-handlers.ts b/src/handlers/branch-handlers.ts index 96f4a5f..d50bf1c 100644 --- a/src/handlers/branch-handlers.ts +++ b/src/handlers/branch-handlers.ts @@ -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('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('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('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('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('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('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('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}`); + } + } } diff --git a/src/index.ts b/src/index.ts index e02ebc2..230ad33 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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': diff --git a/src/tools/definitions.ts b/src/tools/definitions.ts index a9481ce..9de7661 100644 --- a/src/tools/definitions.ts +++ b/src/tools/definitions.ts @@ -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'], + }, + }, ]; diff --git a/src/types/bitbucket.ts b/src/types/bitbucket.ts index 651fb81..5942f7c 100644 --- a/src/types/bitbucket.ts +++ b/src/types/bitbucket.ts @@ -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; diff --git a/src/types/guards.ts b/src/types/guards.ts index 2b82781..d5d9418 100644 --- a/src/types/guards.ts +++ b/src/types/guards.ts @@ -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');