This commit is contained in:
2026-05-19 16:45:59 +02:00
parent 29496cb944
commit 992f25a9a2
16 changed files with 6222 additions and 116 deletions

612
src/bitbucket-client.ts Normal file
View File

@@ -0,0 +1,612 @@
/**
* Bitbucket API client with token configuration integration.
*/
import axios, { AxiosInstance, AxiosError } from 'axios';
import { TokenConfigLoader, TokenConfig } from './config.js';
export interface BitbucketClientOptions {
timeout?: number;
retryCount?: number;
}
/**
* Bitbucket API client wrapper with authentication and error handling.
*/
export class BitbucketClient {
private client!: AxiosInstance;
private tokenSource: string | null = null;
private initialized: boolean = false;
constructor(options: BitbucketClientOptions = {}) {
// Initialize synchronously to avoid race conditions
this.initializeClient(options);
}
private async initializeClient(options: BitbucketClientOptions): Promise<void> {
try {
const config = await this.loadTokenConfig();
this.tokenSource = config.source;
this.client = axios.create({
baseURL: 'https://api.bitbucket.org/2.0',
headers: {
'Content-Type': 'application/json'
},
auth: {
username: config.email,
password: config.token
},
timeout: options.timeout || 30000,
maxRedirects: 5
});
// Request interceptor for token refresh and logging
this.client.interceptors.request.use(config => {
config.headers['User-Agent'] = `Bitbucket-MCP-Server/${this.getVersion()}`;
return config;
});
// Response interceptor for rate limiting and errors
this.client.interceptors.response.use(
response => response,
async (error: AxiosError) => {
await this.handleResponseError(error);
}
);
this.initialized = true;
} catch (error) {
console.error('Failed to load token configuration:', error);
throw new Error('Unable to initialize Bitbucket client - check token configuration');
}
}
private async ensureInitialized(): Promise<void> {
if (!this.initialized) {
// Wait for initialization to complete
await new Promise(resolve => {
const checkInit = () => {
if (this.initialized) {
resolve(void 0);
} else {
setTimeout(checkInit, 10);
}
};
checkInit();
});
}
}
private async loadTokenConfig(): Promise<TokenConfig> {
return TokenConfigLoader.load();
}
private getVersion(): string {
try {
// Use synchronous fs for version lookup - no async needed
const fs = require('fs');
const path = require('path');
const pkgPath = path.join(process.cwd(), 'package.json');
if (fs.existsSync(pkgPath)) {
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
return pkg.version || '1.0.0';
}
return '1.0.0';
} catch {
return '1.0.0';
}
}
private async handleResponseError(error: AxiosError): Promise<void> {
const status = error.response?.status;
if (status === 401) {
// Authentication error - provide detailed logging
const data = error.response?.data;
console.error('🔐 Bitbucket API Authentication Error (401):');
console.error(` Token source: ${this.tokenSource || 'unknown'}`);
console.error(` Request URL: ${error.config?.url}`);
console.error(` Request method: ${error.config?.method?.toUpperCase()}`);
if (typeof data === 'object' && data !== null) {
console.error(' Response data:', JSON.stringify(data, null, 2));
} else if (data) {
console.error(` Response data: ${data}`);
}
// Check if token might be expired or malformed
const authHeader = error.config?.headers?.Authorization;
if (typeof authHeader === 'string') {
const token = authHeader.replace('Bearer ', '');
const tokenLength = token.length;
const isJWT = token.includes('.');
console.error(` Token info: length=${tokenLength}, appears to be JWT=${isJWT}`);
}
console.error(' Possible causes:');
console.error(' - Token expired');
console.error(' - Token lacks required permissions (needs repository read access)');
console.error(' - Token is malformed or corrupted');
console.error(' - Repository/workspace access denied');
} else if (status === 403) {
// Forbidden - similar to auth but different permissions
console.error('🚫 Bitbucket API Forbidden Error (403):');
console.error(` Token source: ${this.tokenSource || 'unknown'}`);
console.error(` Request URL: ${error.config?.url}`);
console.error(' Possible causes:');
console.error(' - Token lacks permission to access this repository');
console.error(' - Repository is private and token has insufficient scope');
} else if (status === 429) {
// Rate limited - wait and retry
const retryAfter = error.response?.headers['retry-after'];
const delay = retryAfter ? parseInt(retryAfter, 10) * 1000 : 5000;
console.log(`Rate limited. Retrying in ${delay}ms...`);
await this.sleep(delay);
} else if (status && status >= 400 && status < 500) {
// Other client error - log but don't retry
const data = error.response?.data;
let message: string;
if (typeof data === 'object' && data !== null && 'message' in data) {
message = String(data.message);
} else {
message = `Client error ${status}`;
}
console.error(`Bitbucket API ${status}: ${message}`);
} else if (status && status >= 500) {
// Server error - could implement exponential backoff retry
console.warn(`Server error ${status}, will retry...`);
}
throw error;
}
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Validate the authentication token by making a test API call.
*/
async validateToken(): Promise<{ valid: boolean; message: string }> {
await this.ensureInitialized();
try {
// Make a simple API call to test authentication
const response = await this.client.get('/user');
return { valid: true, message: 'Token is valid' };
} catch (error: any) {
const status = error.response?.status;
if (status === 401) {
return { valid: false, message: 'Token is invalid or expired' };
} else if (status === 403) {
return { valid: false, message: 'Token lacks required permissions' };
} else {
return { valid: false, message: `Token validation failed: ${this.formatError(error)}` };
}
}
}
/**
* List pull requests in a repository.
*/
async listPullRequests(
workspace: string,
repoSlug: string,
options?: {
state?: 'open' | 'closed' | 'all';
author?: string;
reviewer?: string;
since?: string;
}
): Promise<any[]> {
await this.ensureInitialized();
const params: Record<string, any> = {};
if (options?.state) params.state = options.state;
if (options?.author) params.author = options.author;
if (options?.reviewer) params.reviewer = options.reviewer;
if (options?.since) params.since = options.since;
try {
const response = await this.client.get(
`/repositories/${workspace}/${repoSlug}/pullrequests`,
{ params }
);
return response.data.values || [];
} catch (error) {
throw new Error(`Failed to list pull requests: ${this.formatError(error)}`);
}
}
/**
* Get a specific pull request.
*/
async getPullRequest(
workspace: string,
repoSlug: string,
prId: number
): Promise<any> {
await this.ensureInitialized();
try {
const response = await this.client.get(
`/repositories/${workspace}/${repoSlug}/pullrequests/${prId}`
);
return response.data;
} catch (error) {
throw new Error(`Failed to get pull request: ${this.formatError(error)}`);
}
}
/**
* Get pull request activities (events/actions on the PR).
*/
async getPullRequestActivities(
workspace: string,
repoSlug: string,
prId: number,
options?: {
limit?: number;
start?: number;
}
): Promise<any> {
await this.ensureInitialized();
try {
const params: Record<string, any> = {};
if (options?.limit) params.limit = options.limit;
if (options?.start) params.start = options.start;
const response = await this.client.get(
`/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/activity`,
{ params }
);
return response.data;
} catch (error) {
throw new Error(`Failed to get pull request activities: ${this.formatError(error)}`);
}
}
/**
* Get pull request changes (files modified in the PR).
*/
async getPullRequestChanges(
workspace: string,
repoSlug: string,
prId: number,
options?: {
limit?: number;
start?: number;
}
): Promise<any> {
await this.ensureInitialized();
try {
const params: Record<string, any> = {};
if (options?.limit) params.limit = options.limit;
if (options?.start) params.start = options.start;
const prResponse = await this.client.get(
`/repositories/${workspace}/${repoSlug}/pullrequests/${prId}`
);
const sourceHash = prResponse.data.source?.commit?.hash;
const destHash = prResponse.data.destination?.commit?.hash;
if (sourceHash && destHash) {
const changesPath = `/repositories/${workspace}/${repoSlug}/diffstat/${sourceHash}..${destHash}`;
const response = await this.client.get(changesPath, { params });
return response.data;
}
throw new Error('Could not determine source and destination commits for PR');
} catch (error) {
throw new Error(`Failed to get pull request changes: ${this.formatError(error)}`);
}
}
/**
* Get pull request comments.
*/
async getPullRequestComments(
workspace: string,
repoSlug: string,
prId: number,
options?: {
limit?: number;
start?: number;
}
): Promise<any> {
await this.ensureInitialized();
try {
const params: Record<string, any> = {};
if (options?.limit) params.limit = options.limit;
if (options?.start) params.start = options.start;
const response = await this.client.get(
`/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/comments`,
{ params }
);
return response.data;
} catch (error) {
throw new Error(`Failed to get pull request comments: ${this.formatError(error)}`);
}
}
/**
* Get a specific pull request comment.
*/
async getPullRequestComment(
workspace: string,
repoSlug: string,
prId: number,
commentId: number
): Promise<any> {
await this.ensureInitialized();
try {
const response = await this.client.get(
`/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/comments/${commentId}`
);
return response.data;
} catch (error) {
throw new Error(`Failed to get pull request comment: ${this.formatError(error)}`);
}
}
/**
* Get commits included in a pull request.
*/
async getPullRequestCommits(
workspace: string,
repoSlug: string,
prId: number,
options?: {
limit?: number;
start?: number;
}
): Promise<any> {
await this.ensureInitialized();
try {
const params: Record<string, any> = {};
if (options?.limit) params.limit = options.limit;
if (options?.start) params.start = options.start;
const response = await this.client.get(
`/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/commits`,
{ params }
);
return response.data;
} catch (error) {
throw new Error(`Failed to get pull request commits: ${this.formatError(error)}`);
}
}
/**
* Get diff for a pull request.
*/
async getPullRequestDiff(
workspace: string,
repoSlug: string,
prId: number,
options?: {
context?: number;
path?: string;
whitespace?: 'ignore-all' | 'ignore-changing' | 'ignore-eol' | 'show-all';
}
): Promise<any> {
await this.ensureInitialized();
try {
const params: Record<string, any> = {};
if (options?.context) params.context = options.context;
if (options?.whitespace) params.whitespace = options.whitespace;
if (options?.path) {
const response = await this.client.get(
`/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/diff/${options.path}`,
{ params, responseType: 'text' }
);
return response.data;
}
const prResponse = await this.client.get(
`/repositories/${workspace}/${repoSlug}/pullrequests/${prId}`
);
const sourceHash = prResponse.data.source?.commit?.hash;
const destHash = prResponse.data.destination?.commit?.hash;
if (sourceHash && destHash) {
const diffPath = `/repositories/${workspace}/${repoSlug}/diff/${sourceHash}..${destHash}`;
const response = await this.client.get(diffPath, { params, responseType: 'text' });
return response.data;
}
throw new Error('Could not determine source and destination commits for PR');
} catch (error) {
throw new Error(`Failed to get pull request diff: ${this.formatError(error)}`);
}
}
/**
* Get raw patch for a pull request.
*/
async getPullRequestPatch(
workspace: string,
repoSlug: string,
prId: number
): Promise<any> {
await this.ensureInitialized();
try {
const prResponse = await this.client.get(
`/repositories/${workspace}/${repoSlug}/pullrequests/${prId}`
);
const sourceHash = prResponse.data.source?.commit?.hash;
const destHash = prResponse.data.destination?.commit?.hash;
if (sourceHash && destHash) {
const patchPath = `/repositories/${workspace}/${repoSlug}/diff/${sourceHash}..${destHash}`;
const response = await this.client.get(patchPath, { responseType: 'text' });
return { patch: response.data };
}
throw new Error('Could not determine source and destination commits for PR');
} catch (error) {
throw new Error(`Failed to get pull request patch: ${this.formatError(error)}`);
}
}
/**
* Get participants of a pull request.
*/
async getPullRequestParticipants(
workspace: string,
repoSlug: string,
prId: number
): Promise<any> {
await this.ensureInitialized();
try {
const response = await this.client.get(
`/repositories/${workspace}/${repoSlug}/pullrequests/${prId}`
);
return response.data.participants || [];
} catch (error) {
throw new Error(`Failed to get pull request participants: ${this.formatError(error)}`);
}
}
/**
* Get reviewers of a pull request.
*/
async getPullRequestReviewers(
workspace: string,
repoSlug: string,
prId: number
): Promise<any> {
await this.ensureInitialized();
try {
const response = await this.client.get(
`/repositories/${workspace}/${repoSlug}/pullrequests/${prId}`
);
return response.data.reviewers || [];
} catch (error) {
throw new Error(`Failed to get pull request reviewers: ${this.formatError(error)}`);
}
}
/**
* Get status of a pull request (merged, open, declined).
*/
async getPullRequestStatus(
workspace: string,
repoSlug: string,
prId: number
): Promise<any> {
await this.ensureInitialized();
try {
const response = await this.client.get(
`/repositories/${workspace}/${repoSlug}/pullrequests/${prId}`
);
const pr = response.data;
return {
id: pr.id,
title: pr.title,
state: pr.state,
status: pr.status,
author: pr.author,
source_branch: pr.source?.branch?.name,
destination_branch: pr.destination?.branch?.name,
created_on: pr.created_on,
updated_on: pr.updated_on,
closed_on: pr.closed_on,
merge_commit: pr.merge_commit
};
} catch (error) {
throw new Error(`Failed to get pull request status: ${this.formatError(error)}`);
}
}
/**
* Get tasks associated with a pull request.
*/
async getPullRequestTasks(
workspace: string,
repoSlug: string,
prId: number
): Promise<any> {
await this.ensureInitialized();
try {
const response = await this.client.get(
`/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/tasks`
);
return response.data;
} catch (error) {
throw new Error(`Failed to get pull request tasks: ${this.formatError(error)}`);
}
}
/**
* Get task count for a pull request.
*/
async getPullRequestTaskCount(
workspace: string,
repoSlug: string,
prId: number
): Promise<any> {
await this.ensureInitialized();
try {
const response = await this.client.get(
`/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/tasks`
);
const tasks = response.data;
return { count: tasks.values?.length || 0 };
} catch (error) {
throw new Error(`Failed to get pull request task count: ${this.formatError(error)}`);
}
}
/**
* Get the full raw pull request with all details.
*/
async getFullPullRequest(
workspace: string,
repoSlug: string,
prId: number
): Promise<any> {
await this.ensureInitialized();
try {
const response = await this.client.get(
`/repositories/${workspace}/${repoSlug}/pullrequests/${prId}`,
{
params: {
fields: '+*'
}
}
);
return response.data;
} catch (error) {
throw new Error(`Failed to get full pull request: ${this.formatError(error)}`);
}
}
private formatError(error: any): string {
if (error?.response?.status) {
return `HTTP ${error.response.status}: ${error.message}`;
}
return error?.message || 'Unknown error';
}
}
export default BitbucketClient;