21 Commits
v1.0.0 ... main

Author SHA1 Message Date
2df53cbf10 fix: use correct Bitbucket API for user workspaces and clarify repo config
The `/workspaces` API endpoint was updated to `/user/workspaces` to correctly retrieve workspaces for the authenticated user.

Configuration guidance for `DEFAULT_WORKSPACE` and `DEFAULT_REPO` in documentation and templates now explicitly references deriving these values from the Bitbucket repository URL, simplifying initial setup.
2026-05-21 08:50:21 +02:00
8a410c7fd7 docs: update CLAUDE.md, README, and .env.template for write test requirements
Document RUN_WRITE_TESTS and TEST_PR_ID env vars across all three files.
Fix .env.template to keep write-test vars commented out. Reference .env.template
as the starting point for local development setup.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-20 23:35:05 +02:00
5d911d91a7 docs: update .env.template with write test environment variables 2026-05-20 23:31:01 +02:00
148fe7e51b chore: update .gitignore to ignore all .env files and allow templates 2026-05-20 23:27:26 +02:00
9c7d983df4 test: require TEST_PR_ID for write integration tests to prevent accidental mutations
Write tests no longer pick a random open PR. RUN_WRITE_TESTS=true alone is not
sufficient — TEST_PR_ID must also be set, otherwise each write test is skipped
with a warning. Added try/finally cleanup blocks so approval/comment/task state
is always restored even on assertion failures. Updated .env.example and README
to document the new requirement.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-20 23:25:40 +02:00
ab73c92e6d docs: update README with full capabilities — write ops, repo browsing, tasks
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-20 23:09:12 +02:00
980d79d8d1 docs: update Claude Code global config path to ~/.claude.json
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-20 23:06:14 +02:00
a7121bc185 chore: bump version to 1.1.0 2026-05-20 19:34:33 +02:00
93688bc90c test: add integration tests for repository browsing and write operations 2026-05-20 19:27:59 +02:00
c340873c6b feat: register new tool schemas in MCP server (repo browsing + PR/comment/task write) 2026-05-20 19:27:49 +02:00
d5cff234cf feat: add router handlers for all new MCP tools 2026-05-20 19:20:58 +02:00
2ccb27bcda test: add failing unit tests for new MCP tools (TDD) 2026-05-20 19:18:51 +02:00
4035179bb9 refactor: replace monolithic BitbucketClient with domain-client composition root 2026-05-20 19:15:56 +02:00
4394728c37 feat: add CommentClient with comment and task CRUD 2026-05-20 19:10:48 +02:00
019638e2ff feat: add RepositoryClient for workspace, repo, and branch browsing 2026-05-20 19:10:48 +02:00
5bfef6aa35 fix: correct start pagination guard, task count uses size, narrow state type in PullRequestClient 2026-05-20 19:09:52 +02:00
a0ab910137 feat: add PullRequestClient with read and write methods 2026-05-20 19:04:08 +02:00
cf60d7a8fe fix: improve BaseClient — dynamic version, better error logging, formatError type safety, drop misleading 429 sleep 2026-05-20 19:02:43 +02:00
b823cc1491 feat: add BaseClient with shared Axios instance and interceptors 2026-05-20 18:58:29 +02:00
262d1dfd0f docs: add implementation plan for missing Bitbucket MCP features
10-task TDD plan covering domain client split, PR/comment/task write
operations, and repository/workspace browsing.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-20 18:54:58 +02:00
666fc05dd0 Add design spec for missing Bitbucket MCP features
Covers domain client split, write operations (PR, comment, task),
and repository/workspace/branch browsing tools.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-20 18:33:35 +02:00
16 changed files with 4452 additions and 673 deletions

16
.env.template Normal file
View File

@@ -0,0 +1,16 @@
BITBUCKET_MCP_EMAIL=your_email@example.com
BITBUCKET_MCP_TOKEN=your_api_token
# Find these in your Bitbucket repo URL: https://bitbucket.org/my-workspace/my-repo/pull-requests
DEFAULT_WORKSPACE=my-workspace
DEFAULT_REPO=my-repo
# Integration test write operations
# ---------------------------------------------------------------------------------
# Set RUN_WRITE_TESTS=true to enable tests that create/modify/delete data.
#
# TEST_PR_ID must also be set — write tests will only run against this specific PR
# to avoid accidentally modifying unrelated pull requests.
# ---------------------------------------------------------------------------------
# RUN_WRITE_TESTS=true
# TEST_PR_ID=123
# ---------------------------------------------------------------------------------

6
.gitignore vendored
View File

@@ -6,9 +6,9 @@ dist/
tsconfig.tsbuildinfo tsconfig.tsbuildinfo
# Environment # Environment
.env *.env
.env.local *.env.*
.env.*.local !*.env.template
# IDE # IDE
.idea/ .idea/

View File

@@ -41,9 +41,15 @@ The server is a 3-layer pipeline: **MCP protocol** -> **Router** -> **Bitbucket
|----------|---------| |----------|---------|
| `BITBUCKET_MCP_EMAIL` | Bitbucket account email (Basic Auth username) | | `BITBUCKET_MCP_EMAIL` | Bitbucket account email (Basic Auth username) |
| `BITBUCKET_MCP_TOKEN` | Bitbucket API token (Basic Auth password) | | `BITBUCKET_MCP_TOKEN` | Bitbucket API token (Basic Auth password) |
| `DEFAULT_WORKSPACE` | Optional default workspace slug | | `DEFAULT_WORKSPACE` | Optional default workspace slug (from your repo URL: `https://bitbucket.org/{workspace}/{repo}/...`) |
| `DEFAULT_REPO` | Optional default repository slug | | `DEFAULT_REPO` | Optional default repository slug (from your repo URL: `https://bitbucket.org/{workspace}/{repo}/...`) |
| `RUN_WRITE_TESTS` | Set to `true` to enable write integration tests |
| `TEST_PR_ID` | PR ID to use for write integration tests (required when `RUN_WRITE_TESTS=true`) |
Copy `.env.template` to `.env` and fill in your values for local development and testing.
## Testing ## 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. 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.
Write integration tests (approve/unapprove, comments, tasks) are skipped by default. They require both `RUN_WRITE_TESTS=true` and `TEST_PR_ID` to be set. Write tests never pick a random PR — if `TEST_PR_ID` is missing the test is skipped with a warning.

View File

@@ -15,16 +15,19 @@
## Overview ## 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. This MCP server exposes Bitbucket Cloud operations as tools that AI agents (Claude, etc.) can invoke over stdio. It connects your AI workflow directly to your Bitbucket repositories — covering everything from read-only PR inspection to full write operations.
### Capabilities ### Capabilities
| Category | Operations | | Category | Operations |
|----------|-----------| |----------|-----------|
| **Pull Requests** | List, get details, get full expanded PR | | **Workspaces & Repos** | List workspaces, list/get repositories, list branches |
| **Pull Requests (read)** | List, get details, get full expanded PR, status |
| **Pull Requests (write)** | Create, update, merge, decline |
| **Code Review** | Diff, patch, file changes, commits | | **Code Review** | Diff, patch, file changes, commits |
| **Collaboration** | Comments, activities, participants, reviewers | | **Collaboration** | Comments (read/add/edit/delete), activities, participants, reviewers |
| **Workflow** | Status, tasks, task count | | **Review Workflow** | Approve, unapprove, request changes, remove request-changes |
| **Tasks** | Get tasks, task count, create/update/delete tasks |
| **Auth** | Token validation | | **Auth** | Token validation |
--- ---
@@ -51,7 +54,7 @@ npm run build
Add the MCP server to your Claude configuration: Add the MCP server to your Claude configuration:
**Claude Code** — edit `~/.claude/settings.json` (global) or `.claude/settings.json` (per-project): **Claude Code** — edit `~/.claude.json` (global) or `.claude/settings.json` (per-project):
```json ```json
{ {
@@ -92,6 +95,8 @@ Add the MCP server to your Claude configuration:
> **Important:** > **Important:**
> - Replace `/absolute/path/to/bitbucket-mcp` with the actual absolute path where you cloned the repo. > - Replace `/absolute/path/to/bitbucket-mcp` with the actual absolute path where you cloned the repo.
> - `DEFAULT_WORKSPACE` and `DEFAULT_REPO` are optional but convenient — they let you omit those parameters from every tool call. > - `DEFAULT_WORKSPACE` and `DEFAULT_REPO` are optional but convenient — they let you omit those parameters from every tool call.
> - Read them directly from your Bitbucket repo URL: `https://bitbucket.org/{workspace}/{repository}/pull-requests`
> For example, `https://bitbucket.org/my-workspace/my-repo/pull-requests` → `DEFAULT_WORKSPACE=my-workspace`, `DEFAULT_REPO=my-repo`
> - Get your API token from [Atlassian API Tokens](https://id.atlassian.com/manage-profile/security/api-tokens). > - Get your API token from [Atlassian API Tokens](https://id.atlassian.com/manage-profile/security/api-tokens).
### 4. Verify ### 4. Verify
@@ -108,6 +113,15 @@ Restart Claude (or reload MCP servers), then ask Claude to run the `validate_tok
|------|-------------| |------|-------------|
| `validate_token` | Verify credentials are valid | | `validate_token` | Verify credentials are valid |
### Workspaces & Repositories
| Tool | Description | Key Parameters |
|------|-------------|----------------|
| `list_workspaces` | List all workspaces the authenticated user belongs to | `page?`, `pagelen?` |
| `list_repositories` | List repositories in a workspace | `workspace`, `role?`, `page?`, `pagelen?` |
| `get_repository` | Get metadata for a specific repository | `workspace`, `repository` |
| `list_branches` | List branches in a repository | `workspace`, `repository`, `filter_by_name?`, `page?`, `pagelen?` |
### Pull Request Discovery ### Pull Request Discovery
| Tool | Description | Key Parameters | | Tool | Description | Key Parameters |
@@ -117,6 +131,24 @@ Restart Claude (or reload MCP servers), then ask Claude to run the `validate_tok
| `get_full_pull_request` | Get PR with all fields expanded | `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` | | `get_pull_request_status` | Get PR state (open/merged/declined) | `workspace`, `repository`, `pullRequestId` |
### Pull Request Write Operations
| Tool | Description | Key Parameters |
|------|-------------|----------------|
| `create_pull_request` | Create a new pull request | `workspace`, `repository`, `title`, `source_branch`, `destination_branch`, `description?`, `reviewers?`, `close_source_branch?` |
| `update_pull_request` | Update title, description, reviewers, or destination branch | `workspace`, `repository`, `pullRequestId`, `title?`, `description?`, `reviewers?`, `destination_branch?` |
| `merge_pull_request` | Merge an open pull request | `workspace`, `repository`, `pullRequestId`, `merge_strategy?`, `commit_message?`, `close_source_branch?` |
| `decline_pull_request` | Decline (close) an open pull request | `workspace`, `repository`, `pullRequestId` |
### Review Workflow
| Tool | Description | Key Parameters |
|------|-------------|----------------|
| `approve_pull_request` | Approve a pull request | `workspace`, `repository`, `pullRequestId` |
| `unapprove_pull_request` | Remove your approval | `workspace`, `repository`, `pullRequestId` |
| `request_changes_pull_request` | Request changes on a pull request | `workspace`, `repository`, `pullRequestId` |
| `remove_request_changes_pull_request` | Remove a request-changes vote | `workspace`, `repository`, `pullRequestId` |
### Code Changes ### Code Changes
| Tool | Description | Key Parameters | | Tool | Description | Key Parameters |
@@ -126,12 +158,20 @@ Restart Claude (or reload MCP servers), then ask Claude to run the `validate_tok
| `get_pull_request_changes` | List modified files | `workspace`, `repository`, `pullRequestId` | | `get_pull_request_changes` | List modified files | `workspace`, `repository`, `pullRequestId` |
| `get_pull_request_commits` | List commits in PR | `workspace`, `repository`, `pullRequestId` | | `get_pull_request_commits` | List commits in PR | `workspace`, `repository`, `pullRequestId` |
### Collaboration ### Comments
| Tool | Description | Key Parameters | | Tool | Description | Key Parameters |
|------|-------------|----------------| |------|-------------|----------------|
| `get_pull_request_comments` | Get all comments | `workspace`, `repository`, `pullRequestId` | | `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_comment` | Get a specific comment | `workspace`, `repository`, `pullRequestId`, `commentId` |
| `add_pull_request_comment` | Add a general or inline comment | `workspace`, `repository`, `pullRequestId`, `content`, `inline_path?`, `inline_line?`, `parent_comment_id?` |
| `update_pull_request_comment` | Edit an existing comment | `workspace`, `repository`, `pullRequestId`, `commentId`, `content` |
| `delete_pull_request_comment` | Delete a comment | `workspace`, `repository`, `pullRequestId`, `commentId` |
### Activities & Participants
| Tool | Description | Key Parameters |
|------|-------------|----------------|
| `get_pull_request_activities` | Get activity feed | `workspace`, `repository`, `pullRequestId` | | `get_pull_request_activities` | Get activity feed | `workspace`, `repository`, `pullRequestId` |
| `get_pull_request_participants` | Get all participants | `workspace`, `repository`, `pullRequestId` | | `get_pull_request_participants` | Get all participants | `workspace`, `repository`, `pullRequestId` |
| `get_pull_request_reviewers` | Get assigned reviewers | `workspace`, `repository`, `pullRequestId` | | `get_pull_request_reviewers` | Get assigned reviewers | `workspace`, `repository`, `pullRequestId` |
@@ -142,8 +182,11 @@ Restart Claude (or reload MCP servers), then ask Claude to run the `validate_tok
|------|-------------|----------------| |------|-------------|----------------|
| `get_pull_request_tasks` | Get PR tasks | `workspace`, `repository`, `pullRequestId` | | `get_pull_request_tasks` | Get PR tasks | `workspace`, `repository`, `pullRequestId` |
| `get_pull_request_task_count` | Get task count | `workspace`, `repository`, `pullRequestId` | | `get_pull_request_task_count` | Get task count | `workspace`, `repository`, `pullRequestId` |
| `create_pull_request_task` | Create a review task | `workspace`, `repository`, `pullRequestId`, `content`, `comment_id?` |
| `update_pull_request_task` | Update or resolve/unresolve a task | `workspace`, `repository`, `pullRequestId`, `taskId`, `content?`, `state?` |
| `delete_pull_request_task` | Delete a task | `workspace`, `repository`, `pullRequestId`, `taskId` |
> **Pagination:** Tools that return lists support `limit` and `start` parameters. > **Pagination:** Tools that return lists support `page` and `pagelen` parameters.
--- ---
@@ -184,13 +227,17 @@ Response:
### Running Locally ### Running Locally
For development, you can use a `.env` file instead of configuring credentials in the MCP client: For development, you can use a `.env` file instead of configuring credentials in the MCP client. Copy `.env.template` to `.env` and fill in your values:
```env ```env
BITBUCKET_MCP_EMAIL=your_email@example.com BITBUCKET_MCP_EMAIL=your_email@example.com
BITBUCKET_MCP_TOKEN=your_api_token BITBUCKET_MCP_TOKEN=your_api_token
DEFAULT_WORKSPACE=my-workspace DEFAULT_WORKSPACE=my-workspace
DEFAULT_REPO=my-repo DEFAULT_REPO=my-repo
# Only needed when running write integration tests (see Testing below)
# RUN_WRITE_TESTS=true
# TEST_PR_ID=123
``` ```
```bash ```bash
@@ -204,13 +251,13 @@ npm start
### Testing ### Testing
```bash ```bash
# Run tests # Run all tests (unit only by default)
npm test npm test
# Run tests in watch mode # Run tests in watch mode
npm run test:watch npm run test:watch
# Integration tests (requires valid credentials) # Integration tests — read-only, requires valid credentials in .env
npm run test:integration npm run test:integration
# Type check # Type check
@@ -220,6 +267,15 @@ npx tsc --noEmit
npm run test:coverage npm run test:coverage
``` ```
**Write operation tests** (approve/unapprove, comments, tasks) are disabled by default and require two additional variables in your `.env`:
| Variable | Description |
|----------|-------------|
| `RUN_WRITE_TESTS=true` | Opt in to running write tests |
| `TEST_PR_ID=<id>` | ID of a PR you own and can safely modify |
`TEST_PR_ID` is mandatory — write tests will never fall back to a random PR from the repository. If it is not set, each write test is skipped with a warning. This prevents accidental modifications to PRs you don't intend to touch.
### Project Structure ### Project Structure
``` ```

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,253 @@
# Bitbucket MCP Server — Missing Features Design
**Date:** 2026-05-20
**Status:** Approved
---
## Overview
Extend the existing read-only Bitbucket MCP server with:
1. **Repository/workspace browsing** — list workspaces, repos, branches; get repo metadata
2. **PR write operations** — create, update, merge, decline, approve, request-changes
3. **Comment write operations** — add, update, delete comments (general and inline)
4. **Task write operations** — create, update, delete PR tasks
The server's public contract (tool names, parameter shapes, `ToolResult` response envelope) remains unchanged; new tools are additive.
---
## Architecture
The current monolithic `bitbucket-client.ts` is split into domain modules. A shared `base-client.ts` owns the Axios instance, initialization guard, error interceptors, and `formatError`. Domain clients extend or compose the base. `BitbucketClient` becomes a composition root that wires all domain clients together and exposes a flat API to the router.
```
src/
├── index.ts # MCP server — tool schemas added here
├── router.ts # Tool dispatcher — new switch cases added
├── config.ts # Unchanged
├── bitbucket-client.ts # Composition root: instantiates & delegates to domain clients
└── clients/
├── base-client.ts # Shared Axios instance, ensureInitialized, formatError, error interceptors
├── pull-request-client.ts # All PR methods (read + write)
├── repository-client.ts # Workspace, repo, branch methods
└── comment-client.ts # Comment + task CRUD
```
The router continues calling `this.client.<method>()` — the composition root makes this transparent.
---
## Domain Clients
### `base-client.ts`
Extracted from the current `bitbucket-client.ts`:
- Holds the `AxiosInstance`
- `initializeClient(options)` — loads config, creates axios, attaches interceptors
- `ensureInitialized()` — polling guard used by all domain clients
- `handleResponseError(error)` — 401/403/429 logging and rate-limit wait
- `formatError(error)` — HTTP status + message formatter
- `sleep(ms)` — utility
All domain clients receive a reference to the base client (or extend it) to share the axios instance and initialization state.
### `pull-request-client.ts`
**Existing read methods** (migrated from current `bitbucket-client.ts`):
- `listPullRequests`, `getPullRequest`, `getPullRequestActivities`
- `getPullRequestChanges`, `getPullRequestComments`, `getPullRequestComment`
- `getPullRequestCommits`, `getPullRequestDiff`, `getPullRequestPatch`
- `getPullRequestParticipants`, `getPullRequestReviewers`, `getPullRequestStatus`
- `getPullRequestTasks`, `getPullRequestTaskCount`, `getFullPullRequest`
**New write methods:**
| Method | HTTP | Endpoint |
|--------|------|----------|
| `createPullRequest(ws, repo, opts)` | POST | `/repositories/{ws}/{repo}/pullrequests` |
| `updatePullRequest(ws, repo, id, opts)` | PUT | `/repositories/{ws}/{repo}/pullrequests/{id}` |
| `mergePullRequest(ws, repo, id, opts)` | POST | `.../pullrequests/{id}/merge` |
| `declinePullRequest(ws, repo, id)` | POST | `.../pullrequests/{id}/decline` |
| `approvePullRequest(ws, repo, id)` | POST | `.../pullrequests/{id}/approve` |
| `unapprovePullRequest(ws, repo, id)` | DELETE | `.../pullrequests/{id}/approve` |
| `requestChangesPullRequest(ws, repo, id)` | POST | `.../pullrequests/{id}/request-changes` |
| `removeRequestChangesPullRequest(ws, repo, id)` | DELETE | `.../pullrequests/{id}/request-changes` |
`createPullRequest` options:
```ts
{
title: string;
source_branch: string;
destination_branch: string;
description?: string;
reviewers?: string[]; // account UUIDs or usernames
close_source_branch?: boolean;
}
```
`mergePullRequest` options:
```ts
{
merge_strategy?: 'merge_commit' | 'squash' | 'fast_forward';
commit_message?: string;
close_source_branch?: boolean;
}
```
`updatePullRequest` options (all optional, at least one required):
```ts
{
title?: string;
description?: string;
reviewers?: string[];
destination_branch?: string;
}
```
### `repository-client.ts`
New domain client — no existing methods to migrate.
| Method | HTTP | Endpoint |
|--------|------|----------|
| `listWorkspaces(opts)` | GET | `/workspaces` |
| `listRepositories(ws, opts)` | GET | `/repositories/{workspace}` |
| `getRepository(ws, repo)` | GET | `/repositories/{workspace}/{repo_slug}` |
| `listBranches(ws, repo, opts)` | GET | `/repositories/{ws}/{repo}/refs/branches` |
`listWorkspaces` options: `page`, `pagelen`
`listRepositories` options: `role` (`member`|`contributor`|`owner`), `page`, `pagelen`
`listBranches` options: `page`, `pagelen`, `filter_by_name` (maps to `q=name~"<value>"`)
### `comment-client.ts`
**Existing read methods** (migrated):
- `getPullRequestComments`, `getPullRequestComment`
**New write methods:**
| Method | HTTP | Endpoint |
|--------|------|----------|
| `addPullRequestComment(ws, repo, id, opts)` | POST | `.../pullrequests/{id}/comments` |
| `updatePullRequestComment(ws, repo, id, commentId, opts)` | PUT | `.../comments/{commentId}` |
| `deletePullRequestComment(ws, repo, id, commentId)` | DELETE | `.../comments/{commentId}` |
| `createPullRequestTask(ws, repo, id, opts)` | POST | `.../pullrequests/{id}/tasks` |
| `updatePullRequestTask(ws, repo, id, taskId, opts)` | PUT | `.../tasks/{taskId}` |
| `deletePullRequestTask(ws, repo, id, taskId)` | DELETE | `.../tasks/{taskId}` |
`addPullRequestComment` options:
```ts
{
content: string;
inline?: { path: string; to: number }; // inline comment on file:line
parent_id?: number; // reply to existing comment
}
```
`updatePullRequestComment` options:
```ts
{ content: string }
```
`createPullRequestTask` options:
```ts
{
content: string;
comment_id?: number; // anchor to a specific comment
}
```
`updatePullRequestTask` options:
```ts
{
content?: string;
state?: 'RESOLVED' | 'UNRESOLVED';
}
```
---
## New MCP Tools (index.ts)
### Repository/Workspace
| Tool name | Required params | Optional params |
|-----------|----------------|-----------------|
| `list_workspaces` | — | `page`, `pagelen` |
| `list_repositories` | `workspace` | `role`, `page`, `pagelen` |
| `get_repository` | `workspace`, `repository` | — |
| `list_branches` | `workspace`, `repository` | `filter_by_name`, `page`, `pagelen` |
### PR Write
| Tool name | Required params | Optional params |
|-----------|----------------|-----------------|
| `create_pull_request` | `workspace`, `repository`, `title`, `source_branch`, `destination_branch` | `description`, `reviewers`, `close_source_branch` |
| `update_pull_request` | `workspace`, `repository`, `pullRequestId` | `title`, `description`, `reviewers`, `destination_branch` |
| `merge_pull_request` | `workspace`, `repository`, `pullRequestId` | `merge_strategy`, `commit_message`, `close_source_branch` |
| `decline_pull_request` | `workspace`, `repository`, `pullRequestId` | — |
| `approve_pull_request` | `workspace`, `repository`, `pullRequestId` | — |
| `unapprove_pull_request` | `workspace`, `repository`, `pullRequestId` | — |
| `request_changes_pull_request` | `workspace`, `repository`, `pullRequestId` | — |
| `remove_request_changes_pull_request` | `workspace`, `repository`, `pullRequestId` | — |
### Comment Write
| Tool name | Required params | Optional params |
|-----------|----------------|-----------------|
| `add_pull_request_comment` | `workspace`, `repository`, `pullRequestId`, `content` | `inline_path`, `inline_line`, `parent_comment_id` |
| `update_pull_request_comment` | `workspace`, `repository`, `pullRequestId`, `commentId`, `content` | — |
| `delete_pull_request_comment` | `workspace`, `repository`, `pullRequestId`, `commentId` | — |
### Task Write
| Tool name | Required params | Optional params |
|-----------|----------------|-----------------|
| `create_pull_request_task` | `workspace`, `repository`, `pullRequestId`, `content` | `comment_id` |
| `update_pull_request_task` | `workspace`, `repository`, `pullRequestId`, `taskId` | `content`, `state` |
| `delete_pull_request_task` | `workspace`, `repository`, `pullRequestId`, `taskId` | — |
---
## Router (router.ts)
New `switch` cases added to `executeTool()` for every new tool. Same pattern as existing cases:
1. Extract `workspace`, `repoSlug` via `getDefaultParams()`
2. Parse integer IDs
3. Validate required params — return `{ success: false, error: '...' }` if missing
4. Delegate to `this.client.<method>()`
5. Return `{ success: true, data: result }`
No new helper is needed for write ops; the existing pattern is clear enough.
---
## Error Handling
- Write operations do not retry on failure.
- `base-client.ts` error interceptor handles 401/403/429 (existing behavior preserved).
- All domain client methods catch errors and re-throw with `this.formatError(error)`.
- Router catches and returns `{ success: false, error: '...' }`.
---
## Testing
**Unit tests (`tests/unit/router.test.ts`):**
- New cases added for every new tool, mocking `BitbucketClient` identically to existing tests.
**Integration tests (`tests/integration/bitbucket-api.test.ts`):**
- Read operations (workspaces, repos, branches) added unconditionally.
- Write operations (merge, decline, approve, comment, task) gated on `RUN_WRITE_TESTS=true` env var to prevent accidental mutation of real PRs.
---
## Out of Scope
- Webhooks, pipelines, deployments, issue tracker, snippets.
- Any Bitbucket Server (self-hosted) support — this server targets Bitbucket Cloud only.
- Streaming / SSE responses.

View File

@@ -1,6 +1,6 @@
{ {
"name": "@bitbucket/mcp-server", "name": "@bitbucket/mcp-server",
"version": "1.0.0", "version": "1.1.0",
"description": "MCP Server for Bitbucket Pull Requests - AI agent integration with Bitbucket Cloud REST API", "description": "MCP Server for Bitbucket Pull Requests - AI agent integration with Bitbucket Cloud REST API",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",

View File

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

102
src/clients/base-client.ts Normal file
View File

@@ -0,0 +1,102 @@
import axios, { AxiosInstance, AxiosError } from 'axios';
import { TokenConfigLoader } from '../config.js';
export interface ClientOptions {
timeout?: number;
}
export class BaseClient {
protected axiosInstance!: AxiosInstance;
private tokenSource: string | null = null;
private _initialized = false;
private _initPromise: Promise<void> | null = null;
constructor(options: ClientOptions = {}) {
this._initPromise = this._init(options);
}
private async _init(options: ClientOptions): Promise<void> {
try {
const config = await TokenConfigLoader.load();
this.tokenSource = config.source;
this.axiosInstance = 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,
});
this.axiosInstance.interceptors.request.use(cfg => {
cfg.headers['User-Agent'] = `Bitbucket-MCP-Server/${this._getVersion()}`;
return cfg;
});
this.axiosInstance.interceptors.response.use(
r => r,
(error: AxiosError) => this._handleResponseError(error),
);
this._initialized = true;
} catch (error) {
throw new Error(`Unable to initialize Bitbucket client: ${error}`);
}
}
protected async ensureInitialized(): Promise<void> {
if (this._initialized) return;
await this._initPromise;
}
private async _handleResponseError(error: AxiosError): Promise<never> {
const status = error.response?.status;
if (status === 401) {
console.error('Bitbucket API Authentication Error (401)');
console.error(` Token source: ${this.tokenSource || 'unknown'}`);
console.error(` URL: ${error.config?.url}`);
console.error(` Method: ${error.config?.method?.toUpperCase()}`);
const data = error.response?.data;
if (typeof data === 'object' && data !== null) {
console.error(' Response:', JSON.stringify(data, null, 2));
} else if (data) {
console.error(` Response: ${data}`);
}
} else if (status === 403) {
console.error('Bitbucket API Forbidden (403)');
console.error(` Token source: ${this.tokenSource || 'unknown'}`);
console.error(` URL: ${error.config?.url}`);
} else if (status === 429) {
console.warn(`Rate limited (429). URL: ${error.config?.url}`);
}
throw error;
}
private _sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
private _getVersion(): string {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const fs = require('fs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
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';
}
}
protected formatError(error: unknown): string {
if (error && typeof error === 'object' && 'response' in error) {
const axiosError = error as { response?: { status?: number }; message?: string };
if (axiosError.response?.status) {
return `HTTP ${axiosError.response.status}: ${axiosError.message || 'Unknown error'}`;
}
}
if (error instanceof Error) return error.message;
return String(error) || 'Unknown error';
}
}

View File

@@ -0,0 +1,154 @@
import { BaseClient, ClientOptions } from './base-client.js';
export interface AddCommentOptions {
content: string;
inline?: { path: string; to: number };
parent_id?: number;
}
export interface CreateTaskOptions {
content: string;
comment_id?: number;
}
export interface UpdateTaskOptions {
content?: string;
state?: 'RESOLVED' | 'UNRESOLVED';
}
export class CommentClient extends BaseClient {
constructor(options: ClientOptions = {}) {
super(options);
}
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 !== undefined) params.start = options.start;
const response = await this.axiosInstance.get(
`/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/comments`,
{ params },
);
return response.data;
} catch (error) {
throw new Error(`Failed to get comments: ${this.formatError(error)}`);
}
}
async getPullRequestComment(
workspace: string, repoSlug: string, prId: number, commentId: number,
): Promise<any> {
await this.ensureInitialized();
try {
const response = await this.axiosInstance.get(
`/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/comments/${commentId}`,
);
return response.data;
} catch (error) {
throw new Error(`Failed to get comment: ${this.formatError(error)}`);
}
}
async addPullRequestComment(
workspace: string, repoSlug: string, prId: number, opts: AddCommentOptions,
): Promise<any> {
await this.ensureInitialized();
try {
const body: Record<string, any> = { content: { raw: opts.content } };
if (opts.inline) body.inline = opts.inline;
if (opts.parent_id !== undefined) body.parent = { id: opts.parent_id };
const response = await this.axiosInstance.post(
`/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/comments`,
body,
);
return response.data;
} catch (error) {
throw new Error(`Failed to add comment: ${this.formatError(error)}`);
}
}
async updatePullRequestComment(
workspace: string, repoSlug: string, prId: number, commentId: number,
opts: { content: string },
): Promise<any> {
await this.ensureInitialized();
try {
const response = await this.axiosInstance.put(
`/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/comments/${commentId}`,
{ content: { raw: opts.content } },
);
return response.data;
} catch (error) {
throw new Error(`Failed to update comment: ${this.formatError(error)}`);
}
}
async deletePullRequestComment(
workspace: string, repoSlug: string, prId: number, commentId: number,
): Promise<any> {
await this.ensureInitialized();
try {
await this.axiosInstance.delete(
`/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/comments/${commentId}`,
);
return { success: true };
} catch (error) {
throw new Error(`Failed to delete comment: ${this.formatError(error)}`);
}
}
async createPullRequestTask(
workspace: string, repoSlug: string, prId: number, opts: CreateTaskOptions,
): Promise<any> {
await this.ensureInitialized();
try {
const body: Record<string, any> = { content: { raw: opts.content } };
if (opts.comment_id !== undefined) body.comment = { id: opts.comment_id };
const response = await this.axiosInstance.post(
`/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/tasks`,
body,
);
return response.data;
} catch (error) {
throw new Error(`Failed to create task: ${this.formatError(error)}`);
}
}
async updatePullRequestTask(
workspace: string, repoSlug: string, prId: number, taskId: number,
opts: UpdateTaskOptions,
): Promise<any> {
await this.ensureInitialized();
try {
const body: Record<string, any> = {};
if (opts.content !== undefined) body.content = { raw: opts.content };
if (opts.state !== undefined) body.state = opts.state;
const response = await this.axiosInstance.put(
`/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/tasks/${taskId}`,
body,
);
return response.data;
} catch (error) {
throw new Error(`Failed to update task: ${this.formatError(error)}`);
}
}
async deletePullRequestTask(
workspace: string, repoSlug: string, prId: number, taskId: number,
): Promise<any> {
await this.ensureInitialized();
try {
await this.axiosInstance.delete(
`/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/tasks/${taskId}`,
);
return { success: true };
} catch (error) {
throw new Error(`Failed to delete task: ${this.formatError(error)}`);
}
}
}

View File

@@ -0,0 +1,390 @@
import { BaseClient, ClientOptions } from './base-client.js';
export interface CreatePROptions {
title: string;
source_branch: string;
destination_branch: string;
description?: string;
reviewers?: string[];
close_source_branch?: boolean;
}
export interface UpdatePROptions {
title?: string;
description?: string;
reviewers?: string[];
destination_branch?: string;
}
export interface MergePROptions {
merge_strategy?: 'merge_commit' | 'squash' | 'fast_forward';
commit_message?: string;
close_source_branch?: boolean;
}
export class PullRequestClient extends BaseClient {
constructor(options: ClientOptions = {}) {
super(options);
}
async listPullRequests(
workspace: string,
repoSlug: string,
options?: { state?: 'OPEN' | 'MERGED' | 'DECLINED' | 'SUPERSEDED'; author?: string; reviewer?: string; since?: string },
): Promise<any[]> {
await this.ensureInitialized();
try {
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;
const response = await this.axiosInstance.get(
`/repositories/${workspace}/${repoSlug}/pullrequests`,
{ params },
);
return response.data.values || [];
} catch (error) {
throw new Error(`Failed to list pull requests: ${this.formatError(error)}`);
}
}
async getPullRequest(workspace: string, repoSlug: string, prId: number): Promise<any> {
await this.ensureInitialized();
try {
const response = await this.axiosInstance.get(
`/repositories/${workspace}/${repoSlug}/pullrequests/${prId}`,
);
return response.data;
} catch (error) {
throw new Error(`Failed to get pull request: ${this.formatError(error)}`);
}
}
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 !== undefined) params.start = options.start;
const response = await this.axiosInstance.get(
`/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/activity`,
{ params },
);
return response.data;
} catch (error) {
throw new Error(`Failed to get activities: ${this.formatError(error)}`);
}
}
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 !== undefined) params.start = options.start;
const prResponse = await this.axiosInstance.get(
`/repositories/${workspace}/${repoSlug}/pullrequests/${prId}`,
);
const sourceHash = prResponse.data.source?.commit?.hash;
const destHash = prResponse.data.destination?.commit?.hash;
if (!sourceHash || !destHash) {
throw new Error('Could not determine source and destination commits');
}
const response = await this.axiosInstance.get(
`/repositories/${workspace}/${repoSlug}/diffstat/${sourceHash}..${destHash}`,
{ params },
);
return response.data;
} catch (error) {
throw new Error(`Failed to get changes: ${this.formatError(error)}`);
}
}
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 !== undefined) params.start = options.start;
const response = await this.axiosInstance.get(
`/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/commits`,
{ params },
);
return response.data;
} catch (error) {
throw new Error(`Failed to get commits: ${this.formatError(error)}`);
}
}
async getPullRequestDiff(
workspace: string, repoSlug: string, prId: number,
options?: { context?: number; path?: string; whitespace?: string },
): 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.axiosInstance.get(
`/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/diff/${options.path}`,
{ params, responseType: 'text' },
);
return response.data;
}
const prResponse = await this.axiosInstance.get(
`/repositories/${workspace}/${repoSlug}/pullrequests/${prId}`,
);
const sourceHash = prResponse.data.source?.commit?.hash;
const destHash = prResponse.data.destination?.commit?.hash;
if (!sourceHash || !destHash) {
throw new Error('Could not determine source and destination commits');
}
const response = await this.axiosInstance.get(
`/repositories/${workspace}/${repoSlug}/diff/${sourceHash}..${destHash}`,
{ params, responseType: 'text' },
);
return response.data;
} catch (error) {
throw new Error(`Failed to get diff: ${this.formatError(error)}`);
}
}
async getPullRequestPatch(workspace: string, repoSlug: string, prId: number): Promise<any> {
await this.ensureInitialized();
try {
const prResponse = await this.axiosInstance.get(
`/repositories/${workspace}/${repoSlug}/pullrequests/${prId}`,
);
const sourceHash = prResponse.data.source?.commit?.hash;
const destHash = prResponse.data.destination?.commit?.hash;
if (!sourceHash || !destHash) {
throw new Error('Could not determine source and destination commits');
}
const response = await this.axiosInstance.get(
`/repositories/${workspace}/${repoSlug}/diff/${sourceHash}..${destHash}`,
{ responseType: 'text' },
);
return { patch: response.data };
} catch (error) {
throw new Error(`Failed to get patch: ${this.formatError(error)}`);
}
}
async getPullRequestParticipants(workspace: string, repoSlug: string, prId: number): Promise<any> {
await this.ensureInitialized();
try {
const response = await this.axiosInstance.get(
`/repositories/${workspace}/${repoSlug}/pullrequests/${prId}`,
);
return response.data.participants || [];
} catch (error) {
throw new Error(`Failed to get participants: ${this.formatError(error)}`);
}
}
async getPullRequestReviewers(workspace: string, repoSlug: string, prId: number): Promise<any> {
await this.ensureInitialized();
try {
const response = await this.axiosInstance.get(
`/repositories/${workspace}/${repoSlug}/pullrequests/${prId}`,
);
return response.data.reviewers || [];
} catch (error) {
throw new Error(`Failed to get reviewers: ${this.formatError(error)}`);
}
}
async getPullRequestStatus(workspace: string, repoSlug: string, prId: number): Promise<any> {
await this.ensureInitialized();
try {
const response = await this.axiosInstance.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 status: ${this.formatError(error)}`);
}
}
async getPullRequestTasks(workspace: string, repoSlug: string, prId: number): Promise<any> {
await this.ensureInitialized();
try {
const response = await this.axiosInstance.get(
`/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/tasks`,
);
return response.data;
} catch (error) {
throw new Error(`Failed to get tasks: ${this.formatError(error)}`);
}
}
async getPullRequestTaskCount(workspace: string, repoSlug: string, prId: number): Promise<any> {
await this.ensureInitialized();
try {
const response = await this.axiosInstance.get(
`/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/tasks`,
);
return { count: response.data.size ?? response.data.values?.length ?? 0 };
} catch (error) {
throw new Error(`Failed to get task count: ${this.formatError(error)}`);
}
}
async getFullPullRequest(workspace: string, repoSlug: string, prId: number): Promise<any> {
await this.ensureInitialized();
try {
const response = await this.axiosInstance.get(
`/repositories/${workspace}/${repoSlug}/pullrequests/${prId}`,
{ params: { fields: '+*' } },
);
return response.data;
} catch (error) {
throw new Error(`Failed to get full PR: ${this.formatError(error)}`);
}
}
async createPullRequest(workspace: string, repoSlug: string, opts: CreatePROptions): Promise<any> {
await this.ensureInitialized();
try {
const body: Record<string, any> = {
title: opts.title,
source: { branch: { name: opts.source_branch } },
destination: { branch: { name: opts.destination_branch } },
};
if (opts.description !== undefined) body.description = opts.description;
if (opts.close_source_branch !== undefined) body.close_source_branch = opts.close_source_branch;
if (opts.reviewers?.length) {
body.reviewers = opts.reviewers.map(r => ({ uuid: r }));
}
const response = await this.axiosInstance.post(
`/repositories/${workspace}/${repoSlug}/pullrequests`,
body,
);
return response.data;
} catch (error) {
throw new Error(`Failed to create pull request: ${this.formatError(error)}`);
}
}
async updatePullRequest(
workspace: string, repoSlug: string, prId: number, opts: UpdatePROptions,
): Promise<any> {
await this.ensureInitialized();
try {
const body: Record<string, any> = {};
if (opts.title !== undefined) body.title = opts.title;
if (opts.description !== undefined) body.description = opts.description;
if (opts.destination_branch !== undefined) {
body.destination = { branch: { name: opts.destination_branch } };
}
if (opts.reviewers !== undefined) {
body.reviewers = opts.reviewers.map(r => ({ uuid: r }));
}
const response = await this.axiosInstance.put(
`/repositories/${workspace}/${repoSlug}/pullrequests/${prId}`,
body,
);
return response.data;
} catch (error) {
throw new Error(`Failed to update pull request: ${this.formatError(error)}`);
}
}
async mergePullRequest(
workspace: string, repoSlug: string, prId: number, opts: MergePROptions = {},
): Promise<any> {
await this.ensureInitialized();
try {
const body: Record<string, any> = {};
if (opts.merge_strategy) body.merge_strategy = opts.merge_strategy;
if (opts.commit_message) body.message = opts.commit_message;
if (opts.close_source_branch !== undefined) body.close_source_branch = opts.close_source_branch;
const response = await this.axiosInstance.post(
`/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/merge`,
body,
);
return response.data;
} catch (error) {
throw new Error(`Failed to merge pull request: ${this.formatError(error)}`);
}
}
async declinePullRequest(workspace: string, repoSlug: string, prId: number): Promise<any> {
await this.ensureInitialized();
try {
const response = await this.axiosInstance.post(
`/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/decline`,
);
return response.data;
} catch (error) {
throw new Error(`Failed to decline pull request: ${this.formatError(error)}`);
}
}
async approvePullRequest(workspace: string, repoSlug: string, prId: number): Promise<any> {
await this.ensureInitialized();
try {
const response = await this.axiosInstance.post(
`/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/approve`,
);
return response.data;
} catch (error) {
throw new Error(`Failed to approve pull request: ${this.formatError(error)}`);
}
}
async unapprovePullRequest(workspace: string, repoSlug: string, prId: number): Promise<any> {
await this.ensureInitialized();
try {
await this.axiosInstance.delete(
`/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/approve`,
);
return { success: true };
} catch (error) {
throw new Error(`Failed to unapprove pull request: ${this.formatError(error)}`);
}
}
async requestChangesPullRequest(workspace: string, repoSlug: string, prId: number): Promise<any> {
await this.ensureInitialized();
try {
const response = await this.axiosInstance.post(
`/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/request-changes`,
);
return response.data;
} catch (error) {
throw new Error(`Failed to request changes: ${this.formatError(error)}`);
}
}
async removeRequestChangesPullRequest(workspace: string, repoSlug: string, prId: number): Promise<any> {
await this.ensureInitialized();
try {
await this.axiosInstance.delete(
`/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/request-changes`,
);
return { success: true };
} catch (error) {
throw new Error(`Failed to remove request-changes: ${this.formatError(error)}`);
}
}
}

View File

@@ -0,0 +1,73 @@
import { BaseClient, ClientOptions } from './base-client.js';
export class RepositoryClient extends BaseClient {
constructor(options: ClientOptions = {}) {
super(options);
}
async listWorkspaces(options?: { page?: number; pagelen?: number }): Promise<any> {
await this.ensureInitialized();
try {
const params: Record<string, any> = {};
if (options?.page) params.page = options.page;
if (options?.pagelen) params.pagelen = options.pagelen;
const response = await this.axiosInstance.get('/user/workspaces', { params });
return response.data;
} catch (error) {
throw new Error(`Failed to list workspaces: ${this.formatError(error)}`);
}
}
async listRepositories(
workspace: string,
options?: { role?: 'member' | 'contributor' | 'owner'; page?: number; pagelen?: number },
): Promise<any> {
await this.ensureInitialized();
try {
const params: Record<string, any> = {};
if (options?.role) params.role = options.role;
if (options?.page) params.page = options.page;
if (options?.pagelen) params.pagelen = options.pagelen;
const response = await this.axiosInstance.get(
`/repositories/${workspace}`,
{ params },
);
return response.data;
} catch (error) {
throw new Error(`Failed to list repositories: ${this.formatError(error)}`);
}
}
async getRepository(workspace: string, repoSlug: string): Promise<any> {
await this.ensureInitialized();
try {
const response = await this.axiosInstance.get(
`/repositories/${workspace}/${repoSlug}`,
);
return response.data;
} catch (error) {
throw new Error(`Failed to get repository: ${this.formatError(error)}`);
}
}
async listBranches(
workspace: string,
repoSlug: string,
options?: { filter_by_name?: string; page?: number; pagelen?: number },
): Promise<any> {
await this.ensureInitialized();
try {
const params: Record<string, any> = {};
if (options?.filter_by_name) params.q = `name~"${options.filter_by_name}"`;
if (options?.page) params.page = options.page;
if (options?.pagelen) params.pagelen = options.pagelen;
const response = await this.axiosInstance.get(
`/repositories/${workspace}/${repoSlug}/refs/branches`,
{ params },
);
return response.data;
} catch (error) {
throw new Error(`Failed to list branches: ${this.formatError(error)}`);
}
}
}

View File

@@ -262,6 +262,269 @@ class BitbucketMCPServer {
required: ['workspace', 'repository', 'pullRequestId'], required: ['workspace', 'repository', 'pullRequestId'],
}, },
}, },
// ── Repository / workspace ────────────────────────────────────────
{
name: 'list_workspaces',
description: 'List all Bitbucket workspaces the authenticated user belongs to',
inputSchema: {
type: 'object',
properties: {
page: { type: 'integer', description: 'Page number (1-based)' },
pagelen: { type: 'integer', description: 'Results per page (default 10, max 100)' },
},
},
},
{
name: 'list_repositories',
description: 'List repositories in a Bitbucket workspace',
inputSchema: {
type: 'object',
properties: {
workspace: { type: 'string' },
role: { type: 'string', enum: ['member', 'contributor', 'owner'] },
page: { type: 'integer' },
pagelen: { type: 'integer' },
},
required: ['workspace'],
},
},
{
name: 'get_repository',
description: 'Get metadata for a specific Bitbucket repository',
inputSchema: {
type: 'object',
properties: {
workspace: { type: 'string' },
repository: { type: 'string' },
},
required: ['workspace', 'repository'],
},
},
{
name: 'list_branches',
description: 'List branches in a Bitbucket repository',
inputSchema: {
type: 'object',
properties: {
workspace: { type: 'string' },
repository: { type: 'string' },
filter_by_name: { type: 'string', description: 'Filter branches whose name contains this string' },
page: { type: 'integer' },
pagelen: { type: 'integer' },
},
required: ['workspace', 'repository'],
},
},
// ── PR write ──────────────────────────────────────────────────────
{
name: 'create_pull_request',
description: 'Create a new pull request in a Bitbucket repository',
inputSchema: {
type: 'object',
properties: {
workspace: { type: 'string' },
repository: { type: 'string' },
title: { type: 'string' },
source_branch: { type: 'string' },
destination_branch: { type: 'string' },
description: { type: 'string' },
reviewers: { type: 'array', items: { type: 'string' }, description: 'Reviewer account UUIDs' },
close_source_branch: { type: 'boolean' },
},
required: ['workspace', 'repository', 'title', 'source_branch', 'destination_branch'],
},
},
{
name: 'update_pull_request',
description: 'Update the title, description, reviewers, or destination branch of a pull request',
inputSchema: {
type: 'object',
properties: {
workspace: { type: 'string' },
repository: { type: 'string' },
pullRequestId: { type: 'integer' },
title: { type: 'string' },
description: { type: 'string' },
reviewers: { type: 'array', items: { type: 'string' } },
destination_branch: { type: 'string' },
},
required: ['workspace', 'repository', 'pullRequestId'],
},
},
{
name: 'merge_pull_request',
description: 'Merge an open pull request',
inputSchema: {
type: 'object',
properties: {
workspace: { type: 'string' },
repository: { type: 'string' },
pullRequestId: { type: 'integer' },
merge_strategy: { type: 'string', enum: ['merge_commit', 'squash', 'fast_forward'] },
commit_message: { type: 'string' },
close_source_branch: { type: 'boolean' },
},
required: ['workspace', 'repository', 'pullRequestId'],
},
},
{
name: 'decline_pull_request',
description: 'Decline (close) an open pull request',
inputSchema: {
type: 'object',
properties: {
workspace: { type: 'string' },
repository: { type: 'string' },
pullRequestId: { type: 'integer' },
},
required: ['workspace', 'repository', 'pullRequestId'],
},
},
{
name: 'approve_pull_request',
description: 'Approve a pull request as the authenticated user',
inputSchema: {
type: 'object',
properties: {
workspace: { type: 'string' },
repository: { type: 'string' },
pullRequestId: { type: 'integer' },
},
required: ['workspace', 'repository', 'pullRequestId'],
},
},
{
name: 'unapprove_pull_request',
description: 'Remove approval from a pull request',
inputSchema: {
type: 'object',
properties: {
workspace: { type: 'string' },
repository: { type: 'string' },
pullRequestId: { type: 'integer' },
},
required: ['workspace', 'repository', 'pullRequestId'],
},
},
{
name: 'request_changes_pull_request',
description: 'Request changes on a pull request as the authenticated user',
inputSchema: {
type: 'object',
properties: {
workspace: { type: 'string' },
repository: { type: 'string' },
pullRequestId: { type: 'integer' },
},
required: ['workspace', 'repository', 'pullRequestId'],
},
},
{
name: 'remove_request_changes_pull_request',
description: 'Remove a request-changes vote from a pull request',
inputSchema: {
type: 'object',
properties: {
workspace: { type: 'string' },
repository: { type: 'string' },
pullRequestId: { type: 'integer' },
},
required: ['workspace', 'repository', 'pullRequestId'],
},
},
// ── Comment write ─────────────────────────────────────────────────
{
name: 'add_pull_request_comment',
description: 'Add a comment to a pull request (general or inline on a specific file/line)',
inputSchema: {
type: 'object',
properties: {
workspace: { type: 'string' },
repository: { type: 'string' },
pullRequestId: { type: 'integer' },
content: { type: 'string', description: 'Comment text (Markdown supported)' },
inline_path: { type: 'string', description: 'File path for inline comment' },
inline_line: { type: 'integer', description: 'Line number for inline comment' },
parent_comment_id: { type: 'integer', description: 'Parent comment ID to reply to' },
},
required: ['workspace', 'repository', 'pullRequestId', 'content'],
},
},
{
name: 'update_pull_request_comment',
description: 'Edit the text of an existing pull request comment',
inputSchema: {
type: 'object',
properties: {
workspace: { type: 'string' },
repository: { type: 'string' },
pullRequestId: { type: 'integer' },
commentId: { type: 'integer' },
content: { type: 'string' },
},
required: ['workspace', 'repository', 'pullRequestId', 'commentId', 'content'],
},
},
{
name: 'delete_pull_request_comment',
description: 'Delete a comment from a pull request',
inputSchema: {
type: 'object',
properties: {
workspace: { type: 'string' },
repository: { type: 'string' },
pullRequestId: { type: 'integer' },
commentId: { type: 'integer' },
},
required: ['workspace', 'repository', 'pullRequestId', 'commentId'],
},
},
// ── Task write ────────────────────────────────────────────────────
{
name: 'create_pull_request_task',
description: 'Create a review task on a pull request',
inputSchema: {
type: 'object',
properties: {
workspace: { type: 'string' },
repository: { type: 'string' },
pullRequestId: { type: 'integer' },
content: { type: 'string', description: 'Task description text' },
comment_id: { type: 'integer', description: 'Anchor task to this comment ID' },
},
required: ['workspace', 'repository', 'pullRequestId', 'content'],
},
},
{
name: 'update_pull_request_task',
description: 'Update a pull request task content or resolve/unresolve it',
inputSchema: {
type: 'object',
properties: {
workspace: { type: 'string' },
repository: { type: 'string' },
pullRequestId: { type: 'integer' },
taskId: { type: 'integer' },
content: { type: 'string' },
state: { type: 'string', enum: ['RESOLVED', 'UNRESOLVED'] },
},
required: ['workspace', 'repository', 'pullRequestId', 'taskId'],
},
},
{
name: 'delete_pull_request_task',
description: 'Delete a pull request task',
inputSchema: {
type: 'object',
properties: {
workspace: { type: 'string' },
repository: { type: 'string' },
pullRequestId: { type: 'integer' },
taskId: { type: 'integer' },
},
required: ['workspace', 'repository', 'pullRequestId', 'taskId'],
},
},
], ],
}; };
}); });

View File

@@ -107,6 +107,60 @@ export class BitbucketRouter {
case 'get_full_pull_request': case 'get_full_pull_request':
return this.getFullPullRequest(params); return this.getFullPullRequest(params);
case 'list_workspaces':
return this.listWorkspaces(params);
case 'list_repositories':
return this.listRepositories(params);
case 'get_repository':
return this.getRepository(params);
case 'list_branches':
return this.listBranches(params);
case 'create_pull_request':
return this.createPullRequest(params);
case 'update_pull_request':
return this.updatePullRequest(params);
case 'merge_pull_request':
return this.mergePullRequest(params);
case 'decline_pull_request':
return this.declinePullRequest(params);
case 'approve_pull_request':
return this.approvePullRequest(params);
case 'unapprove_pull_request':
return this.unapprovePullRequest(params);
case 'request_changes_pull_request':
return this.requestChangesPullRequest(params);
case 'remove_request_changes_pull_request':
return this.removeRequestChangesPullRequest(params);
case 'add_pull_request_comment':
return this.addPullRequestComment(params);
case 'update_pull_request_comment':
return this.updatePullRequestComment(params);
case 'delete_pull_request_comment':
return this.deletePullRequestComment(params);
case 'create_pull_request_task':
return this.createPullRequestTask(params);
case 'update_pull_request_task':
return this.updatePullRequestTask(params);
case 'delete_pull_request_task':
return this.deletePullRequestTask(params);
default: default:
return { return {
success: false, success: false,
@@ -484,6 +538,292 @@ export class BitbucketRouter {
return { success: false, error: `Get full PR failed: ${String(error)}` }; return { success: false, error: `Get full PR failed: ${String(error)}` };
} }
} }
private async listWorkspaces(params: ToolCallParams): Promise<ToolResult> {
try {
const result = await this.client.listWorkspaces({
page: params.page,
pagelen: params.pagelen,
});
return { success: true, data: result };
} catch (error) {
return { success: false, error: `List workspaces failed: ${String(error)}` };
}
}
private async listRepositories(params: ToolCallParams): Promise<ToolResult> {
try {
const { workspace } = this.getDefaultParams(params);
if (!workspace) {
return { success: false, error: 'Missing required parameters: workspace' };
}
const result = await this.client.listRepositories(workspace, {
role: params.role,
page: params.page,
pagelen: params.pagelen,
});
return { success: true, data: result };
} catch (error) {
return { success: false, error: `List repositories failed: ${String(error)}` };
}
}
private async getRepository(params: ToolCallParams): Promise<ToolResult> {
try {
const { workspace, repoSlug } = this.getDefaultParams(params);
if (!workspace || !repoSlug) {
return { success: false, error: 'Missing required parameters: workspace and repository' };
}
const result = await this.client.getRepository(workspace, repoSlug);
return { success: true, data: result };
} catch (error) {
return { success: false, error: `Get repository failed: ${String(error)}` };
}
}
private async listBranches(params: ToolCallParams): Promise<ToolResult> {
try {
const { workspace, repoSlug } = this.getDefaultParams(params);
if (!workspace || !repoSlug) {
return { success: false, error: 'Missing required parameters: workspace and repository' };
}
const result = await this.client.listBranches(workspace, repoSlug, {
filter_by_name: params.filter_by_name,
page: params.page,
pagelen: params.pagelen,
});
return { success: true, data: result };
} catch (error) {
return { success: false, error: `List branches failed: ${String(error)}` };
}
}
private async createPullRequest(params: ToolCallParams): Promise<ToolResult> {
try {
const { workspace, repoSlug } = this.getDefaultParams(params);
if (!workspace || !repoSlug || !params.title || !params.source_branch || !params.destination_branch) {
return { success: false, error: 'Missing required parameters: workspace, repository, title, source_branch, destination_branch' };
}
const result = await this.client.createPullRequest(workspace, repoSlug, {
title: params.title,
source_branch: params.source_branch,
destination_branch: params.destination_branch,
description: params.description,
reviewers: params.reviewers,
close_source_branch: params.close_source_branch,
});
return { success: true, data: result };
} catch (error) {
return { success: false, error: `Create pull request failed: ${String(error)}` };
}
}
private async updatePullRequest(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.updatePullRequest(workspace, repoSlug, prId, {
title: params.title,
description: params.description,
reviewers: params.reviewers,
destination_branch: params.destination_branch,
});
return { success: true, data: result };
} catch (error) {
return { success: false, error: `Update pull request failed: ${String(error)}` };
}
}
private async mergePullRequest(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.mergePullRequest(workspace, repoSlug, prId, {
merge_strategy: params.merge_strategy,
commit_message: params.commit_message,
close_source_branch: params.close_source_branch,
});
return { success: true, data: result };
} catch (error) {
return { success: false, error: `Merge pull request failed: ${String(error)}` };
}
}
private async declinePullRequest(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.declinePullRequest(workspace, repoSlug, prId);
return { success: true, data: result };
} catch (error) {
return { success: false, error: `Decline pull request failed: ${String(error)}` };
}
}
private async approvePullRequest(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.approvePullRequest(workspace, repoSlug, prId);
return { success: true, data: result };
} catch (error) {
return { success: false, error: `Approve pull request failed: ${String(error)}` };
}
}
private async unapprovePullRequest(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.unapprovePullRequest(workspace, repoSlug, prId);
return { success: true, data: result };
} catch (error) {
return { success: false, error: `Unapprove pull request failed: ${String(error)}` };
}
}
private async requestChangesPullRequest(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.requestChangesPullRequest(workspace, repoSlug, prId);
return { success: true, data: result };
} catch (error) {
return { success: false, error: `Request changes failed: ${String(error)}` };
}
}
private async removeRequestChangesPullRequest(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.removeRequestChangesPullRequest(workspace, repoSlug, prId);
return { success: true, data: result };
} catch (error) {
return { success: false, error: `Remove request-changes failed: ${String(error)}` };
}
}
private async addPullRequestComment(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) || !params.content) {
return { success: false, error: 'Missing required parameters: workspace, repository, pullRequestId, content' };
}
const opts: any = { content: params.content };
if (params.inline_path && params.inline_line) {
opts.inline = { path: params.inline_path, to: parseInt(params.inline_line, 10) };
}
if (params.parent_comment_id) {
opts.parent_id = parseInt(params.parent_comment_id, 10);
}
const result = await this.client.addPullRequestComment(workspace, repoSlug, prId, opts);
return { success: true, data: result };
} catch (error) {
return { success: false, error: `Add comment failed: ${String(error)}` };
}
}
private async updatePullRequestComment(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) || !params.content) {
return { success: false, error: 'Missing required parameters: workspace, repository, pullRequestId, commentId, content' };
}
const result = await this.client.updatePullRequestComment(workspace, repoSlug, prId, commentId, { content: params.content });
return { success: true, data: result };
} catch (error) {
return { success: false, error: `Update comment failed: ${String(error)}` };
}
}
private async deletePullRequestComment(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.deletePullRequestComment(workspace, repoSlug, prId, commentId);
return { success: true, data: result };
} catch (error) {
return { success: false, error: `Delete comment failed: ${String(error)}` };
}
}
private async createPullRequestTask(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) || !params.content) {
return { success: false, error: 'Missing required parameters: workspace, repository, pullRequestId, content' };
}
const opts: any = { content: params.content };
if (params.comment_id) opts.comment_id = parseInt(params.comment_id, 10);
const result = await this.client.createPullRequestTask(workspace, repoSlug, prId, opts);
return { success: true, data: result };
} catch (error) {
return { success: false, error: `Create task failed: ${String(error)}` };
}
}
private async updatePullRequestTask(params: ToolCallParams): Promise<ToolResult> {
try {
const { workspace, repoSlug } = this.getDefaultParams(params);
const prId = parseInt(params.pullRequestId || params.pr_id, 10);
const taskId = parseInt(params.taskId || params.task_id, 10);
if (!workspace || !repoSlug || isNaN(prId) || isNaN(taskId)) {
return { success: false, error: 'Missing required parameters' };
}
const result = await this.client.updatePullRequestTask(workspace, repoSlug, prId, taskId, {
content: params.content,
state: params.state,
});
return { success: true, data: result };
} catch (error) {
return { success: false, error: `Update task failed: ${String(error)}` };
}
}
private async deletePullRequestTask(params: ToolCallParams): Promise<ToolResult> {
try {
const { workspace, repoSlug } = this.getDefaultParams(params);
const prId = parseInt(params.pullRequestId || params.pr_id, 10);
const taskId = parseInt(params.taskId || params.task_id, 10);
if (!workspace || !repoSlug || isNaN(prId) || isNaN(taskId)) {
return { success: false, error: 'Missing required parameters' };
}
const result = await this.client.deletePullRequestTask(workspace, repoSlug, prId, taskId);
return { success: true, data: result };
} catch (error) {
return { success: false, error: `Delete task failed: ${String(error)}` };
}
}
} }
export default BitbucketRouter; export default BitbucketRouter;

View File

@@ -292,3 +292,112 @@ describe('Error Handling', () => {
} }
}); });
}); });
const RUN_WRITES = process.env.RUN_WRITE_TESTS === 'true';
const TEST_PR_ID = process.env.TEST_PR_ID ? parseInt(process.env.TEST_PR_ID, 10) : undefined;
describe('Repository / Workspace (read)', () => {
let client: BitbucketClient;
beforeAll(() => {
client = new BitbucketClient();
});
it('should list workspaces', async () => {
if (!HAS_TOKEN) return;
const result = await client.listWorkspaces({ pagelen: 10 });
expect(result).toHaveProperty('values');
expect(Array.isArray(result.values)).toBe(true);
});
it('should list repositories in workspace', async () => {
if (!HAS_TOKEN) return;
const result = await client.listRepositories(WORKSPACE, { pagelen: 10 });
expect(result).toHaveProperty('values');
expect(Array.isArray(result.values)).toBe(true);
});
it('should get a specific repository', async () => {
if (!HAS_TOKEN) return;
const result = await client.getRepository(WORKSPACE, REPO_SLUG);
expect(result).toHaveProperty('slug');
expect(result).toHaveProperty('full_name');
});
it('should list branches', async () => {
if (!HAS_TOKEN) return;
const result = await client.listBranches(WORKSPACE, REPO_SLUG, { pagelen: 10 });
expect(result).toHaveProperty('values');
expect(Array.isArray(result.values)).toBe(true);
if (result.values.length > 0) {
expect(result.values[0]).toHaveProperty('name');
}
});
it('should filter branches by name', async () => {
if (!HAS_TOKEN) return;
const result = await client.listBranches(WORKSPACE, REPO_SLUG, { filter_by_name: 'main' });
expect(result).toHaveProperty('values');
});
});
describe('PR Write Operations (requires RUN_WRITE_TESTS=true and TEST_PR_ID)', () => {
let client: BitbucketClient;
beforeAll(() => {
client = new BitbucketClient();
});
it('should approve then immediately unapprove a PR', async () => {
if (!HAS_TOKEN || !RUN_WRITES) return;
if (!TEST_PR_ID) { console.log('⚠️ TEST_PR_ID not set, skipping approve/unapprove test'); return; }
await client.approvePullRequest(WORKSPACE, REPO_SLUG, TEST_PR_ID);
try {
const unapproved = await client.unapprovePullRequest(WORKSPACE, REPO_SLUG, TEST_PR_ID);
expect(unapproved).toHaveProperty('success', true);
} finally {
await client.unapprovePullRequest(WORKSPACE, REPO_SLUG, TEST_PR_ID).catch(() => {});
}
});
});
describe('Comment / Task Write Operations (requires RUN_WRITE_TESTS=true and TEST_PR_ID)', () => {
let client: BitbucketClient;
beforeAll(() => {
client = new BitbucketClient();
});
it('should add, update, then delete a comment', async () => {
if (!HAS_TOKEN || !RUN_WRITES) return;
if (!TEST_PR_ID) { console.log('⚠️ TEST_PR_ID not set, skipping comment test'); return; }
const added = await client.addPullRequestComment(WORKSPACE, REPO_SLUG, TEST_PR_ID, { content: 'Integration test comment' });
expect(added).toHaveProperty('id');
const commentId = added.id;
try {
const updated = await client.updatePullRequestComment(WORKSPACE, REPO_SLUG, TEST_PR_ID, commentId, { content: 'Updated comment' });
expect(updated).toHaveProperty('id', commentId);
} finally {
await client.deletePullRequestComment(WORKSPACE, REPO_SLUG, TEST_PR_ID, commentId).catch(() => {});
}
});
it('should create, resolve, then delete a task', async () => {
if (!HAS_TOKEN || !RUN_WRITES) return;
if (!TEST_PR_ID) { console.log('⚠️ TEST_PR_ID not set, skipping task test'); return; }
const created = await client.createPullRequestTask(WORKSPACE, REPO_SLUG, TEST_PR_ID, { content: 'Integration test task' });
expect(created).toHaveProperty('id');
const taskId = created.id;
try {
const resolved = await client.updatePullRequestTask(WORKSPACE, REPO_SLUG, TEST_PR_ID, taskId, { state: 'RESOLVED' });
expect(resolved).toBeDefined();
} finally {
await client.deletePullRequestTask(WORKSPACE, REPO_SLUG, TEST_PR_ID, taskId).catch(() => {});
}
});
});

View File

@@ -2,68 +2,118 @@
* Unit tests for BitbucketRouter * Unit tests for BitbucketRouter
*/ */
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { BitbucketRouter, ToolResult } from '../../src/router.js'; import { BitbucketRouter, ToolResult } from '../../src/router.js';
// Mock the BitbucketClient // Mock domain clients - use simple object mocks instead of nested vi.fn
vi.mock('../../src/bitbucket-client.js', () => { vi.mock('../../src/clients/pull-request-client.js', () => {
const mockPRClient = {
listPullRequests: async () => [
{ id: 1, title: 'Test PR 1', state: 'OPEN' },
{ id: 2, title: 'Test PR 2', state: 'MERGED' }
],
getPullRequest: async () => ({
id: 1,
title: 'Test PR',
state: 'OPEN',
source: { branch: { name: 'feature' } },
destination: { branch: { name: 'main' } }
}),
getPullRequestStatus: async () => ({
id: 1,
title: 'Test PR',
state: 'OPEN',
status: 'NORMAL'
}),
getPullRequestActivities: async () => ({
values: [{ action: 'OPEN' }]
}),
getPullRequestChanges: async () => ({
values: [{ type: 'modified', path: 'src/test.ts' }]
}),
getPullRequestCommits: async () => ({
values: [{ hash: 'abc123' }]
}),
getPullRequestDiff: async () => ({
diff: '--- test.ts\n+++ test.ts\n@@ -1 +1 @@'
}),
getPullRequestPatch: async () => ({
patch: '--- original\n+++ modified'
}),
getPullRequestParticipants: async () => ({
values: [{ user: { display_name: 'Test User' } }]
}),
getPullRequestReviewers: async () => [
{ user: { display_name: 'Reviewer 1' } }
],
getPullRequestTasks: async () => ({
values: []
}),
getPullRequestTaskCount: async () => ({ count: 0 }),
getFullPullRequest: async () => ({
id: 1,
title: 'Test PR',
description: 'Test description'
}),
createPullRequest: async () => ({ id: 10, title: 'New PR', state: 'OPEN' }),
updatePullRequest: async () => ({ id: 1, title: 'Updated PR', state: 'OPEN' }),
mergePullRequest: async () => ({ id: 1, state: 'MERGED' }),
declinePullRequest: async () => ({ id: 1, state: 'DECLINED' }),
approvePullRequest: async () => ({ approved: true }),
unapprovePullRequest: async () => ({ success: true }),
requestChangesPullRequest: async () => ({ state: 'changes_requested' }),
removeRequestChangesPullRequest: async () => ({ success: true })
};
return { return {
BitbucketClient: vi.fn().mockImplementation(() => ({ PullRequestClient: class {
listPullRequests: vi.fn().mockResolvedValue([ constructor() {
{ id: 1, title: 'Test PR 1', state: 'OPEN' }, Object.assign(this, mockPRClient);
{ id: 2, title: 'Test PR 2', state: 'MERGED' } }
]), }
getPullRequest: vi.fn().mockResolvedValue({ };
id: 1, });
title: 'Test PR',
state: 'OPEN', vi.mock('../../src/clients/repository-client.js', () => {
source: { branch: { name: 'feature' } }, const mockRepoClient = {
destination: { branch: { name: 'main' } } listWorkspaces: async () => ({ values: [{ slug: 'my-workspace' }] }),
}), listRepositories: async () => ({ values: [{ slug: 'my-repo', name: 'My Repo' }] }),
getPullRequestStatus: vi.fn().mockResolvedValue({ getRepository: async () => ({ slug: 'my-repo', name: 'My Repo', full_name: 'ws/my-repo' }),
id: 1, listBranches: async () => ({ values: [{ name: 'main' }, { name: 'develop' }] })
title: 'Test PR', };
state: 'OPEN',
status: 'NORMAL' return {
}), RepositoryClient: class {
getPullRequestActivities: vi.fn().mockResolvedValue({ constructor() {
values: [{ action: 'OPEN' }] Object.assign(this, mockRepoClient);
}), }
getPullRequestChanges: vi.fn().mockResolvedValue({ }
values: [{ type: 'modified', path: 'src/test.ts' }] };
}), });
getPullRequestComments: vi.fn().mockResolvedValue({
values: [{ id: 1, content: { raw: 'Test comment' } }] vi.mock('../../src/clients/comment-client.js', () => {
}), const mockCommentClient = {
getPullRequestComment: vi.fn().mockResolvedValue({ getPullRequestComments: async () => ({
id: 1, values: [{ id: 1, content: { raw: 'Test comment' } }]
content: { raw: 'Test comment' } }),
}), getPullRequestComment: async () => ({
getPullRequestCommits: vi.fn().mockResolvedValue({ id: 1,
values: [{ hash: 'abc123' }] content: { raw: 'Test comment' }
}), }),
getPullRequestDiff: vi.fn().mockResolvedValue({ addPullRequestComment: async () => ({ id: 100, content: { raw: 'New comment' } }),
diff: '--- test.ts\n+++ test.ts\n@@ -1 +1 @@' updatePullRequestComment: async () => ({ id: 100, content: { raw: 'Updated' } }),
}), deletePullRequestComment: async () => ({ success: true }),
getPullRequestPatch: vi.fn().mockResolvedValue({ createPullRequestTask: async () => ({ id: 200, content: { raw: 'Task' }, state: 'UNRESOLVED' }),
patch: '--- original\n+++ modified' updatePullRequestTask: async () => ({ id: 200, state: 'RESOLVED' }),
}), deletePullRequestTask: async () => ({ success: true })
getPullRequestParticipants: vi.fn().mockResolvedValue({ };
values: [{ user: { display_name: 'Test User' } }]
}), return {
getPullRequestReviewers: vi.fn().mockResolvedValue([ CommentClient: class {
{ user: { display_name: 'Reviewer 1' } } constructor() {
]), Object.assign(this, mockCommentClient);
getPullRequestTasks: vi.fn().mockResolvedValue({ }
values: [] }
}),
getPullRequestTaskCount: vi.fn().mockResolvedValue({ count: 0 }),
getFullPullRequest: vi.fn().mockResolvedValue({
id: 1,
title: 'Test PR',
description: 'Test description'
})
}))
}; };
}); });
@@ -415,6 +465,265 @@ describe('BitbucketRouter', () => {
}); });
}); });
describe('list_workspaces', () => {
it('should return workspaces', async () => {
const result = await router.executeTool('list_workspaces', {}) as ToolResult;
expect(result.success).toBe(true);
expect(result.data).toHaveProperty('values');
});
});
describe('list_repositories', () => {
it('should return repositories for valid workspace', async () => {
const result = await router.executeTool('list_repositories', {
workspace: 'test-workspace',
}) as ToolResult;
expect(result.success).toBe(true);
expect(result.data).toHaveProperty('values');
});
it('should fail without workspace', async () => {
const result = await router.executeTool('list_repositories', {}) as ToolResult;
expect(result.success).toBe(false);
expect(result.error).toContain('Missing required parameters');
});
});
describe('get_repository', () => {
it('should return repository metadata', async () => {
const result = await router.executeTool('get_repository', {
workspace: 'test-workspace',
repository: 'test-repo',
}) as ToolResult;
expect(result.success).toBe(true);
expect(result.data).toHaveProperty('slug');
});
});
describe('list_branches', () => {
it('should return branches', async () => {
const result = await router.executeTool('list_branches', {
workspace: 'test-workspace',
repository: 'test-repo',
}) as ToolResult;
expect(result.success).toBe(true);
expect(result.data).toHaveProperty('values');
});
});
describe('create_pull_request', () => {
it('should create a pull request', async () => {
const result = await router.executeTool('create_pull_request', {
workspace: 'test-workspace',
repository: 'test-repo',
title: 'My PR',
source_branch: 'feature/x',
destination_branch: 'main',
}) as ToolResult;
expect(result.success).toBe(true);
expect(result.data).toHaveProperty('id');
});
it('should fail without title', async () => {
const result = await router.executeTool('create_pull_request', {
workspace: 'test-workspace',
repository: 'test-repo',
source_branch: 'feature/x',
destination_branch: 'main',
}) as ToolResult;
expect(result.success).toBe(false);
expect(result.error).toContain('Missing required parameters');
});
it('should fail without source_branch', async () => {
const result = await router.executeTool('create_pull_request', {
workspace: 'test-workspace',
repository: 'test-repo',
title: 'My PR',
destination_branch: 'main',
}) as ToolResult;
expect(result.success).toBe(false);
expect(result.error).toContain('Missing required parameters');
});
});
describe('update_pull_request', () => {
it('should update a pull request', async () => {
const result = await router.executeTool('update_pull_request', {
workspace: 'test-workspace',
repository: 'test-repo',
pullRequestId: 1,
title: 'Updated',
}) as ToolResult;
expect(result.success).toBe(true);
expect(result.data).toHaveProperty('id');
});
});
describe('merge_pull_request', () => {
it('should merge a pull request', async () => {
const result = await router.executeTool('merge_pull_request', {
workspace: 'test-workspace',
repository: 'test-repo',
pullRequestId: 1,
}) as ToolResult;
expect(result.success).toBe(true);
expect(result.data).toHaveProperty('state', 'MERGED');
});
});
describe('decline_pull_request', () => {
it('should decline a pull request', async () => {
const result = await router.executeTool('decline_pull_request', {
workspace: 'test-workspace',
repository: 'test-repo',
pullRequestId: 1,
}) as ToolResult;
expect(result.success).toBe(true);
expect(result.data).toHaveProperty('state', 'DECLINED');
});
});
describe('approve_pull_request', () => {
it('should approve a pull request', async () => {
const result = await router.executeTool('approve_pull_request', {
workspace: 'test-workspace',
repository: 'test-repo',
pullRequestId: 1,
}) as ToolResult;
expect(result.success).toBe(true);
});
});
describe('unapprove_pull_request', () => {
it('should remove approval', async () => {
const result = await router.executeTool('unapprove_pull_request', {
workspace: 'test-workspace',
repository: 'test-repo',
pullRequestId: 1,
}) as ToolResult;
expect(result.success).toBe(true);
});
});
describe('request_changes_pull_request', () => {
it('should request changes', async () => {
const result = await router.executeTool('request_changes_pull_request', {
workspace: 'test-workspace',
repository: 'test-repo',
pullRequestId: 1,
}) as ToolResult;
expect(result.success).toBe(true);
});
});
describe('remove_request_changes_pull_request', () => {
it('should remove request-changes', async () => {
const result = await router.executeTool('remove_request_changes_pull_request', {
workspace: 'test-workspace',
repository: 'test-repo',
pullRequestId: 1,
}) as ToolResult;
expect(result.success).toBe(true);
});
});
describe('add_pull_request_comment', () => {
it('should add a comment', async () => {
const result = await router.executeTool('add_pull_request_comment', {
workspace: 'test-workspace',
repository: 'test-repo',
pullRequestId: 1,
content: 'Great work!',
}) as ToolResult;
expect(result.success).toBe(true);
expect(result.data).toHaveProperty('id');
});
it('should fail without content', async () => {
const result = await router.executeTool('add_pull_request_comment', {
workspace: 'test-workspace',
repository: 'test-repo',
pullRequestId: 1,
}) as ToolResult;
expect(result.success).toBe(false);
expect(result.error).toContain('Missing required parameters');
});
});
describe('update_pull_request_comment', () => {
it('should update a comment', async () => {
const result = await router.executeTool('update_pull_request_comment', {
workspace: 'test-workspace',
repository: 'test-repo',
pullRequestId: 1,
commentId: 100,
content: 'Edited',
}) as ToolResult;
expect(result.success).toBe(true);
});
});
describe('delete_pull_request_comment', () => {
it('should delete a comment', async () => {
const result = await router.executeTool('delete_pull_request_comment', {
workspace: 'test-workspace',
repository: 'test-repo',
pullRequestId: 1,
commentId: 100,
}) as ToolResult;
expect(result.success).toBe(true);
});
});
describe('create_pull_request_task', () => {
it('should create a task', async () => {
const result = await router.executeTool('create_pull_request_task', {
workspace: 'test-workspace',
repository: 'test-repo',
pullRequestId: 1,
content: 'Review section 3',
}) as ToolResult;
expect(result.success).toBe(true);
expect(result.data).toHaveProperty('id');
});
it('should fail without content', async () => {
const result = await router.executeTool('create_pull_request_task', {
workspace: 'test-workspace',
repository: 'test-repo',
pullRequestId: 1,
}) as ToolResult;
expect(result.success).toBe(false);
expect(result.error).toContain('Missing required parameters');
});
});
describe('update_pull_request_task', () => {
it('should update a task', async () => {
const result = await router.executeTool('update_pull_request_task', {
workspace: 'test-workspace',
repository: 'test-repo',
pullRequestId: 1,
taskId: 200,
state: 'RESOLVED',
}) as ToolResult;
expect(result.success).toBe(true);
});
});
describe('delete_pull_request_task', () => {
it('should delete a task', async () => {
const result = await router.executeTool('delete_pull_request_task', {
workspace: 'test-workspace',
repository: 'test-repo',
pullRequestId: 1,
taskId: 200,
}) as ToolResult;
expect(result.success).toBe(true);
});
});
describe('unknown tool', () => { describe('unknown tool', () => {
it('should return error for unknown tool', async () => { it('should return error for unknown tool', async () => {
const result = await router.executeTool('unknown_tool', {}) as ToolResult; const result = await router.executeTool('unknown_tool', {}) as ToolResult;