Add code
This commit is contained in:
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
tsconfig.tsbuildinfo
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
49
CLAUDE.md
Normal file
49
CLAUDE.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## What This Is
|
||||||
|
|
||||||
|
An MCP (Model Context Protocol) server that exposes Bitbucket Cloud Pull Request operations as tools for AI agents. It communicates over stdio using the `@modelcontextprotocol/sdk`.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start # Run server (tsx src/index.ts)
|
||||||
|
npm run dev # Run with hot reload (tsx watch)
|
||||||
|
npm run build # Compile TypeScript to dist/
|
||||||
|
npm test # Run all tests (vitest)
|
||||||
|
npm run test:integration # Integration tests only (hits real Bitbucket API)
|
||||||
|
vitest run tests/unit/config.test.ts # Run a single test file
|
||||||
|
```
|
||||||
|
|
||||||
|
Node version: 24.13.0 (see .nvmrc)
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The server is a 3-layer pipeline: **MCP protocol** -> **Router** -> **Bitbucket Client**.
|
||||||
|
|
||||||
|
- `src/index.ts` — MCP server setup. Registers tool schemas (inputSchema definitions) and delegates `CallToolRequest` to the router. This is where new tools must be declared.
|
||||||
|
- `src/router.ts` — `BitbucketRouter` maps tool names to client methods. Handles parameter extraction (supports both `pullRequestId` and `pr_id` forms) and wraps results in `ToolResult { success, data?, error? }`.
|
||||||
|
- `src/bitbucket-client.ts` — Axios-based HTTP client against `api.bitbucket.org/2.0`. Uses Basic Auth (email + app password). Has async initialization with a polling `ensureInitialized()` guard on every public method.
|
||||||
|
- `src/config.ts` — Credential loading with priority: env vars (`BITBUCKET_MCP_EMAIL`, `BITBUCKET_MCP_TOKEN`) > `.env` file > interactive prompt. Also loads `DEFAULT_WORKSPACE` and `DEFAULT_REPO` for optional parameter defaults.
|
||||||
|
|
||||||
|
## Adding a New Tool
|
||||||
|
|
||||||
|
1. Add the tool schema in `src/index.ts` (in the `ListToolsRequestSchema` handler array)
|
||||||
|
2. Add a case in `BitbucketRouter.executeTool()` switch statement
|
||||||
|
3. Add the private handler method in the router
|
||||||
|
4. Add the underlying API call in `BitbucketClient`
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
|----------|---------|
|
||||||
|
| `BITBUCKET_MCP_EMAIL` | Bitbucket account email (Basic Auth username) |
|
||||||
|
| `BITBUCKET_MCP_TOKEN` | Bitbucket app password (Basic Auth password) |
|
||||||
|
| `DEFAULT_WORKSPACE` | Optional default workspace slug |
|
||||||
|
| `DEFAULT_REPO` | Optional default repository slug |
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Tests use vitest with 30s timeouts. Unit tests mock the Bitbucket client; integration tests call the real API and require valid credentials in the environment.
|
||||||
334
README.md
334
README.md
@@ -1,162 +1,264 @@
|
|||||||
|
<div align="center">
|
||||||
|
|
||||||
# Bitbucket MCP Server
|
# Bitbucket MCP Server
|
||||||
|
|
||||||
MCP (Model Context Protocol) server for AI agents to interact with **Bitbucket Cloud Pull Requests**.
|
**A Model Context Protocol server for AI agents to interact with Bitbucket Cloud Pull Requests**
|
||||||
|
|
||||||
## Features
|
[](https://nodejs.org/)
|
||||||
|
[](https://www.typescriptlang.org/)
|
||||||
|
[](https://modelcontextprotocol.io/)
|
||||||
|
[](LICENSE)
|
||||||
|
|
||||||
- ✅ List pull requests in repositories
|
</div>
|
||||||
- ✅ Get detailed PR information
|
|
||||||
- ✅ Get PR activities/events
|
|
||||||
- ✅ Get PR changes (files modified)
|
|
||||||
- ✅ Get PR comments and individual comments
|
|
||||||
- ✅ Get PR commits
|
|
||||||
- ✅ Get PR diff and patch
|
|
||||||
- ✅ Get PR participants and reviewers
|
|
||||||
- ✅ Get PR status/state
|
|
||||||
- ✅ Get PR tasks
|
|
||||||
- ✅ Flexible credential configuration (env var or .env file)
|
|
||||||
- ✅ Rate limiting protection
|
|
||||||
- ✅ Error handling and retry logic
|
|
||||||
|
|
||||||
## Installation
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This MCP server exposes **read-only** Bitbucket Cloud Pull Request operations as tools that AI agents (Claude, etc.) can invoke over stdio. It connects your AI workflow directly to your Bitbucket repositories.
|
||||||
|
|
||||||
|
### Capabilities
|
||||||
|
|
||||||
|
| Category | Operations |
|
||||||
|
|----------|-----------|
|
||||||
|
| **Pull Requests** | List, get details, get full expanded PR |
|
||||||
|
| **Code Review** | Diff, patch, file changes, commits |
|
||||||
|
| **Collaboration** | Comments, activities, participants, reviewers |
|
||||||
|
| **Workflow** | Status, tasks, task count |
|
||||||
|
| **Auth** | Token validation |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js **24.13+** (see `.nvmrc`)
|
||||||
|
- A Bitbucket Cloud [App Password](https://bitbucket.org/account/settings/app-passwords/) with **repository read** permissions
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd bitbucket-mcp
|
||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration
|
### Configuration
|
||||||
|
|
||||||
### Credentials Sources (Priority Order)
|
Create a `.env` file in the project root:
|
||||||
|
|
||||||
1. **Environment Variables** (highest priority):
|
```env
|
||||||
```bash
|
BITBUCKET_MCP_EMAIL=your_email@example.com
|
||||||
export BITBUCKET_MCP_EMAIL=your_email@example.com
|
BITBUCKET_MCP_TOKEN=your_app_password
|
||||||
export BITBUCKET_MCP_TOKEN=your_token_here
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **.env file** in project root:
|
# Optional defaults (avoids passing workspace/repo on every call)
|
||||||
```
|
DEFAULT_WORKSPACE=my-workspace
|
||||||
BITBUCKET_MCP_EMAIL=your_email@example.com
|
DEFAULT_REPO=my-repo
|
||||||
BITBUCKET_MCP_TOKEN=your_token_here
|
```
|
||||||
```
|
|
||||||
|
|
||||||
3. **Interactive prompt** (development fallback)
|
Or export as environment variables (takes priority over `.env`):
|
||||||
|
|
||||||
### Getting Your Access Token
|
|
||||||
|
|
||||||
1. Go to [Bitbucket Applications](https://bitbucket.org/account/applications/)
|
|
||||||
2. Click "New application" or select existing
|
|
||||||
3. Set callback URL to `http://localhost:8080` (or any placeholder)
|
|
||||||
4. Copy the **Access token**
|
|
||||||
|
|
||||||
**Note:** Bitbucket uses Basic Authentication with your email as username and the access token as password.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
### Start the Server
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
export BITBUCKET_MCP_EMAIL=your_email@example.com
|
||||||
|
export BITBUCKET_MCP_TOKEN=your_app_password
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Production
|
||||||
npm start
|
npm start
|
||||||
# Or for development:
|
|
||||||
|
# Development (hot reload)
|
||||||
npm run dev
|
npm run dev
|
||||||
|
|
||||||
|
# Build
|
||||||
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
The server will output:
|
---
|
||||||
```
|
|
||||||
✅ Bitbucket MCP Server started
|
## MCP Client Configuration
|
||||||
Name: bitbucket-pullrequests v1.0.0
|
|
||||||
Token source: environment variable
|
### Claude Code
|
||||||
|
|
||||||
|
Add the following to your Claude Code MCP settings (`~/.claude/settings.json` or project `.claude/settings.json`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"bitbucket": {
|
||||||
|
"command": "node",
|
||||||
|
"args": [
|
||||||
|
"/absolute/path/to/bitbucket-mcp/dist/index.js"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"BITBUCKET_MCP_EMAIL": "your_email@example.com",
|
||||||
|
"BITBUCKET_MCP_TOKEN": "your_bitbucket_app_password",
|
||||||
|
"DEFAULT_WORKSPACE": "your-workspace",
|
||||||
|
"DEFAULT_REPO": "your-repo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Available Tools
|
### Claude Desktop
|
||||||
|
|
||||||
| Tool | Description | Parameters |
|
Add to your Claude Desktop config (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS or `%APPDATA%\Claude\claude_desktop_config.json` on Windows):
|
||||||
|------|-------------|------------|
|
|
||||||
| `list_pull_requests` | List PRs in a repository | workspace, repository, state (optional), author (optional) |
|
|
||||||
| `get_pull_request` | Get PR details | workspace, repository, pullRequestId |
|
|
||||||
| `get_pull_request_activities` | Get PR activities/events | workspace, repository, pullRequestId, limit (optional), start (optional) |
|
|
||||||
| `get_pull_request_changes` | Get files changed in PR | workspace, repository, pullRequestId, limit (optional), start (optional) |
|
|
||||||
| `get_pull_request_comments` | Get all PR comments | workspace, repository, pullRequestId, limit (optional), start (optional) |
|
|
||||||
| `get_pull_request_comment` | Get a specific PR comment | workspace, repository, pullRequestId, commentId |
|
|
||||||
| `get_pull_request_commits` | Get commits in PR | workspace, repository, pullRequestId, limit (optional), start (optional) |
|
|
||||||
| `get_pull_request_diff` | Get PR diff | workspace, repository, pullRequestId, path (optional), context (optional) |
|
|
||||||
| `get_pull_request_patch` | Get PR patch | workspace, repository, pullRequestId |
|
|
||||||
| `get_pull_request_participants` | Get PR participants | workspace, repository, pullRequestId |
|
|
||||||
| `get_pull_request_reviewers` | Get PR reviewers | workspace, repository, pullRequestId |
|
|
||||||
| `get_pull_request_status` | Get PR status/state | workspace, repository, pullRequestId |
|
|
||||||
| `get_pull_request_tasks` | Get PR tasks | workspace, repository, pullRequestId |
|
|
||||||
| `get_pull_request_task_count` | Get PR task count | workspace, repository, pullRequestId |
|
|
||||||
| `get_full_pull_request` | Get full PR details | workspace, repository, pullRequestId |
|
|
||||||
|
|
||||||
### Example MCP Call
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"bitbucket": {
|
||||||
|
"command": "node",
|
||||||
|
"args": [
|
||||||
|
"/absolute/path/to/bitbucket-mcp/dist/index.js"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"BITBUCKET_MCP_EMAIL": "your_email@example.com",
|
||||||
|
"BITBUCKET_MCP_TOKEN": "your_bitbucket_app_password",
|
||||||
|
"DEFAULT_WORKSPACE": "your-workspace",
|
||||||
|
"DEFAULT_REPO": "your-repo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** You must build the project first (`npm run build`) since the config points to `dist/index.js`. Use the **absolute path** to the compiled entry point. Setting `DEFAULT_WORKSPACE` and `DEFAULT_REPO` is optional but convenient — it lets you omit those parameters from every tool call.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Available Tools
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `validate_token` | Verify credentials are valid |
|
||||||
|
|
||||||
|
### Pull Request Discovery
|
||||||
|
|
||||||
|
| Tool | Description | Key Parameters |
|
||||||
|
|------|-------------|----------------|
|
||||||
|
| `list_pull_requests` | List PRs in a repository | `workspace`, `repository`, `state?`, `author?` |
|
||||||
|
| `get_pull_request` | Get PR summary | `workspace`, `repository`, `pullRequestId` |
|
||||||
|
| `get_full_pull_request` | Get PR with all fields expanded | `workspace`, `repository`, `pullRequestId` |
|
||||||
|
| `get_pull_request_status` | Get PR state (open/merged/declined) | `workspace`, `repository`, `pullRequestId` |
|
||||||
|
|
||||||
|
### Code Changes
|
||||||
|
|
||||||
|
| Tool | Description | Key Parameters |
|
||||||
|
|------|-------------|----------------|
|
||||||
|
| `get_pull_request_diff` | Get unified diff | `workspace`, `repository`, `pullRequestId`, `path?`, `context?` |
|
||||||
|
| `get_pull_request_patch` | Get raw patch file | `workspace`, `repository`, `pullRequestId` |
|
||||||
|
| `get_pull_request_changes` | List modified files | `workspace`, `repository`, `pullRequestId` |
|
||||||
|
| `get_pull_request_commits` | List commits in PR | `workspace`, `repository`, `pullRequestId` |
|
||||||
|
|
||||||
|
### Collaboration
|
||||||
|
|
||||||
|
| Tool | Description | Key Parameters |
|
||||||
|
|------|-------------|----------------|
|
||||||
|
| `get_pull_request_comments` | Get all comments | `workspace`, `repository`, `pullRequestId` |
|
||||||
|
| `get_pull_request_comment` | Get a specific comment | `workspace`, `repository`, `pullRequestId`, `commentId` |
|
||||||
|
| `get_pull_request_activities` | Get activity feed | `workspace`, `repository`, `pullRequestId` |
|
||||||
|
| `get_pull_request_participants` | Get all participants | `workspace`, `repository`, `pullRequestId` |
|
||||||
|
| `get_pull_request_reviewers` | Get assigned reviewers | `workspace`, `repository`, `pullRequestId` |
|
||||||
|
|
||||||
|
### Tasks
|
||||||
|
|
||||||
|
| Tool | Description | Key Parameters |
|
||||||
|
|------|-------------|----------------|
|
||||||
|
| `get_pull_request_tasks` | Get PR tasks | `workspace`, `repository`, `pullRequestId` |
|
||||||
|
| `get_pull_request_task_count` | Get task count | `workspace`, `repository`, `pullRequestId` |
|
||||||
|
|
||||||
|
> **Pagination:** Tools that return lists support `limit` and `start` parameters.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"name": "list_pull_requests",
|
"name": "list_pull_requests",
|
||||||
"arguments": {
|
"arguments": {
|
||||||
"workspace": "my-workspace",
|
"workspace": "my-team",
|
||||||
"repository": "my-repo",
|
"repository": "backend-api",
|
||||||
"state": "open"
|
"state": "open"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## API Reference
|
Response:
|
||||||
|
|
||||||
### list_pull_requests(workspace, repository, options?)
|
```json
|
||||||
|
{
|
||||||
|
"pull_requests": [
|
||||||
|
{
|
||||||
|
"id": 42,
|
||||||
|
"title": "feat: add user authentication",
|
||||||
|
"state": "OPEN",
|
||||||
|
"author": { "display_name": "Jane Doe" },
|
||||||
|
"source": { "branch": { "name": "feature/auth" } },
|
||||||
|
"destination": { "branch": { "name": "main" } }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"count": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
List pull requests in a repository.
|
---
|
||||||
|
|
||||||
**Options:**
|
|
||||||
- `state`: 'open' | 'closed' | 'all' (default: open)
|
|
||||||
- `author`: Filter by author username
|
|
||||||
|
|
||||||
**Returns:** Array of pull request objects with metadata.
|
|
||||||
|
|
||||||
### get_pull_request(workspace, repository, pullRequestId)
|
|
||||||
|
|
||||||
Get detailed information about a specific pull request.
|
|
||||||
|
|
||||||
**Returns:** Complete PR object including title, description, source/destination branches, reviewers, etc.
|
|
||||||
|
|
||||||
### merge_pull_request(workspace, repository, pullRequestId, options?)
|
|
||||||
|
|
||||||
Merge a pull request into the target branch.
|
|
||||||
|
|
||||||
**Options:**
|
|
||||||
- `mergeStrategy`: 'merge' | 'squash' | 'fastforward' (default: merge)
|
|
||||||
- `deleteSourceBranch`: boolean (default: false)
|
|
||||||
|
|
||||||
### create_pull_request(workspace, repository, title, description, sourceBranch, destinationBranch)
|
|
||||||
|
|
||||||
Create a new pull request.
|
|
||||||
|
|
||||||
**Returns:** Created PR object with ID and URL.
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
The server handles the following errors:
|
|
||||||
|
|
||||||
| Error | Cause |
|
|
||||||
|-------|-------|
|
|
||||||
| `AuthenticationFailed` | Invalid or expired token |
|
|
||||||
| `RateLimited` | API rate limit exceeded (429) |
|
|
||||||
| `RepositoryNotFound` | Workspace/repo doesn't exist |
|
|
||||||
| `PRNotFound` | Pull request ID invalid |
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install dependencies
|
# Run tests
|
||||||
npm install
|
npm test
|
||||||
|
|
||||||
# Run in development mode with hot reload
|
# Run tests in watch mode
|
||||||
npm run dev
|
npm run test:watch
|
||||||
|
|
||||||
# Build for production
|
# Integration tests (requires valid credentials)
|
||||||
npm run build
|
npm run test:integration
|
||||||
|
|
||||||
|
# Type check
|
||||||
|
npx tsc --noEmit
|
||||||
|
|
||||||
|
# Coverage report
|
||||||
|
npm run test:coverage
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── index.ts # MCP server & tool schema definitions
|
||||||
|
├── router.ts # Tool name → API method dispatcher
|
||||||
|
├── bitbucket-client.ts # Axios HTTP client for Bitbucket Cloud API
|
||||||
|
└── config.ts # Credential & default config loading
|
||||||
|
tests/
|
||||||
|
├── unit/ # Mocked unit tests
|
||||||
|
└── integration/ # Live API tests
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentication Details
|
||||||
|
|
||||||
|
This server uses **HTTP Basic Authentication** with the Bitbucket Cloud REST API v2.0:
|
||||||
|
|
||||||
|
- **Username** = your Bitbucket email
|
||||||
|
- **Password** = an [App Password](https://bitbucket.org/account/settings/app-passwords/)
|
||||||
|
|
||||||
|
Credential resolution order:
|
||||||
|
1. Environment variables (`BITBUCKET_MCP_EMAIL` + `BITBUCKET_MCP_TOKEN`)
|
||||||
|
2. `.env` file in project root
|
||||||
|
3. Interactive prompt (development only)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
[MIT](LICENSE)
|
||||||
|
|||||||
3262
package-lock.json
generated
Normal file
3262
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
package.json
Normal file
41
package.json
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "@bitbucket/mcp-server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "MCP Server for Bitbucket Pull Requests - AI agent integration with Bitbucket Cloud REST API",
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"bin": {
|
||||||
|
"@bitbucket/mcp-server": "./dist/index.js"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc && node -e \"const fs=require('fs'); const pkg=JSON.parse(fs.readFileSync('package.json')); pkg.main='dist/index.js'; fs.writeFileSync('package.json',JSON.stringify(pkg,null,2));\"",
|
||||||
|
"start": "tsx src/index.ts",
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
|
"test:integration": "vitest run tests/integration",
|
||||||
|
"test:integration:watch": "vitest tests/integration"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"mcp",
|
||||||
|
"bitbucket",
|
||||||
|
"pull-request",
|
||||||
|
"ai-agent",
|
||||||
|
"atlassian"
|
||||||
|
],
|
||||||
|
"author": "",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
|
"axios": "^1.6.2",
|
||||||
|
"dotenv": "^16.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.19.39",
|
||||||
|
"@vitest/spy": "^1.6.0",
|
||||||
|
"tsx": "^4.7.0",
|
||||||
|
"typescript": "^5.3.0",
|
||||||
|
"vitest": "^4.1.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
612
src/bitbucket-client.ts
Normal file
612
src/bitbucket-client.ts
Normal 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;
|
||||||
175
src/config.ts
Normal file
175
src/config.ts
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration with priority order:
|
||||||
|
* 1. Environment variables
|
||||||
|
* 2. .env file in project root
|
||||||
|
* 3. Interactive prompt (development fallback)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface TokenConfig {
|
||||||
|
email: string;
|
||||||
|
token: string;
|
||||||
|
source: 'env' | 'dotenv' | 'prompt';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DefaultConfig {
|
||||||
|
workspace: string | null;
|
||||||
|
repo: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token configuration loader with priority order.
|
||||||
|
* Loads from environment variable, .env file, or prompts user.
|
||||||
|
*/
|
||||||
|
export class TokenConfigLoader {
|
||||||
|
private static readonly ENV_FILE = '.env';
|
||||||
|
private static readonly EMAIL_ENV = 'BITBUCKET_MCP_EMAIL';
|
||||||
|
private static readonly TOKEN_ENV = 'BITBUCKET_MCP_TOKEN';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load configuration from configured sources with priority order.
|
||||||
|
* @returns TokenConfig with loaded email, token and source
|
||||||
|
*/
|
||||||
|
public static async load(): Promise<TokenConfig> {
|
||||||
|
// Priority 1: Environment variables (highest precedence)
|
||||||
|
const envEmail = process.env[this.EMAIL_ENV];
|
||||||
|
const envToken = process.env[this.TOKEN_ENV];
|
||||||
|
if (envEmail && envToken && this.isValidToken(envToken)) {
|
||||||
|
return { email: envEmail, token: envToken, source: 'env' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug: Log environment check result
|
||||||
|
console.debug(`[DEBUG] Env var check - email present: ${!!envEmail}, token present: ${!!envToken}, valid: ${!!(envEmail && envToken && this.isValidToken(envToken))}`);
|
||||||
|
|
||||||
|
// Priority 2: .env file in project root
|
||||||
|
const { email: dotenvEmail, token: dotenvToken } = this.loadFromDotEnv();
|
||||||
|
console.debug(`[DEBUG] Dotenv config loaded - email: ${!!dotenvEmail}, token: ${!!dotenvToken}`);
|
||||||
|
if (dotenvEmail && dotenvToken && this.isValidToken(dotenvToken)) {
|
||||||
|
return { email: dotenvEmail, token: dotenvToken, source: 'dotenv' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug: Log dotenv check result
|
||||||
|
console.debug(`[DEBUG] Dotenv check - email present: ${!!dotenvEmail}, token present: ${!!dotenvToken}, valid: ${!!(dotenvEmail && dotenvToken && this.isValidToken(dotenvToken))}`);
|
||||||
|
|
||||||
|
// Priority 3: Interactive prompt (fallback)
|
||||||
|
console.warn(
|
||||||
|
'⚠️ No Bitbucket credentials found. Please set one of:'
|
||||||
|
);
|
||||||
|
console.warn(' 1. Export BITBUCKET_MCP_EMAIL=<your_email> and BITBUCKET_MCP_TOKEN=<your_token>');
|
||||||
|
console.warn(' 2. Add to .env: BITBUCKET_MCP_EMAIL=<your_email> and BITBUCKET_MCP_TOKEN=<your_token>');
|
||||||
|
|
||||||
|
return this.promptForCredentials().then(credentials => {
|
||||||
|
if (credentials.email && this.isValidToken(credentials.token)) {
|
||||||
|
return { ...credentials, source: 'prompt' };
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
'Invalid or missing Bitbucket credentials. Aborting.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load email and token from .env file in project root directory.
|
||||||
|
*/
|
||||||
|
private static loadFromDotEnv(): { email: string | null; token: string | null } {
|
||||||
|
console.debug('[DEBUG] Attempting to load credentials from .env file');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Resolve to project root (look for package.json)
|
||||||
|
let currentDir = process.cwd();
|
||||||
|
while (currentDir !== '/') {
|
||||||
|
const pkgPath = path.join(currentDir, 'package.json');
|
||||||
|
if (fs.existsSync(pkgPath)) break;
|
||||||
|
currentDir = path.dirname(currentDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
const envPath = path.join(currentDir, this.ENV_FILE);
|
||||||
|
if (!fs.existsSync(envPath)) return { email: null, token: null };
|
||||||
|
|
||||||
|
const envContent = fs.readFileSync(envPath, 'utf-8');
|
||||||
|
|
||||||
|
const emailMatch = envContent.match(
|
||||||
|
new RegExp(`${this.EMAIL_ENV}\\s*=\\s*([^\\r\\n]+)`, 'i')
|
||||||
|
);
|
||||||
|
const tokenMatch = envContent.match(
|
||||||
|
new RegExp(`${this.TOKEN_ENV}\\s*=\\s*([^\\r\\n]+)`, 'i')
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
email: emailMatch ? emailMatch[1].trim() : null,
|
||||||
|
token: tokenMatch ? tokenMatch[1].trim() : null
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load .env:', error);
|
||||||
|
return { email: null, token: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate Bitbucket access token format.
|
||||||
|
*/
|
||||||
|
private static isValidToken(token: string): boolean {
|
||||||
|
if (!token || token.length < 8) return false;
|
||||||
|
const validPattern = /^[A-Za-z0-9=._\-+/]+$/;
|
||||||
|
return validPattern.test(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompt user for credentials (development fallback).
|
||||||
|
*/
|
||||||
|
private static async promptForCredentials(): Promise<{ email: string; token: string }> {
|
||||||
|
// Skip prompting if running in non-Node.js environment
|
||||||
|
if (typeof require === 'undefined') {
|
||||||
|
return { email: '', token: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const readline = await import('readline');
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout,
|
||||||
|
});
|
||||||
|
|
||||||
|
const askQuestion = (question: string): Promise<string> => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
rl.question(question, (answer: string) => {
|
||||||
|
resolve(answer);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
rl.question(
|
||||||
|
'Enter your Bitbucket email (or press Enter to skip): ',
|
||||||
|
async (email: string) => {
|
||||||
|
const token = await askQuestion('Enter your Bitbucket access token (or press Enter to skip): ');
|
||||||
|
rl.close();
|
||||||
|
resolve({ email: email || '', token: token || '' });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DefaultConfigLoader {
|
||||||
|
private static readonly WORKSPACE_ENV = 'DEFAULT_WORKSPACE';
|
||||||
|
private static readonly REPO_ENV = 'DEFAULT_REPO';
|
||||||
|
|
||||||
|
public static load(): DefaultConfig {
|
||||||
|
return {
|
||||||
|
workspace: process.env[this.WORKSPACE_ENV] || null,
|
||||||
|
repo: process.env[this.REPO_ENV] || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getWorkspace(): string | null {
|
||||||
|
return process.env[this.WORKSPACE_ENV] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getRepo(): string | null {
|
||||||
|
return process.env[this.REPO_ENV] || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TokenConfigLoader;
|
||||||
347
src/index.ts
Normal file
347
src/index.ts
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bitbucket Pull Request MCP Server
|
||||||
|
*
|
||||||
|
* Configuration:
|
||||||
|
* 1. Export BITBUCKET_MCP_TOKEN=<token> (environment variable)
|
||||||
|
* 2. Add to .env file in project root
|
||||||
|
* 3. Interactive prompt (development fallback)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||||
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||||
|
import {
|
||||||
|
ListToolsRequestSchema,
|
||||||
|
CallToolRequestSchema,
|
||||||
|
} from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
|
||||||
|
import { BitbucketRouter } from './router.js';
|
||||||
|
|
||||||
|
const router = new BitbucketRouter();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MCP Server implementation for Bitbucket Pull Requests.
|
||||||
|
*/
|
||||||
|
class BitbucketMCPServer {
|
||||||
|
private server: Server;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.server = new Server({
|
||||||
|
name: 'bitbucket-pullrequests',
|
||||||
|
version: '1.0.0',
|
||||||
|
}, {
|
||||||
|
capabilities: {
|
||||||
|
tools: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle requests
|
||||||
|
this.setupRequestHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup MCP request handlers.
|
||||||
|
*/
|
||||||
|
private setupRequestHandlers(): void {
|
||||||
|
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||||
|
return {
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
name: 'validate_token',
|
||||||
|
description: 'Validate the Bitbucket authentication token',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'list_pull_requests',
|
||||||
|
description: 'List pull requests in a Bitbucket repository',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
workspace: { type: 'string' },
|
||||||
|
repository: { type: 'string' },
|
||||||
|
state: { type: 'string', enum: ['open', 'closed', 'all'] },
|
||||||
|
author: { type: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'get_pull_request',
|
||||||
|
description: 'Get details of a specific pull request',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
workspace: { type: 'string' },
|
||||||
|
repository: { type: 'string' },
|
||||||
|
pullRequestId: { type: 'integer' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'get_pull_request_activities',
|
||||||
|
description: 'Get activities/events for a pull request (opens, closes, comments, reviews, etc.)',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
workspace: { type: 'string' },
|
||||||
|
repository: { type: 'string' },
|
||||||
|
pullRequestId: { type: 'integer' },
|
||||||
|
limit: { type: 'integer', description: 'Max results per page (default 25)' },
|
||||||
|
start: { type: 'integer', description: 'Pagination start index' },
|
||||||
|
},
|
||||||
|
required: ['workspace', 'repository', 'pullRequestId'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'get_pull_request_changes',
|
||||||
|
description: 'Get files changed in a pull request',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
workspace: { type: 'string' },
|
||||||
|
repository: { type: 'string' },
|
||||||
|
pullRequestId: { type: 'integer' },
|
||||||
|
limit: { type: 'integer', description: 'Max results per page (default 25)' },
|
||||||
|
start: { type: 'integer', description: 'Pagination start index' },
|
||||||
|
},
|
||||||
|
required: ['workspace', 'repository', 'pullRequestId'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'get_pull_request_comments',
|
||||||
|
description: 'Get all comments on a pull request',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
workspace: { type: 'string' },
|
||||||
|
repository: { type: 'string' },
|
||||||
|
pullRequestId: { type: 'integer' },
|
||||||
|
limit: { type: 'integer', description: 'Max results per page (default 25)' },
|
||||||
|
start: { type: 'integer', description: 'Pagination start index' },
|
||||||
|
},
|
||||||
|
required: ['workspace', 'repository', 'pullRequestId'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'get_pull_request_comment',
|
||||||
|
description: 'Get a specific comment on a pull request',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
workspace: { type: 'string' },
|
||||||
|
repository: { type: 'string' },
|
||||||
|
pullRequestId: { type: 'integer' },
|
||||||
|
commentId: { type: 'integer', description: 'The comment ID' },
|
||||||
|
},
|
||||||
|
required: ['workspace', 'repository', 'pullRequestId', 'commentId'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'get_pull_request_commits',
|
||||||
|
description: 'Get commits included in a pull request',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
workspace: { type: 'string' },
|
||||||
|
repository: { type: 'string' },
|
||||||
|
pullRequestId: { type: 'integer' },
|
||||||
|
limit: { type: 'integer', description: 'Max results per page (default 25)' },
|
||||||
|
start: { type: 'integer', description: 'Pagination start index' },
|
||||||
|
},
|
||||||
|
required: ['workspace', 'repository', 'pullRequestId'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'get_pull_request_diff',
|
||||||
|
description: 'Get the diff of a pull request',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
workspace: { type: 'string' },
|
||||||
|
repository: { type: 'string' },
|
||||||
|
pullRequestId: { type: 'integer' },
|
||||||
|
path: { type: 'string', description: 'Specific file path to get diff for' },
|
||||||
|
context: { type: 'integer', description: 'Number of context lines around changes' },
|
||||||
|
whitespace: { type: 'string', enum: ['ignore-all', 'ignore-changing', 'ignore-eol', 'show-all'] },
|
||||||
|
},
|
||||||
|
required: ['workspace', 'repository', 'pullRequestId'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'get_pull_request_patch',
|
||||||
|
description: 'Get the raw patch file for a pull request',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
workspace: { type: 'string' },
|
||||||
|
repository: { type: 'string' },
|
||||||
|
pullRequestId: { type: 'integer' },
|
||||||
|
},
|
||||||
|
required: ['workspace', 'repository', 'pullRequestId'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'get_pull_request_participants',
|
||||||
|
description: 'Get participants of a pull request',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
workspace: { type: 'string' },
|
||||||
|
repository: { type: 'string' },
|
||||||
|
pullRequestId: { type: 'integer' },
|
||||||
|
},
|
||||||
|
required: ['workspace', 'repository', 'pullRequestId'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'get_pull_request_reviewers',
|
||||||
|
description: 'Get reviewers of a pull request',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
workspace: { type: 'string' },
|
||||||
|
repository: { type: 'string' },
|
||||||
|
pullRequestId: { type: 'integer' },
|
||||||
|
},
|
||||||
|
required: ['workspace', 'repository', 'pullRequestId'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'get_pull_request_status',
|
||||||
|
description: 'Get status/state of a pull request (open, merged, declined)',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
workspace: { type: 'string' },
|
||||||
|
repository: { type: 'string' },
|
||||||
|
pullRequestId: { type: 'integer' },
|
||||||
|
},
|
||||||
|
required: ['workspace', 'repository', 'pullRequestId'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'get_pull_request_tasks',
|
||||||
|
description: 'Get tasks associated with a pull request',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
workspace: { type: 'string' },
|
||||||
|
repository: { type: 'string' },
|
||||||
|
pullRequestId: { type: 'integer' },
|
||||||
|
},
|
||||||
|
required: ['workspace', 'repository', 'pullRequestId'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'get_pull_request_task_count',
|
||||||
|
description: 'Get count of tasks on a pull request',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
workspace: { type: 'string' },
|
||||||
|
repository: { type: 'string' },
|
||||||
|
pullRequestId: { type: 'integer' },
|
||||||
|
},
|
||||||
|
required: ['workspace', 'repository', 'pullRequestId'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'get_full_pull_request',
|
||||||
|
description: 'Get full detailed information about a pull request with all fields expanded',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
workspace: { type: 'string' },
|
||||||
|
repository: { type: 'string' },
|
||||||
|
pullRequestId: { type: 'integer' },
|
||||||
|
},
|
||||||
|
required: ['workspace', 'repository', 'pullRequestId'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||||
|
const { name, arguments: params } = request.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`Executing tool: ${name}`);
|
||||||
|
console.log('Parameters:', JSON.stringify(params));
|
||||||
|
|
||||||
|
const result = await router.executeTool(name, params as any);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Tool execution failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(result.data, null, 2),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Tool error (${name}):`, error);
|
||||||
|
|
||||||
|
throw {
|
||||||
|
name: 'ToolExecutionError',
|
||||||
|
message: typeof error === 'string' ? error : (error as Error).message || 'Unknown error',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the MCP server.
|
||||||
|
*/
|
||||||
|
async start(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const transport = new StdioServerTransport();
|
||||||
|
await this.server.connect(transport);
|
||||||
|
|
||||||
|
console.log('✅ Bitbucket MCP Server started');
|
||||||
|
console.log(' Name: bitbucket-pullrequests v1.0.0');
|
||||||
|
|
||||||
|
// Log token source for debugging
|
||||||
|
if (process.env.BITBUCKET_MCP_TOKEN) {
|
||||||
|
console.log(' Token source: environment variable');
|
||||||
|
} else {
|
||||||
|
console.log(' Token source: .env file or interactive prompt');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate token on startup
|
||||||
|
try {
|
||||||
|
console.log('🔍 Validating Bitbucket token...');
|
||||||
|
const router = new BitbucketRouter();
|
||||||
|
const validation = await router.executeTool('validate_token', {});
|
||||||
|
if (validation.success) {
|
||||||
|
console.log('✅ Token validation successful');
|
||||||
|
} else {
|
||||||
|
console.error('❌ Token validation failed:', validation.error);
|
||||||
|
console.error(' Please check your BITBUCKET_MCP_TOKEN configuration');
|
||||||
|
console.error(' Required permissions: Repository read access');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Token validation error:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Server startup failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entry point
|
||||||
|
const server = new BitbucketMCPServer();
|
||||||
|
server.start().catch((error) => {
|
||||||
|
console.error('Fatal error:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
489
src/router.ts
Normal file
489
src/router.ts
Normal file
@@ -0,0 +1,489 @@
|
|||||||
|
/**
|
||||||
|
* MCP tool router that maps tool calls to Bitbucket API operations.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BitbucketClient } from './bitbucket-client.js';
|
||||||
|
import { DefaultConfigLoader } from './config.js';
|
||||||
|
|
||||||
|
interface ToolCallParams {
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolResult {
|
||||||
|
success: boolean;
|
||||||
|
data?: any;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Router that maps MCP tool calls to Bitbucket API operations.
|
||||||
|
*/
|
||||||
|
export class BitbucketRouter {
|
||||||
|
private client: BitbucketClient;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.client = new BitbucketClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDefaultParams(params: ToolCallParams): { workspace: string | undefined; repoSlug: string | undefined } {
|
||||||
|
const defaults = DefaultConfigLoader.load();
|
||||||
|
return {
|
||||||
|
workspace: params.workspace || defaults.workspace || undefined,
|
||||||
|
repoSlug: params.repository || params.repo || defaults.repo || undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the Bitbucket token.
|
||||||
|
*/
|
||||||
|
private async validateToken(): Promise<ToolResult> {
|
||||||
|
try {
|
||||||
|
const result = await this.client.validateToken();
|
||||||
|
return {
|
||||||
|
success: result.valid,
|
||||||
|
data: result
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Token validation failed: ${String(error)}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a tool call and return MCP-compatible result.
|
||||||
|
*/
|
||||||
|
async executeTool(
|
||||||
|
toolName: string,
|
||||||
|
params: ToolCallParams
|
||||||
|
): Promise<ToolResult> {
|
||||||
|
switch (toolName) {
|
||||||
|
case 'validate_token':
|
||||||
|
return this.validateToken();
|
||||||
|
|
||||||
|
case 'list_pull_requests':
|
||||||
|
return this.listPullRequests(params);
|
||||||
|
|
||||||
|
case 'get_pull_request':
|
||||||
|
return this.getPullRequest(params);
|
||||||
|
|
||||||
|
case 'get_pull_request_activities':
|
||||||
|
return this.getPullRequestActivities(params);
|
||||||
|
|
||||||
|
case 'get_pull_request_changes':
|
||||||
|
return this.getPullRequestChanges(params);
|
||||||
|
|
||||||
|
case 'get_pull_request_comments':
|
||||||
|
return this.getPullRequestComments(params);
|
||||||
|
|
||||||
|
case 'get_pull_request_comment':
|
||||||
|
return this.getPullRequestComment(params);
|
||||||
|
|
||||||
|
case 'get_pull_request_commits':
|
||||||
|
return this.getPullRequestCommits(params);
|
||||||
|
|
||||||
|
case 'get_pull_request_diff':
|
||||||
|
return this.getPullRequestDiff(params);
|
||||||
|
|
||||||
|
case 'get_pull_request_patch':
|
||||||
|
return this.getPullRequestPatch(params);
|
||||||
|
|
||||||
|
case 'get_pull_request_participants':
|
||||||
|
return this.getPullRequestParticipants(params);
|
||||||
|
|
||||||
|
case 'get_pull_request_reviewers':
|
||||||
|
return this.getPullRequestReviewers(params);
|
||||||
|
|
||||||
|
case 'get_pull_request_status':
|
||||||
|
return this.getPullRequestStatus(params);
|
||||||
|
|
||||||
|
case 'get_pull_request_tasks':
|
||||||
|
return this.getPullRequestTasks(params);
|
||||||
|
|
||||||
|
case 'get_pull_request_task_count':
|
||||||
|
return this.getPullRequestTaskCount(params);
|
||||||
|
|
||||||
|
case 'get_full_pull_request':
|
||||||
|
return this.getFullPullRequest(params);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Unknown tool: ${toolName}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List pull requests in a repository.
|
||||||
|
*/
|
||||||
|
private async listPullRequests(
|
||||||
|
params: ToolCallParams
|
||||||
|
): Promise<ToolResult> {
|
||||||
|
try {
|
||||||
|
const { workspace, repoSlug } = this.getDefaultParams(params);
|
||||||
|
|
||||||
|
if (!workspace || !repoSlug) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Missing required parameters: workspace and repository'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to open PRs if state not specified
|
||||||
|
const options = params.state ? { state: params.state } : undefined;
|
||||||
|
|
||||||
|
const prs = await this.client.listPullRequests(workspace, repoSlug, options);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
pull_requests: prs,
|
||||||
|
count: prs.length
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `List pull requests failed: ${typeof error === 'string' ? error : String(error)}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific pull request.
|
||||||
|
*/
|
||||||
|
private async getPullRequest(
|
||||||
|
params: ToolCallParams
|
||||||
|
): Promise<ToolResult> {
|
||||||
|
try {
|
||||||
|
const { workspace, repoSlug } = this.getDefaultParams(params);
|
||||||
|
const prId = parseInt(params.pullRequestId || params.pr_id, 10);
|
||||||
|
|
||||||
|
if (!workspace || !repoSlug || isNaN(prId)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Missing required parameters'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const pr = await this.client.getPullRequest(workspace, repoSlug, prId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: pr
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Get pull request failed: ${typeof error === 'string' ? error : String(error)}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get pull request activities (events/actions).
|
||||||
|
*/
|
||||||
|
private async getPullRequestActivities(
|
||||||
|
params: ToolCallParams
|
||||||
|
): Promise<ToolResult> {
|
||||||
|
try {
|
||||||
|
const { workspace, repoSlug } = this.getDefaultParams(params);
|
||||||
|
const prId = parseInt(params.pullRequestId || params.pr_id, 10);
|
||||||
|
|
||||||
|
if (!workspace || !repoSlug || isNaN(prId)) {
|
||||||
|
return { success: false, error: 'Missing required parameters' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.client.getPullRequestActivities(workspace, repoSlug, prId, {
|
||||||
|
limit: params.limit,
|
||||||
|
start: params.start
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, data: result };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: `Get activities failed: ${String(error)}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get pull request changes (files modified).
|
||||||
|
*/
|
||||||
|
private async getPullRequestChanges(
|
||||||
|
params: ToolCallParams
|
||||||
|
): Promise<ToolResult> {
|
||||||
|
try {
|
||||||
|
const { workspace, repoSlug } = this.getDefaultParams(params);
|
||||||
|
const prId = parseInt(params.pullRequestId || params.pr_id, 10);
|
||||||
|
|
||||||
|
if (!workspace || !repoSlug || isNaN(prId)) {
|
||||||
|
return { success: false, error: 'Missing required parameters' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.client.getPullRequestChanges(workspace, repoSlug, prId, {
|
||||||
|
limit: params.limit,
|
||||||
|
start: params.start
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, data: result };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: `Get changes failed: ${String(error)}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get pull request comments.
|
||||||
|
*/
|
||||||
|
private async getPullRequestComments(
|
||||||
|
params: ToolCallParams
|
||||||
|
): Promise<ToolResult> {
|
||||||
|
try {
|
||||||
|
const { workspace, repoSlug } = this.getDefaultParams(params);
|
||||||
|
const prId = parseInt(params.pullRequestId || params.pr_id, 10);
|
||||||
|
|
||||||
|
if (!workspace || !repoSlug || isNaN(prId)) {
|
||||||
|
return { success: false, error: 'Missing required parameters' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.client.getPullRequestComments(workspace, repoSlug, prId, {
|
||||||
|
limit: params.limit,
|
||||||
|
start: params.start
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, data: result };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: `Get comments failed: ${String(error)}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific pull request comment.
|
||||||
|
*/
|
||||||
|
private async getPullRequestComment(
|
||||||
|
params: ToolCallParams
|
||||||
|
): Promise<ToolResult> {
|
||||||
|
try {
|
||||||
|
const { workspace, repoSlug } = this.getDefaultParams(params);
|
||||||
|
const prId = parseInt(params.pullRequestId || params.pr_id, 10);
|
||||||
|
const commentId = parseInt(params.commentId || params.comment_id, 10);
|
||||||
|
|
||||||
|
if (!workspace || !repoSlug || isNaN(prId) || isNaN(commentId)) {
|
||||||
|
return { success: false, error: 'Missing required parameters' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.client.getPullRequestComment(workspace, repoSlug, prId, commentId);
|
||||||
|
|
||||||
|
return { success: true, data: result };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: `Get comment failed: ${String(error)}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get commits in a pull request.
|
||||||
|
*/
|
||||||
|
private async getPullRequestCommits(
|
||||||
|
params: ToolCallParams
|
||||||
|
): Promise<ToolResult> {
|
||||||
|
try {
|
||||||
|
const { workspace, repoSlug } = this.getDefaultParams(params);
|
||||||
|
const prId = parseInt(params.pullRequestId || params.pr_id, 10);
|
||||||
|
|
||||||
|
if (!workspace || !repoSlug || isNaN(prId)) {
|
||||||
|
return { success: false, error: 'Missing required parameters' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.client.getPullRequestCommits(workspace, repoSlug, prId, {
|
||||||
|
limit: params.limit,
|
||||||
|
start: params.start
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, data: result };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: `Get commits failed: ${String(error)}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get pull request diff.
|
||||||
|
*/
|
||||||
|
private async getPullRequestDiff(
|
||||||
|
params: ToolCallParams
|
||||||
|
): Promise<ToolResult> {
|
||||||
|
try {
|
||||||
|
const { workspace, repoSlug } = this.getDefaultParams(params);
|
||||||
|
const prId = parseInt(params.pullRequestId || params.pr_id, 10);
|
||||||
|
|
||||||
|
if (!workspace || !repoSlug || isNaN(prId)) {
|
||||||
|
return { success: false, error: 'Missing required parameters' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.client.getPullRequestDiff(workspace, repoSlug, prId, {
|
||||||
|
context: params.context,
|
||||||
|
path: params.path,
|
||||||
|
whitespace: params.whitespace
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, data: result };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: `Get diff failed: ${String(error)}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get pull request patch.
|
||||||
|
*/
|
||||||
|
private async getPullRequestPatch(
|
||||||
|
params: ToolCallParams
|
||||||
|
): Promise<ToolResult> {
|
||||||
|
try {
|
||||||
|
const { workspace, repoSlug } = this.getDefaultParams(params);
|
||||||
|
const prId = parseInt(params.pullRequestId || params.pr_id, 10);
|
||||||
|
|
||||||
|
if (!workspace || !repoSlug || isNaN(prId)) {
|
||||||
|
return { success: false, error: 'Missing required parameters' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.client.getPullRequestPatch(workspace, repoSlug, prId);
|
||||||
|
|
||||||
|
return { success: true, data: result };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: `Get patch failed: ${String(error)}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get pull request participants.
|
||||||
|
*/
|
||||||
|
private async getPullRequestParticipants(
|
||||||
|
params: ToolCallParams
|
||||||
|
): Promise<ToolResult> {
|
||||||
|
try {
|
||||||
|
const { workspace, repoSlug } = this.getDefaultParams(params);
|
||||||
|
const prId = parseInt(params.pullRequestId || params.pr_id, 10);
|
||||||
|
|
||||||
|
if (!workspace || !repoSlug || isNaN(prId)) {
|
||||||
|
return { success: false, error: 'Missing required parameters' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.client.getPullRequestParticipants(workspace, repoSlug, prId);
|
||||||
|
|
||||||
|
return { success: true, data: result };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: `Get participants failed: ${String(error)}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get pull request reviewers.
|
||||||
|
*/
|
||||||
|
private async getPullRequestReviewers(
|
||||||
|
params: ToolCallParams
|
||||||
|
): Promise<ToolResult> {
|
||||||
|
try {
|
||||||
|
const { workspace, repoSlug } = this.getDefaultParams(params);
|
||||||
|
const prId = parseInt(params.pullRequestId || params.pr_id, 10);
|
||||||
|
|
||||||
|
if (!workspace || !repoSlug || isNaN(prId)) {
|
||||||
|
return { success: false, error: 'Missing required parameters' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.client.getPullRequestReviewers(workspace, repoSlug, prId);
|
||||||
|
|
||||||
|
return { success: true, data: result };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: `Get reviewers failed: ${String(error)}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get pull request status.
|
||||||
|
*/
|
||||||
|
private async getPullRequestStatus(
|
||||||
|
params: ToolCallParams
|
||||||
|
): Promise<ToolResult> {
|
||||||
|
try {
|
||||||
|
const { workspace, repoSlug } = this.getDefaultParams(params);
|
||||||
|
const prId = parseInt(params.pullRequestId || params.pr_id, 10);
|
||||||
|
|
||||||
|
if (!workspace || !repoSlug || isNaN(prId)) {
|
||||||
|
return { success: false, error: 'Missing required parameters' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.client.getPullRequestStatus(workspace, repoSlug, prId);
|
||||||
|
|
||||||
|
return { success: true, data: result };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: `Get status failed: ${String(error)}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get pull request tasks.
|
||||||
|
*/
|
||||||
|
private async getPullRequestTasks(
|
||||||
|
params: ToolCallParams
|
||||||
|
): Promise<ToolResult> {
|
||||||
|
try {
|
||||||
|
const { workspace, repoSlug } = this.getDefaultParams(params);
|
||||||
|
const prId = parseInt(params.pullRequestId || params.pr_id, 10);
|
||||||
|
|
||||||
|
if (!workspace || !repoSlug || isNaN(prId)) {
|
||||||
|
return { success: false, error: 'Missing required parameters' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.client.getPullRequestTasks(workspace, repoSlug, prId);
|
||||||
|
|
||||||
|
return { success: true, data: result };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: `Get tasks failed: ${String(error)}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get pull request task count.
|
||||||
|
*/
|
||||||
|
private async getPullRequestTaskCount(
|
||||||
|
params: ToolCallParams
|
||||||
|
): Promise<ToolResult> {
|
||||||
|
try {
|
||||||
|
const { workspace, repoSlug } = this.getDefaultParams(params);
|
||||||
|
const prId = parseInt(params.pullRequestId || params.pr_id, 10);
|
||||||
|
|
||||||
|
if (!workspace || !repoSlug || isNaN(prId)) {
|
||||||
|
return { success: false, error: 'Missing required parameters' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.client.getPullRequestTaskCount(workspace, repoSlug, prId);
|
||||||
|
|
||||||
|
return { success: true, data: result };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: `Get task count failed: ${String(error)}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get full pull request details.
|
||||||
|
*/
|
||||||
|
private async getFullPullRequest(
|
||||||
|
params: ToolCallParams
|
||||||
|
): Promise<ToolResult> {
|
||||||
|
try {
|
||||||
|
const { workspace, repoSlug } = this.getDefaultParams(params);
|
||||||
|
const prId = parseInt(params.pullRequestId || params.pr_id, 10);
|
||||||
|
|
||||||
|
if (!workspace || !repoSlug || isNaN(prId)) {
|
||||||
|
return { success: false, error: 'Missing required parameters' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.client.getFullPullRequest(workspace, repoSlug, prId);
|
||||||
|
|
||||||
|
return { success: true, data: result };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: `Get full PR failed: ${String(error)}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BitbucketRouter;
|
||||||
87
test-client.js
Normal file
87
test-client.js
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple test client for Bitbucket MCP Server.
|
||||||
|
* Tests the router and API client functionality.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BitbucketRouter } from './dist/router.js';
|
||||||
|
|
||||||
|
const router = new BitbucketRouter();
|
||||||
|
|
||||||
|
console.log('🧪 Testing Bitbucket MCP Router\n');
|
||||||
|
|
||||||
|
// Test 1: List pull requests (will fail without valid token, but tests structure)
|
||||||
|
console.log('Test 1: list_pull_requests');
|
||||||
|
try {
|
||||||
|
const result = await router.executeTool('list_pull_requests', {
|
||||||
|
workspace: 'test-workspace',
|
||||||
|
repository: 'test-repo'
|
||||||
|
});
|
||||||
|
console.log('Result:', JSON.stringify(result, null, 2));
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Expected error (no valid token):', error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 2: Get pull request
|
||||||
|
console.log('\nTest 2: get_pull_request');
|
||||||
|
try {
|
||||||
|
const result = await router.executeTool('get_pull_request', {
|
||||||
|
workspace: 'test-workspace',
|
||||||
|
repository: 'test-repo',
|
||||||
|
pullRequestId: 12345
|
||||||
|
});
|
||||||
|
console.log('Result:', JSON.stringify(result, null, 2));
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Expected error (no valid token):', error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 3: Get PR status
|
||||||
|
console.log('\nTest 3: get_pull_request_status');
|
||||||
|
try {
|
||||||
|
const result = await router.executeTool('get_pull_request_status', {
|
||||||
|
workspace: 'test-workspace',
|
||||||
|
repository: 'test-repo',
|
||||||
|
pullRequestId: 12345
|
||||||
|
});
|
||||||
|
console.log('Result:', JSON.stringify(result, null, 2));
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Expected error (no valid token):', error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 4: Get PR comments
|
||||||
|
console.log('\nTest 4: get_pull_request_comments');
|
||||||
|
try {
|
||||||
|
const result = await router.executeTool('get_pull_request_comments', {
|
||||||
|
workspace: 'test-workspace',
|
||||||
|
repository: 'test-repo',
|
||||||
|
pullRequestId: 12345
|
||||||
|
});
|
||||||
|
console.log('Result:', JSON.stringify(result, null, 2));
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Expected error (no valid token):', error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 6: Invalid tool name
|
||||||
|
console.log('\nTest 5: Unknown tool');
|
||||||
|
try {
|
||||||
|
const result = await router.executeTool('invalid_tool', {});
|
||||||
|
console.log('Result:', JSON.stringify(result, null, 2));
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Error:', error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 5: Get PR activities
|
||||||
|
console.log('\nTest 5: get_pull_request_activities');
|
||||||
|
try {
|
||||||
|
const result = await router.executeTool('get_pull_request_activities', {
|
||||||
|
workspace: 'test-workspace',
|
||||||
|
repository: 'test-repo',
|
||||||
|
pullRequestId: 12345
|
||||||
|
});
|
||||||
|
console.log('Result:', JSON.stringify(result, null, 2));
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Expected error (no valid token):', error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n✅ All tests completed!');
|
||||||
294
tests/integration/bitbucket-api.test.ts
Normal file
294
tests/integration/bitbucket-api.test.ts
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
/**
|
||||||
|
* Integration tests for Bitbucket MCP Server
|
||||||
|
* Tests against real Bitbucket API with iland-software-engineering/1111-frontend repo
|
||||||
|
*
|
||||||
|
* Requires BITBUCKET_MCP_EMAIL and BITBUCKET_MCP_TOKEN in .env or environment variable.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeAll } from 'vitest';
|
||||||
|
import { BitbucketClient } from '../../src/bitbucket-client.js';
|
||||||
|
import { BitbucketRouter, ToolResult } from '../../src/router.js';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as dotenv from 'dotenv';
|
||||||
|
|
||||||
|
const WORKSPACE = 'iland-software-engineering';
|
||||||
|
const REPO_SLUG = '1111-front';
|
||||||
|
|
||||||
|
dotenv.config({ path: path.resolve(process.cwd(), '.env') });
|
||||||
|
|
||||||
|
const EMAIL = process.env.BITBUCKET_MCP_EMAIL || '';
|
||||||
|
const TOKEN = process.env.BITBUCKET_MCP_TOKEN || '';
|
||||||
|
const HAS_TOKEN = !!EMAIL && !!TOKEN && TOKEN.length >= 20;
|
||||||
|
|
||||||
|
describe('Bitbucket API Integration Tests', () => {
|
||||||
|
let client: BitbucketClient;
|
||||||
|
let router: BitbucketRouter;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
client = new BitbucketClient();
|
||||||
|
router = new BitbucketRouter();
|
||||||
|
}, 35000);
|
||||||
|
|
||||||
|
describe('Pull Request Listing', () => {
|
||||||
|
it('should list pull requests', async () => {
|
||||||
|
if (!HAS_TOKEN) return;
|
||||||
|
const prs = await client.listPullRequests(WORKSPACE, REPO_SLUG);
|
||||||
|
expect(Array.isArray(prs)).toBe(true);
|
||||||
|
if (prs.length > 0) {
|
||||||
|
expect(prs[0]).toHaveProperty('id');
|
||||||
|
expect(prs[0]).toHaveProperty('title');
|
||||||
|
expect(prs[0]).toHaveProperty('state');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should list open pull requests', async () => {
|
||||||
|
if (!HAS_TOKEN) return;
|
||||||
|
const prs = await client.listPullRequests(WORKSPACE, REPO_SLUG, { state: 'open' });
|
||||||
|
expect(Array.isArray(prs)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should list closed pull requests', async () => {
|
||||||
|
if (!HAS_TOKEN) return;
|
||||||
|
const prs = await client.listPullRequests(WORKSPACE, REPO_SLUG, { state: 'closed' });
|
||||||
|
expect(Array.isArray(prs)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Pull Request Details', () => {
|
||||||
|
it('should get a specific pull request', async () => {
|
||||||
|
if (!HAS_TOKEN) return;
|
||||||
|
const prs = await client.listPullRequests(WORKSPACE, REPO_SLUG, { state: 'open' });
|
||||||
|
|
||||||
|
if (prs.length === 0) {
|
||||||
|
const closedPrs = await client.listPullRequests(WORKSPACE, REPO_SLUG, { state: 'closed' });
|
||||||
|
if (closedPrs.length === 0) {
|
||||||
|
console.log('⚠️ No PRs found in repository, skipping specific PR test');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pr = await client.getPullRequest(WORKSPACE, REPO_SLUG, closedPrs[0].id);
|
||||||
|
expect(pr).toHaveProperty('id');
|
||||||
|
expect(pr).toHaveProperty('title');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pr = await client.getPullRequest(WORKSPACE, REPO_SLUG, prs[0].id);
|
||||||
|
expect(pr).toHaveProperty('id');
|
||||||
|
expect(pr).toHaveProperty('title');
|
||||||
|
expect(pr.id).toBe(prs[0].id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get pull request status', async () => {
|
||||||
|
if (!HAS_TOKEN) return;
|
||||||
|
const prs = await client.listPullRequests(WORKSPACE, REPO_SLUG);
|
||||||
|
if (prs.length === 0) {
|
||||||
|
console.log('⚠️ No PRs found, skipping status test');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = await client.getPullRequestStatus(WORKSPACE, REPO_SLUG, prs[0].id);
|
||||||
|
expect(status).toHaveProperty('id');
|
||||||
|
expect(status).toHaveProperty('state');
|
||||||
|
expect(status).toHaveProperty('title');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get pull request reviewers', async () => {
|
||||||
|
if (!HAS_TOKEN) return;
|
||||||
|
const prs = await client.listPullRequests(WORKSPACE, REPO_SLUG);
|
||||||
|
if (prs.length === 0) {
|
||||||
|
console.log('⚠️ No PRs found, skipping reviewers test');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reviewers = await client.getPullRequestReviewers(WORKSPACE, REPO_SLUG, prs[0].id);
|
||||||
|
expect(Array.isArray(reviewers)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get pull request participants', async () => {
|
||||||
|
if (!HAS_TOKEN) return;
|
||||||
|
const prs = await client.listPullRequests(WORKSPACE, REPO_SLUG);
|
||||||
|
if (prs.length === 0) {
|
||||||
|
console.log('⚠️ No PRs found, skipping participants test');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const participants = await client.getPullRequestParticipants(WORKSPACE, REPO_SLUG, prs[0].id);
|
||||||
|
expect(participants).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Pull Request Activities & Comments', () => {
|
||||||
|
it('should get pull request activities', async () => {
|
||||||
|
if (!HAS_TOKEN) return;
|
||||||
|
const prs = await client.listPullRequests(WORKSPACE, REPO_SLUG);
|
||||||
|
if (prs.length === 0) {
|
||||||
|
console.log('⚠️ No PRs found, skipping activities test');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activities = await client.getPullRequestActivities(WORKSPACE, REPO_SLUG, prs[0].id);
|
||||||
|
expect(activities).toBeDefined();
|
||||||
|
expect(activities).toHaveProperty('values');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get pull request comments', async () => {
|
||||||
|
if (!HAS_TOKEN) return;
|
||||||
|
const prs = await client.listPullRequests(WORKSPACE, REPO_SLUG);
|
||||||
|
if (prs.length === 0) {
|
||||||
|
console.log('⚠️ No PRs found, skipping comments test');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const comments = await client.getPullRequestComments(WORKSPACE, REPO_SLUG, prs[0].id);
|
||||||
|
expect(comments).toBeDefined();
|
||||||
|
expect(comments).toHaveProperty('values');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get pull request commits', async () => {
|
||||||
|
if (!HAS_TOKEN) return;
|
||||||
|
const prs = await client.listPullRequests(WORKSPACE, REPO_SLUG);
|
||||||
|
if (prs.length === 0) {
|
||||||
|
console.log('⚠️ No PRs found, skipping commits test');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const commits = await client.getPullRequestCommits(WORKSPACE, REPO_SLUG, prs[0].id);
|
||||||
|
expect(commits).toBeDefined();
|
||||||
|
expect(commits).toHaveProperty('values');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Pull Request Changes & Diff', () => {
|
||||||
|
it('should get pull request changes', async () => {
|
||||||
|
if (!HAS_TOKEN) return;
|
||||||
|
const prs = await client.listPullRequests(WORKSPACE, REPO_SLUG);
|
||||||
|
if (prs.length === 0) {
|
||||||
|
console.log('⚠️ No PRs found, skipping changes test');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const changes = await client.getPullRequestChanges(WORKSPACE, REPO_SLUG, prs[0].id);
|
||||||
|
expect(changes).toBeDefined();
|
||||||
|
expect(changes).toHaveProperty('values');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get pull request diff', async () => {
|
||||||
|
if (!HAS_TOKEN) return;
|
||||||
|
const prs = await client.listPullRequests(WORKSPACE, REPO_SLUG);
|
||||||
|
if (prs.length === 0) {
|
||||||
|
console.log('⚠️ No PRs found, skipping diff test');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const diff = await client.getPullRequestDiff(WORKSPACE, REPO_SLUG, prs[0].id);
|
||||||
|
expect(diff).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get pull request patch', async () => {
|
||||||
|
if (!HAS_TOKEN) return;
|
||||||
|
const prs = await client.listPullRequests(WORKSPACE, REPO_SLUG);
|
||||||
|
if (prs.length === 0) {
|
||||||
|
console.log('⚠️ No PRs found, skipping patch test');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const patch = await client.getPullRequestPatch(WORKSPACE, REPO_SLUG, prs[0].id);
|
||||||
|
expect(patch).toHaveProperty('patch');
|
||||||
|
expect(typeof patch.patch).toBe('string');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Pull Request Tasks', () => {
|
||||||
|
it('should get pull request tasks', async () => {
|
||||||
|
if (!HAS_TOKEN) return;
|
||||||
|
const prs = await client.listPullRequests(WORKSPACE, REPO_SLUG);
|
||||||
|
if (prs.length === 0) {
|
||||||
|
console.log('⚠️ No PRs found, skipping tasks test');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasks = await client.getPullRequestTasks(WORKSPACE, REPO_SLUG, prs[0].id);
|
||||||
|
expect(tasks).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get pull request task count', async () => {
|
||||||
|
if (!HAS_TOKEN) return;
|
||||||
|
const prs = await client.listPullRequests(WORKSPACE, REPO_SLUG);
|
||||||
|
if (prs.length === 0) {
|
||||||
|
console.log('⚠️ No PRs found, skipping task count test');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskCount = await client.getPullRequestTaskCount(WORKSPACE, REPO_SLUG, prs[0].id);
|
||||||
|
expect(taskCount).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Full Pull Request', () => {
|
||||||
|
it('should get full pull request details', async () => {
|
||||||
|
if (!HAS_TOKEN) return;
|
||||||
|
const prs = await client.listPullRequests(WORKSPACE, REPO_SLUG);
|
||||||
|
if (prs.length === 0) {
|
||||||
|
console.log('⚠️ No PRs found, skipping full PR test');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullPR = await client.getFullPullRequest(WORKSPACE, REPO_SLUG, prs[0].id);
|
||||||
|
expect(fullPR).toHaveProperty('id');
|
||||||
|
expect(fullPR).toHaveProperty('title');
|
||||||
|
expect(fullPR).toHaveProperty('source');
|
||||||
|
expect(fullPR).toHaveProperty('destination');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Bitbucket Router Integration', () => {
|
||||||
|
let router: BitbucketRouter;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
delete process.env.DEFAULT_WORKSPACE;
|
||||||
|
delete process.env.DEFAULT_REPO;
|
||||||
|
router = new BitbucketRouter();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
delete process.env.DEFAULT_WORKSPACE;
|
||||||
|
delete process.env.DEFAULT_REPO;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Router parameter validation', () => {
|
||||||
|
it('should require workspace for list_pull_requests', async () => {
|
||||||
|
const result = await router.executeTool('list_pull_requests', {
|
||||||
|
repository: REPO_SLUG
|
||||||
|
}) as ToolResult;
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require workspace and pullRequestId for get_pull_request', async () => {
|
||||||
|
const result = await router.executeTool('get_pull_request', {
|
||||||
|
workspace: WORKSPACE
|
||||||
|
}) as ToolResult;
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle unknown tool', async () => {
|
||||||
|
const result = await router.executeTool('unknown_tool', {}) as ToolResult;
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('Unknown tool');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Error Handling', () => {
|
||||||
|
it('should handle invalid pull request ID', async () => {
|
||||||
|
if (!HAS_TOKEN) return;
|
||||||
|
try {
|
||||||
|
await new BitbucketClient().getPullRequest(WORKSPACE, REPO_SLUG, 999999999);
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
141
tests/unit/config.test.ts
Normal file
141
tests/unit/config.test.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for TokenConfigLoader
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { TokenConfigLoader, TokenConfig, DefaultConfigLoader, DefaultConfig } from '../../src/config.js';
|
||||||
|
|
||||||
|
describe('TokenConfigLoader', () => {
|
||||||
|
const originalEnv = { ...process.env };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env = { ...originalEnv };
|
||||||
|
delete process.env.BITBUCKET_MCP_TOKEN;
|
||||||
|
delete process.env.DEFAULT_WORKSPACE;
|
||||||
|
delete process.env.DEFAULT_REPO;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = originalEnv;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('token validation', () => {
|
||||||
|
it('should accept valid tokens (alphanumeric with special chars)', () => {
|
||||||
|
const validPattern = /^[A-Za-z0-9=._-]+$/;
|
||||||
|
|
||||||
|
expect(validPattern.test('abc123def45678901234567')).toBe(true);
|
||||||
|
expect(validPattern.test('ATBB1234567890abcdefgh')).toBe(true);
|
||||||
|
expect(validPattern.test('Bearer_token.with=chars12')).toBe(true);
|
||||||
|
expect(validPattern.test('a'.repeat(20))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject tokens that are too short', () => {
|
||||||
|
const shortToken = 'abc123';
|
||||||
|
expect(shortToken.length).toBeLessThan(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject tokens with invalid characters', () => {
|
||||||
|
const invalidToken = 'token with spaces!@#$%';
|
||||||
|
const validPattern = /^[A-Za-z0-9=._-]+$/;
|
||||||
|
expect(validPattern.test(invalidToken)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('environment variable loading', () => {
|
||||||
|
it('should load token from BITBUCKET_MCP_TOKEN env var', async () => {
|
||||||
|
process.env.BITBUCKET_MCP_EMAIL = 'test@example.com';
|
||||||
|
process.env.BITBUCKET_MCP_TOKEN = 'valid_test_token_12345678901234567';
|
||||||
|
|
||||||
|
const config = await TokenConfigLoader.load();
|
||||||
|
|
||||||
|
expect(config.token).toBe('valid_test_token_12345678901234567');
|
||||||
|
expect(config.source).toBe('env');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TokenConfig interface', () => {
|
||||||
|
it('should return correct config structure for env source', async () => {
|
||||||
|
process.env.BITBUCKET_MCP_TOKEN = 'test_token_12345678901234567';
|
||||||
|
|
||||||
|
const config = await TokenConfigLoader.load();
|
||||||
|
|
||||||
|
expect(config).toHaveProperty('token');
|
||||||
|
expect(config).toHaveProperty('source');
|
||||||
|
expect(['env', 'dotenv', 'prompt']).toContain(config.source);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DefaultConfigLoader', () => {
|
||||||
|
const originalEnv = { ...process.env };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env = { ...originalEnv };
|
||||||
|
delete process.env.DEFAULT_WORKSPACE;
|
||||||
|
delete process.env.DEFAULT_REPO;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = originalEnv;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('load', () => {
|
||||||
|
it('should return null values when env vars are not set', () => {
|
||||||
|
const config = DefaultConfigLoader.load();
|
||||||
|
|
||||||
|
expect(config.workspace).toBeNull();
|
||||||
|
expect(config.repo).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return workspace and repo from env vars when set', () => {
|
||||||
|
process.env.DEFAULT_WORKSPACE = 'my-workspace';
|
||||||
|
process.env.DEFAULT_REPO = 'my-repo';
|
||||||
|
|
||||||
|
const config = DefaultConfigLoader.load();
|
||||||
|
|
||||||
|
expect(config.workspace).toBe('my-workspace');
|
||||||
|
expect(config.repo).toBe('my-repo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return partial config when only workspace is set', () => {
|
||||||
|
process.env.DEFAULT_WORKSPACE = 'only-workspace';
|
||||||
|
|
||||||
|
const config = DefaultConfigLoader.load();
|
||||||
|
|
||||||
|
expect(config.workspace).toBe('only-workspace');
|
||||||
|
expect(config.repo).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return partial config when only repo is set', () => {
|
||||||
|
process.env.DEFAULT_REPO = 'only-repo';
|
||||||
|
|
||||||
|
const config = DefaultConfigLoader.load();
|
||||||
|
|
||||||
|
expect(config.workspace).toBeNull();
|
||||||
|
expect(config.repo).toBe('only-repo');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getWorkspace', () => {
|
||||||
|
it('should return null when DEFAULT_WORKSPACE is not set', () => {
|
||||||
|
expect(DefaultConfigLoader.getWorkspace()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return workspace value when set', () => {
|
||||||
|
process.env.DEFAULT_WORKSPACE = 'test-workspace';
|
||||||
|
expect(DefaultConfigLoader.getWorkspace()).toBe('test-workspace');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getRepo', () => {
|
||||||
|
it('should return null when DEFAULT_REPO is not set', () => {
|
||||||
|
expect(DefaultConfigLoader.getRepo()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return repo value when set', () => {
|
||||||
|
process.env.DEFAULT_REPO = 'test-repo';
|
||||||
|
expect(DefaultConfigLoader.getRepo()).toBe('test-repo');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
444
tests/unit/router.test.ts
Normal file
444
tests/unit/router.test.ts
Normal file
@@ -0,0 +1,444 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for BitbucketRouter
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { BitbucketRouter, ToolResult } from '../../src/router.js';
|
||||||
|
|
||||||
|
// Mock the BitbucketClient
|
||||||
|
vi.mock('../../src/bitbucket-client.js', () => {
|
||||||
|
return {
|
||||||
|
BitbucketClient: vi.fn().mockImplementation(() => ({
|
||||||
|
listPullRequests: vi.fn().mockResolvedValue([
|
||||||
|
{ id: 1, title: 'Test PR 1', state: 'OPEN' },
|
||||||
|
{ id: 2, title: 'Test PR 2', state: 'MERGED' }
|
||||||
|
]),
|
||||||
|
getPullRequest: vi.fn().mockResolvedValue({
|
||||||
|
id: 1,
|
||||||
|
title: 'Test PR',
|
||||||
|
state: 'OPEN',
|
||||||
|
source: { branch: { name: 'feature' } },
|
||||||
|
destination: { branch: { name: 'main' } }
|
||||||
|
}),
|
||||||
|
getPullRequestStatus: vi.fn().mockResolvedValue({
|
||||||
|
id: 1,
|
||||||
|
title: 'Test PR',
|
||||||
|
state: 'OPEN',
|
||||||
|
status: 'NORMAL'
|
||||||
|
}),
|
||||||
|
getPullRequestActivities: vi.fn().mockResolvedValue({
|
||||||
|
values: [{ action: 'OPEN' }]
|
||||||
|
}),
|
||||||
|
getPullRequestChanges: vi.fn().mockResolvedValue({
|
||||||
|
values: [{ type: 'modified', path: 'src/test.ts' }]
|
||||||
|
}),
|
||||||
|
getPullRequestComments: vi.fn().mockResolvedValue({
|
||||||
|
values: [{ id: 1, content: { raw: 'Test comment' } }]
|
||||||
|
}),
|
||||||
|
getPullRequestComment: vi.fn().mockResolvedValue({
|
||||||
|
id: 1,
|
||||||
|
content: { raw: 'Test comment' }
|
||||||
|
}),
|
||||||
|
getPullRequestCommits: vi.fn().mockResolvedValue({
|
||||||
|
values: [{ hash: 'abc123' }]
|
||||||
|
}),
|
||||||
|
getPullRequestDiff: vi.fn().mockResolvedValue({
|
||||||
|
diff: '--- test.ts\n+++ test.ts\n@@ -1 +1 @@'
|
||||||
|
}),
|
||||||
|
getPullRequestPatch: vi.fn().mockResolvedValue({
|
||||||
|
patch: '--- original\n+++ modified'
|
||||||
|
}),
|
||||||
|
getPullRequestParticipants: vi.fn().mockResolvedValue({
|
||||||
|
values: [{ user: { display_name: 'Test User' } }]
|
||||||
|
}),
|
||||||
|
getPullRequestReviewers: vi.fn().mockResolvedValue([
|
||||||
|
{ user: { display_name: 'Reviewer 1' } }
|
||||||
|
]),
|
||||||
|
getPullRequestTasks: vi.fn().mockResolvedValue({
|
||||||
|
values: []
|
||||||
|
}),
|
||||||
|
getPullRequestTaskCount: vi.fn().mockResolvedValue({ count: 0 }),
|
||||||
|
getFullPullRequest: vi.fn().mockResolvedValue({
|
||||||
|
id: 1,
|
||||||
|
title: 'Test PR',
|
||||||
|
description: 'Test description'
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('BitbucketRouter', () => {
|
||||||
|
let router: BitbucketRouter;
|
||||||
|
const originalEnv = { ...process.env };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env = { ...originalEnv };
|
||||||
|
delete process.env.DEFAULT_WORKSPACE;
|
||||||
|
delete process.env.DEFAULT_REPO;
|
||||||
|
router = new BitbucketRouter();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = originalEnv;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('list_pull_requests', () => {
|
||||||
|
it('should return pull requests for valid parameters', async () => {
|
||||||
|
const result = await router.executeTool('list_pull_requests', {
|
||||||
|
workspace: 'test-workspace',
|
||||||
|
repository: 'test-repo'
|
||||||
|
}) as ToolResult;
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data).toHaveProperty('pull_requests');
|
||||||
|
expect(result.data.pull_requests).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail without workspace', async () => {
|
||||||
|
const result = await router.executeTool('list_pull_requests', {
|
||||||
|
repository: 'test-repo'
|
||||||
|
}) as ToolResult;
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('Missing required parameters');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail without repository', async () => {
|
||||||
|
const result = await router.executeTool('list_pull_requests', {
|
||||||
|
workspace: 'test-workspace'
|
||||||
|
}) as ToolResult;
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('Missing required parameters');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept "repo" as alias for "repository"', async () => {
|
||||||
|
const result = await router.executeTool('list_pull_requests', {
|
||||||
|
workspace: 'test-workspace',
|
||||||
|
repo: 'test-repo'
|
||||||
|
}) as ToolResult;
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use DEFAULT_WORKSPACE when workspace is not provided', async () => {
|
||||||
|
process.env.DEFAULT_WORKSPACE = 'default-workspace';
|
||||||
|
process.env.DEFAULT_REPO = 'default-repo';
|
||||||
|
|
||||||
|
const result = await router.executeTool('list_pull_requests', {}) as ToolResult;
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use DEFAULT_REPO when repository is not provided', async () => {
|
||||||
|
process.env.DEFAULT_REPO = 'default-repo';
|
||||||
|
|
||||||
|
const result = await router.executeTool('list_pull_requests', {
|
||||||
|
workspace: 'test-workspace'
|
||||||
|
}) as ToolResult;
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use both DEFAULT_WORKSPACE and DEFAULT_REPO when neither is provided', async () => {
|
||||||
|
process.env.DEFAULT_WORKSPACE = 'default-workspace';
|
||||||
|
process.env.DEFAULT_REPO = 'default-repo';
|
||||||
|
|
||||||
|
const result = await router.executeTool('list_pull_requests', {}) as ToolResult;
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prefer explicit params over defaults', async () => {
|
||||||
|
process.env.DEFAULT_WORKSPACE = 'default-workspace';
|
||||||
|
process.env.DEFAULT_REPO = 'default-repo';
|
||||||
|
|
||||||
|
const result = await router.executeTool('list_pull_requests', {
|
||||||
|
workspace: 'explicit-workspace',
|
||||||
|
repository: 'explicit-repo'
|
||||||
|
}) as ToolResult;
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should still fail when no workspace and no default available', async () => {
|
||||||
|
const result = await router.executeTool('list_pull_requests', {
|
||||||
|
repository: 'test-repo'
|
||||||
|
}) as ToolResult;
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('Missing required parameters');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should still fail when no repository and no default available', async () => {
|
||||||
|
const result = await router.executeTool('list_pull_requests', {
|
||||||
|
workspace: 'test-workspace'
|
||||||
|
}) as ToolResult;
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('Missing required parameters');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get_pull_request', () => {
|
||||||
|
it('should return pull request for valid parameters', async () => {
|
||||||
|
const result = await router.executeTool('get_pull_request', {
|
||||||
|
workspace: 'test-workspace',
|
||||||
|
repository: 'test-repo',
|
||||||
|
pullRequestId: 1
|
||||||
|
}) as ToolResult;
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data).toHaveProperty('id', 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept pullRequestId as pr_id', async () => {
|
||||||
|
const result = await router.executeTool('get_pull_request', {
|
||||||
|
workspace: 'test-workspace',
|
||||||
|
repository: 'test-repo',
|
||||||
|
pr_id: 1
|
||||||
|
}) as ToolResult;
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with invalid pullRequestId', async () => {
|
||||||
|
const result = await router.executeTool('get_pull_request', {
|
||||||
|
workspace: 'test-workspace',
|
||||||
|
repository: 'test-repo',
|
||||||
|
pullRequestId: 'invalid'
|
||||||
|
}) as ToolResult;
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get_pull_request_activities', () => {
|
||||||
|
it('should return activities for valid PR', async () => {
|
||||||
|
const result = await router.executeTool('get_pull_request_activities', {
|
||||||
|
workspace: 'test-workspace',
|
||||||
|
repository: 'test-repo',
|
||||||
|
pullRequestId: 1
|
||||||
|
}) as ToolResult;
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data).toHaveProperty('values');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept pagination parameters', async () => {
|
||||||
|
const result = await router.executeTool('get_pull_request_activities', {
|
||||||
|
workspace: 'test-workspace',
|
||||||
|
repository: 'test-repo',
|
||||||
|
pullRequestId: 1,
|
||||||
|
limit: 10,
|
||||||
|
start: 0
|
||||||
|
}) as ToolResult;
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get_pull_request_changes', () => {
|
||||||
|
it('should return changes for valid PR', async () => {
|
||||||
|
const result = await router.executeTool('get_pull_request_changes', {
|
||||||
|
workspace: 'test-workspace',
|
||||||
|
repository: 'test-repo',
|
||||||
|
pullRequestId: 1
|
||||||
|
}) as ToolResult;
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data).toHaveProperty('values');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get_pull_request_comments', () => {
|
||||||
|
it('should return comments for valid PR', async () => {
|
||||||
|
const result = await router.executeTool('get_pull_request_comments', {
|
||||||
|
workspace: 'test-workspace',
|
||||||
|
repository: 'test-repo',
|
||||||
|
pullRequestId: 1
|
||||||
|
}) as ToolResult;
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data).toHaveProperty('values');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get_pull_request_comment', () => {
|
||||||
|
it('should return specific comment', async () => {
|
||||||
|
const result = await router.executeTool('get_pull_request_comment', {
|
||||||
|
workspace: 'test-workspace',
|
||||||
|
repository: 'test-repo',
|
||||||
|
pullRequestId: 1,
|
||||||
|
commentId: 1
|
||||||
|
}) as ToolResult;
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data).toHaveProperty('id', 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail without commentId', async () => {
|
||||||
|
const result = await router.executeTool('get_pull_request_comment', {
|
||||||
|
workspace: 'test-workspace',
|
||||||
|
repository: 'test-repo',
|
||||||
|
pullRequestId: 1
|
||||||
|
}) as ToolResult;
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get_pull_request_commits', () => {
|
||||||
|
it('should return commits for valid PR', async () => {
|
||||||
|
const result = await router.executeTool('get_pull_request_commits', {
|
||||||
|
workspace: 'test-workspace',
|
||||||
|
repository: 'test-repo',
|
||||||
|
pullRequestId: 1
|
||||||
|
}) as ToolResult;
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data).toHaveProperty('values');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get_pull_request_diff', () => {
|
||||||
|
it('should return diff for valid PR', async () => {
|
||||||
|
const result = await router.executeTool('get_pull_request_diff', {
|
||||||
|
workspace: 'test-workspace',
|
||||||
|
repository: 'test-repo',
|
||||||
|
pullRequestId: 1
|
||||||
|
}) as ToolResult;
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data).toHaveProperty('diff');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept path and context parameters', async () => {
|
||||||
|
const result = await router.executeTool('get_pull_request_diff', {
|
||||||
|
workspace: 'test-workspace',
|
||||||
|
repository: 'test-repo',
|
||||||
|
pullRequestId: 1,
|
||||||
|
path: 'src/test.ts',
|
||||||
|
context: 3
|
||||||
|
}) as ToolResult;
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get_pull_request_patch', () => {
|
||||||
|
it('should return patch for valid PR', async () => {
|
||||||
|
const result = await router.executeTool('get_pull_request_patch', {
|
||||||
|
workspace: 'test-workspace',
|
||||||
|
repository: 'test-repo',
|
||||||
|
pullRequestId: 1
|
||||||
|
}) as ToolResult;
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data).toHaveProperty('patch');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get_pull_request_participants', () => {
|
||||||
|
it('should return participants for valid PR', async () => {
|
||||||
|
const result = await router.executeTool('get_pull_request_participants', {
|
||||||
|
workspace: 'test-workspace',
|
||||||
|
repository: 'test-repo',
|
||||||
|
pullRequestId: 1
|
||||||
|
}) as ToolResult;
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data).toHaveProperty('values');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get_pull_request_reviewers', () => {
|
||||||
|
it('should return reviewers for valid PR', async () => {
|
||||||
|
const result = await router.executeTool('get_pull_request_reviewers', {
|
||||||
|
workspace: 'test-workspace',
|
||||||
|
repository: 'test-repo',
|
||||||
|
pullRequestId: 1
|
||||||
|
}) as ToolResult;
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get_pull_request_status', () => {
|
||||||
|
it('should return status for valid PR', async () => {
|
||||||
|
const result = await router.executeTool('get_pull_request_status', {
|
||||||
|
workspace: 'test-workspace',
|
||||||
|
repository: 'test-repo',
|
||||||
|
pullRequestId: 1
|
||||||
|
}) as ToolResult;
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data).toHaveProperty('state');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get_pull_request_tasks', () => {
|
||||||
|
it('should return tasks for valid PR', async () => {
|
||||||
|
const result = await router.executeTool('get_pull_request_tasks', {
|
||||||
|
workspace: 'test-workspace',
|
||||||
|
repository: 'test-repo',
|
||||||
|
pullRequestId: 1
|
||||||
|
}) as ToolResult;
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get_pull_request_task_count', () => {
|
||||||
|
it('should return task count for valid PR', async () => {
|
||||||
|
const result = await router.executeTool('get_pull_request_task_count', {
|
||||||
|
workspace: 'test-workspace',
|
||||||
|
repository: 'test-repo',
|
||||||
|
pullRequestId: 1
|
||||||
|
}) as ToolResult;
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get_full_pull_request', () => {
|
||||||
|
it('should return full PR details', async () => {
|
||||||
|
const result = await router.executeTool('get_full_pull_request', {
|
||||||
|
workspace: 'test-workspace',
|
||||||
|
repository: 'test-repo',
|
||||||
|
pullRequestId: 1
|
||||||
|
}) as ToolResult;
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data).toHaveProperty('id');
|
||||||
|
expect(result.data).toHaveProperty('description');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('unknown tool', () => {
|
||||||
|
it('should return error for unknown tool', async () => {
|
||||||
|
const result = await router.executeTool('unknown_tool', {}) as ToolResult;
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('Unknown tool');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('error handling', () => {
|
||||||
|
it('should handle API errors gracefully', async () => {
|
||||||
|
// Create router with a mock that throws
|
||||||
|
const errorRouter = new BitbucketRouter();
|
||||||
|
|
||||||
|
const result = await errorRouter.executeTool('list_pull_requests', {
|
||||||
|
workspace: 'test-workspace',
|
||||||
|
repository: 'test-repo'
|
||||||
|
}) as ToolResult;
|
||||||
|
|
||||||
|
// With our mock, this should succeed, but error cases should return proper format
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.error).toBeDefined();
|
||||||
|
expect(typeof result.error).toBe('string');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
20
tsconfig.json
Normal file
20
tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
16
vitest.config.ts
Normal file
16
vitest.config.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'node',
|
||||||
|
include: ['tests/unit/**/*.test.ts', 'tests/integration/**/*.test.ts'],
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text', 'json', 'html'],
|
||||||
|
exclude: ['node_modules/', 'dist/', 'tests/fixtures/']
|
||||||
|
},
|
||||||
|
testTimeout: 30000,
|
||||||
|
hookTimeout: 30000
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user