refactor: replace monolithic BitbucketClient with domain-client composition root
This commit is contained in:
@@ -1,612 +1,118 @@
|
|||||||
/**
|
import { ClientOptions } from './clients/base-client.js';
|
||||||
* Bitbucket API client with token configuration integration.
|
import { PullRequestClient, CreatePROptions, UpdatePROptions, MergePROptions } from './clients/pull-request-client.js';
|
||||||
*/
|
import { RepositoryClient } from './clients/repository-client.js';
|
||||||
|
import { CommentClient, AddCommentOptions, CreateTaskOptions, UpdateTaskOptions } from './clients/comment-client.js';
|
||||||
|
|
||||||
import axios, { AxiosInstance, AxiosError } from 'axios';
|
export type { CreatePROptions, UpdatePROptions, MergePROptions };
|
||||||
import { TokenConfigLoader, TokenConfig } from './config.js';
|
export type { AddCommentOptions, CreateTaskOptions, UpdateTaskOptions };
|
||||||
|
export type { ClientOptions as BitbucketClientOptions };
|
||||||
|
|
||||||
export interface BitbucketClientOptions {
|
|
||||||
timeout?: number;
|
|
||||||
retryCount?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bitbucket API client wrapper with authentication and error handling.
|
|
||||||
*/
|
|
||||||
export class BitbucketClient {
|
export class BitbucketClient {
|
||||||
private client!: AxiosInstance;
|
private pr!: PullRequestClient;
|
||||||
private tokenSource: string | null = null;
|
private repo!: RepositoryClient;
|
||||||
private initialized: boolean = false;
|
private comment!: CommentClient;
|
||||||
|
|
||||||
constructor(options: BitbucketClientOptions = {}) {
|
// ── Pull Request read ─────────────────────────────────────────────────────
|
||||||
// Initialize synchronously to avoid race conditions
|
listPullRequests!: (workspace: string, repo: string, options?: any) => Promise<any>;
|
||||||
this.initializeClient(options);
|
getPullRequest!: (workspace: string, repo: string, prId: number) => Promise<any>;
|
||||||
|
getPullRequestActivities!: (workspace: string, repo: string, prId: number, options?: any) => Promise<any>;
|
||||||
|
getPullRequestChanges!: (workspace: string, repo: string, prId: number, options?: any) => Promise<any>;
|
||||||
|
getPullRequestCommits!: (workspace: string, repo: string, prId: number, options?: any) => Promise<any>;
|
||||||
|
getPullRequestDiff!: (workspace: string, repo: string, prId: number, options?: any) => Promise<any>;
|
||||||
|
getPullRequestPatch!: (workspace: string, repo: string, prId: number) => Promise<any>;
|
||||||
|
getPullRequestParticipants!: (workspace: string, repo: string, prId: number) => Promise<any>;
|
||||||
|
getPullRequestReviewers!: (workspace: string, repo: string, prId: number) => Promise<any>;
|
||||||
|
getPullRequestStatus!: (workspace: string, repo: string, prId: number) => Promise<any>;
|
||||||
|
getPullRequestTasks!: (workspace: string, repo: string, prId: number) => Promise<any>;
|
||||||
|
getPullRequestTaskCount!: (workspace: string, repo: string, prId: number) => Promise<any>;
|
||||||
|
getFullPullRequest!: (workspace: string, repo: string, prId: number) => Promise<any>;
|
||||||
|
|
||||||
|
// ── Pull Request write ────────────────────────────────────────────────────
|
||||||
|
createPullRequest!: (workspace: string, repo: string, options: CreatePROptions) => Promise<any>;
|
||||||
|
updatePullRequest!: (workspace: string, repo: string, prId: number, options: UpdatePROptions) => Promise<any>;
|
||||||
|
mergePullRequest!: (workspace: string, repo: string, prId: number, options: MergePROptions) => Promise<any>;
|
||||||
|
declinePullRequest!: (workspace: string, repo: string, prId: number) => Promise<any>;
|
||||||
|
approvePullRequest!: (workspace: string, repo: string, prId: number) => Promise<any>;
|
||||||
|
unapprovePullRequest!: (workspace: string, repo: string, prId: number) => Promise<any>;
|
||||||
|
requestChangesPullRequest!: (workspace: string, repo: string, prId: number) => Promise<any>;
|
||||||
|
removeRequestChangesPullRequest!: (workspace: string, repo: string, prId: number) => Promise<any>;
|
||||||
|
|
||||||
|
// ── Repository / workspace ────────────────────────────────────────────────
|
||||||
|
listWorkspaces!: (options?: any) => Promise<any>;
|
||||||
|
listRepositories!: (workspace: string, options?: any) => Promise<any>;
|
||||||
|
getRepository!: (workspace: string, repo: string) => Promise<any>;
|
||||||
|
listBranches!: (workspace: string, repo: string, options?: any) => Promise<any>;
|
||||||
|
|
||||||
|
// ── Comment read ──────────────────────────────────────────────────────────
|
||||||
|
getPullRequestComments!: (workspace: string, repo: string, prId: number, options?: any) => Promise<any>;
|
||||||
|
getPullRequestComment!: (workspace: string, repo: string, prId: number, commentId: number) => Promise<any>;
|
||||||
|
|
||||||
|
// ── Comment / task write ──────────────────────────────────────────────────
|
||||||
|
addPullRequestComment!: (workspace: string, repo: string, prId: number, options: AddCommentOptions) => Promise<any>;
|
||||||
|
updatePullRequestComment!: (workspace: string, repo: string, prId: number, commentId: number, options: AddCommentOptions) => Promise<any>;
|
||||||
|
deletePullRequestComment!: (workspace: string, repo: string, prId: number, commentId: number) => Promise<any>;
|
||||||
|
createPullRequestTask!: (workspace: string, repo: string, prId: number, options: CreateTaskOptions) => Promise<any>;
|
||||||
|
updatePullRequestTask!: (workspace: string, repo: string, prId: number, taskId: number, options: UpdateTaskOptions) => Promise<any>;
|
||||||
|
deletePullRequestTask!: (workspace: string, repo: string, prId: number, taskId: number) => Promise<any>;
|
||||||
|
|
||||||
|
constructor(options: ClientOptions = {}) {
|
||||||
|
this.pr = new PullRequestClient(options);
|
||||||
|
this.repo = new RepositoryClient(options);
|
||||||
|
this.comment = new CommentClient(options);
|
||||||
|
|
||||||
|
// Bind all PR methods
|
||||||
|
this.listPullRequests = this.pr.listPullRequests.bind(this.pr);
|
||||||
|
this.getPullRequest = this.pr.getPullRequest.bind(this.pr);
|
||||||
|
this.getPullRequestActivities = this.pr.getPullRequestActivities.bind(this.pr);
|
||||||
|
this.getPullRequestChanges = this.pr.getPullRequestChanges.bind(this.pr);
|
||||||
|
this.getPullRequestCommits = this.pr.getPullRequestCommits.bind(this.pr);
|
||||||
|
this.getPullRequestDiff = this.pr.getPullRequestDiff.bind(this.pr);
|
||||||
|
this.getPullRequestPatch = this.pr.getPullRequestPatch.bind(this.pr);
|
||||||
|
this.getPullRequestParticipants = this.pr.getPullRequestParticipants.bind(this.pr);
|
||||||
|
this.getPullRequestReviewers = this.pr.getPullRequestReviewers.bind(this.pr);
|
||||||
|
this.getPullRequestStatus = this.pr.getPullRequestStatus.bind(this.pr);
|
||||||
|
this.getPullRequestTasks = this.pr.getPullRequestTasks.bind(this.pr);
|
||||||
|
this.getPullRequestTaskCount = this.pr.getPullRequestTaskCount.bind(this.pr);
|
||||||
|
this.getFullPullRequest = this.pr.getFullPullRequest.bind(this.pr);
|
||||||
|
|
||||||
|
this.createPullRequest = this.pr.createPullRequest.bind(this.pr);
|
||||||
|
this.updatePullRequest = this.pr.updatePullRequest.bind(this.pr);
|
||||||
|
this.mergePullRequest = this.pr.mergePullRequest.bind(this.pr);
|
||||||
|
this.declinePullRequest = this.pr.declinePullRequest.bind(this.pr);
|
||||||
|
this.approvePullRequest = this.pr.approvePullRequest.bind(this.pr);
|
||||||
|
this.unapprovePullRequest = this.pr.unapprovePullRequest.bind(this.pr);
|
||||||
|
this.requestChangesPullRequest = this.pr.requestChangesPullRequest.bind(this.pr);
|
||||||
|
this.removeRequestChangesPullRequest = this.pr.removeRequestChangesPullRequest.bind(this.pr);
|
||||||
|
|
||||||
|
// Bind all repo methods
|
||||||
|
this.listWorkspaces = this.repo.listWorkspaces.bind(this.repo);
|
||||||
|
this.listRepositories = this.repo.listRepositories.bind(this.repo);
|
||||||
|
this.getRepository = this.repo.getRepository.bind(this.repo);
|
||||||
|
this.listBranches = this.repo.listBranches.bind(this.repo);
|
||||||
|
|
||||||
|
// Bind all comment methods
|
||||||
|
this.getPullRequestComments = this.comment.getPullRequestComments.bind(this.comment);
|
||||||
|
this.getPullRequestComment = this.comment.getPullRequestComment.bind(this.comment);
|
||||||
|
|
||||||
|
this.addPullRequestComment = this.comment.addPullRequestComment.bind(this.comment);
|
||||||
|
this.updatePullRequestComment = this.comment.updatePullRequestComment.bind(this.comment);
|
||||||
|
this.deletePullRequestComment = this.comment.deletePullRequestComment.bind(this.comment);
|
||||||
|
this.createPullRequestTask = this.comment.createPullRequestTask.bind(this.comment);
|
||||||
|
this.updatePullRequestTask = this.comment.updatePullRequestTask.bind(this.comment);
|
||||||
|
this.deletePullRequestTask = this.comment.deletePullRequestTask.bind(this.comment);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 }> {
|
async validateToken(): Promise<{ valid: boolean; message: string }> {
|
||||||
await this.ensureInitialized();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Make a simple API call to test authentication
|
await this.repo.listWorkspaces({ pagelen: 1 });
|
||||||
const response = await this.client.get('/user');
|
|
||||||
return { valid: true, message: 'Token is valid' };
|
return { valid: true, message: 'Token is valid' };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const status = error.response?.status;
|
const status = error?.response?.status ?? error?.cause?.response?.status;
|
||||||
if (status === 401) {
|
if (status === 401) return { valid: false, message: 'Token is invalid or expired' };
|
||||||
return { valid: false, message: 'Token is invalid or expired' };
|
if (status === 403) return { valid: false, message: 'Token lacks required permissions' };
|
||||||
} else if (status === 403) {
|
return { valid: false, message: `Token validation failed: ${error?.message || error}` };
|
||||||
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;
|
export default BitbucketClient;
|
||||||
@@ -2,68 +2,118 @@
|
|||||||
* Unit tests for BitbucketRouter
|
* Unit tests for BitbucketRouter
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import { BitbucketRouter, ToolResult } from '../../src/router.js';
|
import { BitbucketRouter, ToolResult } from '../../src/router.js';
|
||||||
|
|
||||||
// Mock the BitbucketClient
|
// Mock domain clients - use simple object mocks instead of nested vi.fn
|
||||||
vi.mock('../../src/bitbucket-client.js', () => {
|
vi.mock('../../src/clients/pull-request-client.js', () => {
|
||||||
return {
|
const mockPRClient = {
|
||||||
BitbucketClient: vi.fn().mockImplementation(() => ({
|
listPullRequests: async () => [
|
||||||
listPullRequests: vi.fn().mockResolvedValue([
|
|
||||||
{ id: 1, title: 'Test PR 1', state: 'OPEN' },
|
{ id: 1, title: 'Test PR 1', state: 'OPEN' },
|
||||||
{ id: 2, title: 'Test PR 2', state: 'MERGED' }
|
{ id: 2, title: 'Test PR 2', state: 'MERGED' }
|
||||||
]),
|
],
|
||||||
getPullRequest: vi.fn().mockResolvedValue({
|
getPullRequest: async () => ({
|
||||||
id: 1,
|
id: 1,
|
||||||
title: 'Test PR',
|
title: 'Test PR',
|
||||||
state: 'OPEN',
|
state: 'OPEN',
|
||||||
source: { branch: { name: 'feature' } },
|
source: { branch: { name: 'feature' } },
|
||||||
destination: { branch: { name: 'main' } }
|
destination: { branch: { name: 'main' } }
|
||||||
}),
|
}),
|
||||||
getPullRequestStatus: vi.fn().mockResolvedValue({
|
getPullRequestStatus: async () => ({
|
||||||
id: 1,
|
id: 1,
|
||||||
title: 'Test PR',
|
title: 'Test PR',
|
||||||
state: 'OPEN',
|
state: 'OPEN',
|
||||||
status: 'NORMAL'
|
status: 'NORMAL'
|
||||||
}),
|
}),
|
||||||
getPullRequestActivities: vi.fn().mockResolvedValue({
|
getPullRequestActivities: async () => ({
|
||||||
values: [{ action: 'OPEN' }]
|
values: [{ action: 'OPEN' }]
|
||||||
}),
|
}),
|
||||||
getPullRequestChanges: vi.fn().mockResolvedValue({
|
getPullRequestChanges: async () => ({
|
||||||
values: [{ type: 'modified', path: 'src/test.ts' }]
|
values: [{ type: 'modified', path: 'src/test.ts' }]
|
||||||
}),
|
}),
|
||||||
getPullRequestComments: vi.fn().mockResolvedValue({
|
getPullRequestCommits: async () => ({
|
||||||
values: [{ id: 1, content: { raw: 'Test comment' } }]
|
|
||||||
}),
|
|
||||||
getPullRequestComment: vi.fn().mockResolvedValue({
|
|
||||||
id: 1,
|
|
||||||
content: { raw: 'Test comment' }
|
|
||||||
}),
|
|
||||||
getPullRequestCommits: vi.fn().mockResolvedValue({
|
|
||||||
values: [{ hash: 'abc123' }]
|
values: [{ hash: 'abc123' }]
|
||||||
}),
|
}),
|
||||||
getPullRequestDiff: vi.fn().mockResolvedValue({
|
getPullRequestDiff: async () => ({
|
||||||
diff: '--- test.ts\n+++ test.ts\n@@ -1 +1 @@'
|
diff: '--- test.ts\n+++ test.ts\n@@ -1 +1 @@'
|
||||||
}),
|
}),
|
||||||
getPullRequestPatch: vi.fn().mockResolvedValue({
|
getPullRequestPatch: async () => ({
|
||||||
patch: '--- original\n+++ modified'
|
patch: '--- original\n+++ modified'
|
||||||
}),
|
}),
|
||||||
getPullRequestParticipants: vi.fn().mockResolvedValue({
|
getPullRequestParticipants: async () => ({
|
||||||
values: [{ user: { display_name: 'Test User' } }]
|
values: [{ user: { display_name: 'Test User' } }]
|
||||||
}),
|
}),
|
||||||
getPullRequestReviewers: vi.fn().mockResolvedValue([
|
getPullRequestReviewers: async () => [
|
||||||
{ user: { display_name: 'Reviewer 1' } }
|
{ user: { display_name: 'Reviewer 1' } }
|
||||||
]),
|
],
|
||||||
getPullRequestTasks: vi.fn().mockResolvedValue({
|
getPullRequestTasks: async () => ({
|
||||||
values: []
|
values: []
|
||||||
}),
|
}),
|
||||||
getPullRequestTaskCount: vi.fn().mockResolvedValue({ count: 0 }),
|
getPullRequestTaskCount: async () => ({ count: 0 }),
|
||||||
getFullPullRequest: vi.fn().mockResolvedValue({
|
getFullPullRequest: async () => ({
|
||||||
id: 1,
|
id: 1,
|
||||||
title: 'Test PR',
|
title: 'Test PR',
|
||||||
description: 'Test description'
|
description: 'Test description'
|
||||||
})
|
}),
|
||||||
}))
|
createPullRequest: async () => ({ id: 1 }),
|
||||||
|
updatePullRequest: async () => ({ id: 1 }),
|
||||||
|
mergePullRequest: async () => ({ id: 1 }),
|
||||||
|
declinePullRequest: async () => ({ id: 1 }),
|
||||||
|
approvePullRequest: async () => ({ id: 1 }),
|
||||||
|
unapprovePullRequest: async () => ({ id: 1 }),
|
||||||
|
requestChangesPullRequest: async () => ({ id: 1 }),
|
||||||
|
removeRequestChangesPullRequest: async () => ({ id: 1 })
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
PullRequestClient: class {
|
||||||
|
constructor() {
|
||||||
|
Object.assign(this, mockPRClient);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../../src/clients/repository-client.js', () => {
|
||||||
|
const mockRepoClient = {
|
||||||
|
listWorkspaces: async () => ({ values: [] }),
|
||||||
|
listRepositories: async () => ({ values: [] }),
|
||||||
|
getRepository: async () => ({ slug: 'test' }),
|
||||||
|
listBranches: async () => ({ values: [] })
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
RepositoryClient: class {
|
||||||
|
constructor() {
|
||||||
|
Object.assign(this, mockRepoClient);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../../src/clients/comment-client.js', () => {
|
||||||
|
const mockCommentClient = {
|
||||||
|
getPullRequestComments: async () => ({
|
||||||
|
values: [{ id: 1, content: { raw: 'Test comment' } }]
|
||||||
|
}),
|
||||||
|
getPullRequestComment: async () => ({
|
||||||
|
id: 1,
|
||||||
|
content: { raw: 'Test comment' }
|
||||||
|
}),
|
||||||
|
addPullRequestComment: async () => ({ id: 1 }),
|
||||||
|
updatePullRequestComment: async () => ({ id: 1 }),
|
||||||
|
deletePullRequestComment: async () => ({ success: true }),
|
||||||
|
createPullRequestTask: async () => ({ id: 1 }),
|
||||||
|
updatePullRequestTask: async () => ({ id: 1 }),
|
||||||
|
deletePullRequestTask: async () => ({ success: true })
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
CommentClient: class {
|
||||||
|
constructor() {
|
||||||
|
Object.assign(this, mockCommentClient);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user