Skip to main content
Glama
branch-handlers.ts22.3 kB
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; import { BitbucketApiClient } from '../utils/api-client.js'; import { isListBranchesArgs, isDeleteBranchArgs, isGetBranchArgs, isListBranchCommitsArgs } from '../types/guards.js'; import { BitbucketServerBranch, BitbucketCloudBranch, BitbucketServerCommit, BitbucketCloudCommit, FormattedCommit } from '../types/bitbucket.js'; import { formatServerCommit, formatCloudCommit } from '../utils/formatters.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}`); } } 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}`); } } async handleListBranchCommits(args: any) { if (!isListBranchCommitsArgs(args)) { throw new McpError( ErrorCode.InvalidParams, 'Invalid arguments for list_branch_commits' ); } const { workspace, repository, branch_name, limit = 25, start = 0, since, until, author, include_merge_commits = true, search, include_build_status = false } = args; try { let apiPath: string; let params: any = {}; let commits: FormattedCommit[] = []; let totalCount = 0; let nextPageStart: number | null = null; if (this.apiClient.getIsServer()) { // Bitbucket Server API apiPath = `/rest/api/1.0/projects/${workspace}/repos/${repository}/commits`; params = { until: `refs/heads/${branch_name}`, limit, start, withCounts: true }; // Add filters if (since) { params.since = since; } if (!include_merge_commits) { params.merges = 'exclude'; } const response = await this.apiClient.makeRequest<any>('get', apiPath, undefined, { params }); // Format commits commits = (response.values || []).map((commit: BitbucketServerCommit) => formatServerCommit(commit)); // Apply client-side filters for Server API if (author) { // Filter by author email or name commits = commits.filter(c => c.author.email === author || c.author.name === author || c.author.email.toLowerCase() === author.toLowerCase() || c.author.name.toLowerCase() === author.toLowerCase() ); } // Filter by date if 'until' is provided (Server API doesn't support 'until' param directly) if (until) { const untilDate = new Date(until).getTime(); commits = commits.filter(c => new Date(c.date).getTime() <= untilDate); } // Filter by message search if provided if (search) { const searchLower = search.toLowerCase(); commits = commits.filter(c => c.message.toLowerCase().includes(searchLower)); } // If we applied client-side filters, update the total count if (author || until || search) { totalCount = commits.length; // Can't determine if there are more results when filtering client-side nextPageStart = null; } else { totalCount = response.size || commits.length; if (!response.isLastPage && response.nextPageStart !== undefined) { nextPageStart = response.nextPageStart; } } } else { // Bitbucket Cloud API apiPath = `/repositories/${workspace}/${repository}/commits/${encodeURIComponent(branch_name)}`; params = { pagelen: limit, page: Math.floor(start / limit) + 1 }; // Build query string for filters const queryParts: string[] = []; if (author) { queryParts.push(`author.raw ~ "${author}"`); } if (!include_merge_commits) { // Cloud API doesn't have direct merge exclusion, we'll filter client-side } if (queryParts.length > 0) { params.q = queryParts.join(' AND '); } const response = await this.apiClient.makeRequest<any>('get', apiPath, undefined, { params }); // Format commits let cloudCommits = (response.values || []).map((commit: BitbucketCloudCommit) => formatCloudCommit(commit)); // Apply client-side filters if (!include_merge_commits) { cloudCommits = cloudCommits.filter((c: FormattedCommit) => !c.is_merge_commit); } if (since) { const sinceDate = new Date(since).getTime(); cloudCommits = cloudCommits.filter((c: FormattedCommit) => new Date(c.date).getTime() >= sinceDate); } if (until) { const untilDate = new Date(until).getTime(); cloudCommits = cloudCommits.filter((c: FormattedCommit) => new Date(c.date).getTime() <= untilDate); } if (search) { const searchLower = search.toLowerCase(); cloudCommits = cloudCommits.filter((c: FormattedCommit) => c.message.toLowerCase().includes(searchLower)); } commits = cloudCommits; totalCount = response.size || commits.length; if (response.next) { nextPageStart = start + limit; } } // Fetch build status if requested (Server only) if (include_build_status && this.apiClient.getIsServer() && commits.length > 0) { try { // Extract commit hashes (use full hash, not abbreviated) const commitIds = commits.map(c => c.hash); // Fetch build summaries for all commits const buildSummaries = await this.apiClient.getBuildSummaries( workspace, repository, commitIds ); // Merge build status into commits commits = commits.map(commit => { const buildData = buildSummaries[commit.hash]; if (buildData) { return { ...commit, build_status: { successful: buildData.successful || 0, failed: buildData.failed || 0, in_progress: buildData.inProgress || 0, unknown: buildData.unknown || 0 } }; } return commit; }); } catch (error) { // Gracefully degrade - log error but don't fail the entire request console.error('Failed to fetch build status:', error); } } // Get branch head info let branchHead: string | null = null; try { if (this.apiClient.getIsServer()) { 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: 1 } }); const branch = branchesResponse.values?.find((b: any) => b.displayId === branch_name); branchHead = branch?.latestCommit || null; } else { const branchPath = `/repositories/${workspace}/${repository}/refs/branches/${encodeURIComponent(branch_name)}`; const branch = await this.apiClient.makeRequest<any>('get', branchPath); branchHead = branch.target?.hash || null; } } catch (e) { // Ignore error, branch head is optional } // Build filters applied summary const filtersApplied: any = {}; if (author) filtersApplied.author = author; if (since) filtersApplied.since = since; if (until) filtersApplied.until = until; if (include_merge_commits !== undefined) filtersApplied.include_merge_commits = include_merge_commits; if (search) filtersApplied.search = search; if (include_build_status) filtersApplied.include_build_status = include_build_status; return { content: [ { type: 'text', text: JSON.stringify({ branch_name, branch_head: branchHead, commits, total_count: totalCount, start, limit, has_more: nextPageStart !== null, next_start: nextPageStart, filters_applied: filtersApplied }, null, 2), }, ], }; } catch (error) { return this.apiClient.handleApiError(error, `listing commits for branch '${branch_name}' in ${workspace}/${repository}`); } } }

Implementation Reference

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/pdogra1299/bitbucket-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server