Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2df53cbf10 | |||
| 8a410c7fd7 | |||
| 5d911d91a7 | |||
| 148fe7e51b | |||
| 9c7d983df4 | |||
| ab73c92e6d | |||
| 980d79d8d1 | |||
| a7121bc185 | |||
| 93688bc90c | |||
| c340873c6b | |||
| d5cff234cf | |||
| 2ccb27bcda | |||
| 4035179bb9 | |||
| 4394728c37 | |||
| 019638e2ff | |||
| 5bfef6aa35 | |||
| a0ab910137 | |||
| cf60d7a8fe | |||
| b823cc1491 | |||
| 262d1dfd0f | |||
| 666fc05dd0 |
16
.env.template
Normal file
16
.env.template
Normal 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
6
.gitignore
vendored
@@ -6,9 +6,9 @@ dist/
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
*.env
|
||||
*.env.*
|
||||
!*.env.template
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
|
||||
10
CLAUDE.md
10
CLAUDE.md
@@ -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_TOKEN` | Bitbucket API token (Basic Auth password) |
|
||||
| `DEFAULT_WORKSPACE` | Optional default workspace slug |
|
||||
| `DEFAULT_REPO` | Optional default repository slug |
|
||||
| `DEFAULT_WORKSPACE` | Optional default workspace slug (from your repo URL: `https://bitbucket.org/{workspace}/{repo}/...`) |
|
||||
| `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
|
||||
|
||||
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.
|
||||
|
||||
76
README.md
76
README.md
@@ -15,16 +15,19 @@
|
||||
|
||||
## 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
|
||||
|
||||
| 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 |
|
||||
| **Collaboration** | Comments, activities, participants, reviewers |
|
||||
| **Workflow** | Status, tasks, task count |
|
||||
| **Collaboration** | Comments (read/add/edit/delete), activities, participants, reviewers |
|
||||
| **Review Workflow** | Approve, unapprove, request changes, remove request-changes |
|
||||
| **Tasks** | Get tasks, task count, create/update/delete tasks |
|
||||
| **Auth** | Token validation |
|
||||
|
||||
---
|
||||
@@ -51,7 +54,7 @@ npm run build
|
||||
|
||||
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
|
||||
{
|
||||
@@ -92,6 +95,8 @@ Add the MCP server to your Claude configuration:
|
||||
> **Important:**
|
||||
> - 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.
|
||||
> - 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).
|
||||
|
||||
### 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 |
|
||||
|
||||
### 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
|
||||
|
||||
| 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_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
|
||||
|
||||
| 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_commits` | List commits in PR | `workspace`, `repository`, `pullRequestId` |
|
||||
|
||||
### Collaboration
|
||||
### Comments
|
||||
|
||||
| Tool | Description | Key Parameters |
|
||||
|------|-------------|----------------|
|
||||
| `get_pull_request_comments` | Get all comments | `workspace`, `repository`, `pullRequestId` |
|
||||
| `get_pull_request_comment` | Get a specific comment | `workspace`, `repository`, `pullRequestId`, `commentId` |
|
||||
| `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_participants` | Get all participants | `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_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
|
||||
|
||||
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
|
||||
BITBUCKET_MCP_EMAIL=your_email@example.com
|
||||
BITBUCKET_MCP_TOKEN=your_api_token
|
||||
DEFAULT_WORKSPACE=my-workspace
|
||||
DEFAULT_REPO=my-repo
|
||||
|
||||
# Only needed when running write integration tests (see Testing below)
|
||||
# RUN_WRITE_TESTS=true
|
||||
# TEST_PR_ID=123
|
||||
```
|
||||
|
||||
```bash
|
||||
@@ -204,13 +251,13 @@ npm start
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
# Run tests
|
||||
# Run all tests (unit only by default)
|
||||
npm test
|
||||
|
||||
# Run tests in watch mode
|
||||
npm run test:watch
|
||||
|
||||
# Integration tests (requires valid credentials)
|
||||
# Integration tests — read-only, requires valid credentials in .env
|
||||
npm run test:integration
|
||||
|
||||
# Type check
|
||||
@@ -220,6 +267,15 @@ npx tsc --noEmit
|
||||
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
|
||||
|
||||
```
|
||||
|
||||
2202
docs/superpowers/plans/2026-05-20-bitbucket-mcp-features.md
Normal file
2202
docs/superpowers/plans/2026-05-20-bitbucket-mcp-features.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -1,612 +1,118 @@
|
||||
/**
|
||||
* Bitbucket API client with token configuration integration.
|
||||
*/
|
||||
import { ClientOptions } from './clients/base-client.js';
|
||||
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';
|
||||
import { TokenConfigLoader, TokenConfig } from './config.js';
|
||||
export type { CreatePROptions, UpdatePROptions, MergePROptions };
|
||||
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 {
|
||||
private client!: AxiosInstance;
|
||||
private tokenSource: string | null = null;
|
||||
private initialized: boolean = false;
|
||||
private pr!: PullRequestClient;
|
||||
private repo!: RepositoryClient;
|
||||
private comment!: CommentClient;
|
||||
|
||||
constructor(options: BitbucketClientOptions = {}) {
|
||||
// Initialize synchronously to avoid race conditions
|
||||
this.initializeClient(options);
|
||||
// ── Pull Request read ─────────────────────────────────────────────────────
|
||||
listPullRequests!: (workspace: string, repo: string, options?: any) => Promise<any>;
|
||||
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 }> {
|
||||
await this.ensureInitialized();
|
||||
|
||||
try {
|
||||
// Make a simple API call to test authentication
|
||||
const response = await this.client.get('/user');
|
||||
await this.repo.listWorkspaces({ pagelen: 1 });
|
||||
return { valid: true, message: 'Token is valid' };
|
||||
} catch (error: any) {
|
||||
const status = error.response?.status;
|
||||
if (status === 401) {
|
||||
return { valid: false, message: 'Token is invalid or expired' };
|
||||
} else if (status === 403) {
|
||||
return { valid: false, message: 'Token lacks required permissions' };
|
||||
} else {
|
||||
return { valid: false, message: `Token validation failed: ${this.formatError(error)}` };
|
||||
}
|
||||
const status = error?.response?.status ?? error?.cause?.response?.status;
|
||||
if (status === 401) return { valid: false, message: 'Token is invalid or expired' };
|
||||
if (status === 403) return { valid: false, message: 'Token lacks required permissions' };
|
||||
return { valid: false, message: `Token validation failed: ${error?.message || 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
102
src/clients/base-client.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
154
src/clients/comment-client.ts
Normal file
154
src/clients/comment-client.ts
Normal 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)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
390
src/clients/pull-request-client.ts
Normal file
390
src/clients/pull-request-client.ts
Normal 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)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
73
src/clients/repository-client.ts
Normal file
73
src/clients/repository-client.ts
Normal 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)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
263
src/index.ts
263
src/index.ts
@@ -262,6 +262,269 @@ class BitbucketMCPServer {
|
||||
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'],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
340
src/router.ts
340
src/router.ts
@@ -107,6 +107,60 @@ export class BitbucketRouter {
|
||||
case 'get_full_pull_request':
|
||||
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:
|
||||
return {
|
||||
success: false,
|
||||
@@ -484,6 +538,292 @@ export class BitbucketRouter {
|
||||
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;
|
||||
@@ -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(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,68 +2,118 @@
|
||||
* 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';
|
||||
|
||||
// Mock the BitbucketClient
|
||||
vi.mock('../../src/bitbucket-client.js', () => {
|
||||
// Mock domain clients - use simple object mocks instead of nested vi.fn
|
||||
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 {
|
||||
BitbucketClient: vi.fn().mockImplementation(() => ({
|
||||
listPullRequests: vi.fn().mockResolvedValue([
|
||||
{ id: 1, title: 'Test PR 1', state: 'OPEN' },
|
||||
{ id: 2, title: 'Test PR 2', state: 'MERGED' }
|
||||
]),
|
||||
getPullRequest: vi.fn().mockResolvedValue({
|
||||
id: 1,
|
||||
title: 'Test PR',
|
||||
state: 'OPEN',
|
||||
source: { branch: { name: 'feature' } },
|
||||
destination: { branch: { name: 'main' } }
|
||||
}),
|
||||
getPullRequestStatus: vi.fn().mockResolvedValue({
|
||||
id: 1,
|
||||
title: 'Test PR',
|
||||
state: 'OPEN',
|
||||
status: 'NORMAL'
|
||||
}),
|
||||
getPullRequestActivities: vi.fn().mockResolvedValue({
|
||||
values: [{ action: 'OPEN' }]
|
||||
}),
|
||||
getPullRequestChanges: vi.fn().mockResolvedValue({
|
||||
values: [{ type: 'modified', path: 'src/test.ts' }]
|
||||
}),
|
||||
getPullRequestComments: vi.fn().mockResolvedValue({
|
||||
values: [{ id: 1, content: { raw: 'Test comment' } }]
|
||||
}),
|
||||
getPullRequestComment: vi.fn().mockResolvedValue({
|
||||
id: 1,
|
||||
content: { raw: 'Test comment' }
|
||||
}),
|
||||
getPullRequestCommits: vi.fn().mockResolvedValue({
|
||||
values: [{ hash: 'abc123' }]
|
||||
}),
|
||||
getPullRequestDiff: vi.fn().mockResolvedValue({
|
||||
diff: '--- test.ts\n+++ test.ts\n@@ -1 +1 @@'
|
||||
}),
|
||||
getPullRequestPatch: vi.fn().mockResolvedValue({
|
||||
patch: '--- original\n+++ modified'
|
||||
}),
|
||||
getPullRequestParticipants: vi.fn().mockResolvedValue({
|
||||
values: [{ user: { display_name: 'Test User' } }]
|
||||
}),
|
||||
getPullRequestReviewers: vi.fn().mockResolvedValue([
|
||||
{ user: { display_name: 'Reviewer 1' } }
|
||||
]),
|
||||
getPullRequestTasks: vi.fn().mockResolvedValue({
|
||||
values: []
|
||||
}),
|
||||
getPullRequestTaskCount: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
getFullPullRequest: vi.fn().mockResolvedValue({
|
||||
id: 1,
|
||||
title: 'Test PR',
|
||||
description: 'Test description'
|
||||
})
|
||||
}))
|
||||
PullRequestClient: class {
|
||||
constructor() {
|
||||
Object.assign(this, mockPRClient);
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../src/clients/repository-client.js', () => {
|
||||
const mockRepoClient = {
|
||||
listWorkspaces: async () => ({ values: [{ slug: 'my-workspace' }] }),
|
||||
listRepositories: async () => ({ values: [{ slug: 'my-repo', name: 'My Repo' }] }),
|
||||
getRepository: async () => ({ slug: 'my-repo', name: 'My Repo', full_name: 'ws/my-repo' }),
|
||||
listBranches: async () => ({ values: [{ name: 'main' }, { name: 'develop' }] })
|
||||
};
|
||||
|
||||
return {
|
||||
RepositoryClient: class {
|
||||
constructor() {
|
||||
Object.assign(this, mockRepoClient);
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../src/clients/comment-client.js', () => {
|
||||
const mockCommentClient = {
|
||||
getPullRequestComments: async () => ({
|
||||
values: [{ id: 1, content: { raw: 'Test comment' } }]
|
||||
}),
|
||||
getPullRequestComment: async () => ({
|
||||
id: 1,
|
||||
content: { raw: 'Test comment' }
|
||||
}),
|
||||
addPullRequestComment: async () => ({ id: 100, content: { raw: 'New comment' } }),
|
||||
updatePullRequestComment: async () => ({ id: 100, content: { raw: 'Updated' } }),
|
||||
deletePullRequestComment: async () => ({ success: true }),
|
||||
createPullRequestTask: async () => ({ id: 200, content: { raw: 'Task' }, state: 'UNRESOLVED' }),
|
||||
updatePullRequestTask: async () => ({ id: 200, state: 'RESOLVED' }),
|
||||
deletePullRequestTask: async () => ({ success: true })
|
||||
};
|
||||
|
||||
return {
|
||||
CommentClient: class {
|
||||
constructor() {
|
||||
Object.assign(this, mockCommentClient);
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
it('should return error for unknown tool', async () => {
|
||||
const result = await router.executeTool('unknown_tool', {}) as ToolResult;
|
||||
|
||||
Reference in New Issue
Block a user