cli / mcp for bitbucket
at main 316 lines 9.0 kB view raw
1import { Client } from '@modelcontextprotocol/sdk/client/index.js'; 2import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; 3import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; 4import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; 5import { registerAllTools } from '../../tools'; 6 7vi.mock('@bitbucket-tool/core', () => ({ 8 listPullRequests: vi.fn(), 9 getPullRequest: vi.fn(), 10 createPullRequest: vi.fn(), 11 updatePullRequest: vi.fn(), 12 declinePullRequest: vi.fn(), 13 getPullRequestComments: vi.fn(), 14 addComment: vi.fn(), 15 getPullRequestDiff: vi.fn(), 16})); 17 18import { 19 addComment, 20 createPullRequest, 21 declinePullRequest, 22 getPullRequest, 23 getPullRequestComments, 24 getPullRequestDiff, 25 listPullRequests, 26 updatePullRequest, 27} from '@bitbucket-tool/core'; 28 29const ws = 'test-workspace'; 30const repo = 'test-repo'; 31const prId = 42; 32 33const callTool = async (client: Client, name: string, args: Record<string, unknown>) => 34 client.callTool({ name, arguments: args }); 35 36const textOf = (result: Awaited<ReturnType<Client['callTool']>>) => 37 (result.content as Array<{ type: string; text: string }>)[0].text; 38 39describe('PR tools', () => { 40 let client: Client; 41 let server: McpServer; 42 43 beforeAll(async () => { 44 const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); 45 server = new McpServer({ name: 'test', version: '0.0.1' }); 46 client = new Client({ name: 'test-client', version: '0.0.1' }); 47 48 registerAllTools(server); 49 50 await server.connect(serverTransport); 51 await client.connect(clientTransport); 52 }); 53 54 afterAll(async () => { 55 await client.close(); 56 await server.close(); 57 }); 58 59 beforeEach(() => { 60 vi.clearAllMocks(); 61 }); 62 63 describe('list_pull_requests', () => { 64 it('passes params to core and returns JSON response', async () => { 65 const prs = [{ id: 1, title: 'First PR' }]; 66 vi.mocked(listPullRequests).mockResolvedValue({ ok: true, data: prs as never }); 67 68 const result = await callTool(client, 'list_pull_requests', { 69 workspace: ws, 70 repo_slug: repo, 71 state: 'OPEN', 72 }); 73 74 expect(listPullRequests).toHaveBeenCalledWith({ 75 workspace: ws, 76 repoSlug: repo, 77 state: 'OPEN', 78 page: undefined, 79 pagelen: undefined, 80 }); 81 expect(result.isError).toBeFalsy(); 82 expect(JSON.parse(textOf(result))).toEqual(prs); 83 }); 84 85 it('returns error response on failure', async () => { 86 vi.mocked(listPullRequests).mockResolvedValue({ 87 ok: false, 88 error: { status: 401, message: 'Unauthorized' }, 89 }); 90 91 const result = await callTool(client, 'list_pull_requests', { 92 workspace: ws, 93 repo_slug: repo, 94 }); 95 96 expect(result.isError).toBe(true); 97 expect(textOf(result)).toBe('Unauthorized (401)'); 98 }); 99 }); 100 101 describe('get_pull_request', () => { 102 it('passes params to core and returns JSON response', async () => { 103 const pr = { id: prId, title: 'My PR' }; 104 vi.mocked(getPullRequest).mockResolvedValue({ ok: true, data: pr as never }); 105 106 const result = await callTool(client, 'get_pull_request', { 107 workspace: ws, 108 repo_slug: repo, 109 pull_request_id: prId, 110 }); 111 112 expect(getPullRequest).toHaveBeenCalledWith({ 113 workspace: ws, 114 repoSlug: repo, 115 prId, 116 }); 117 expect(JSON.parse(textOf(result))).toEqual(pr); 118 }); 119 }); 120 121 describe('create_pull_request', () => { 122 it('passes params with default destination branch', async () => { 123 const pr = { id: 1, title: 'New PR' }; 124 vi.mocked(createPullRequest).mockResolvedValue({ ok: true, data: pr as never }); 125 126 await callTool(client, 'create_pull_request', { 127 workspace: ws, 128 repo_slug: repo, 129 source_branch: 'feat/cool', 130 title: 'New PR', 131 }); 132 133 expect(createPullRequest).toHaveBeenCalledWith({ 134 workspace: ws, 135 repoSlug: repo, 136 sourceBranch: 'feat/cool', 137 destinationBranch: 'main', 138 title: 'New PR', 139 description: undefined, 140 }); 141 }); 142 143 it('uses custom destination branch when provided', async () => { 144 vi.mocked(createPullRequest).mockResolvedValue({ 145 ok: true, 146 data: { id: 1 } as never, 147 }); 148 149 await callTool(client, 'create_pull_request', { 150 workspace: ws, 151 repo_slug: repo, 152 source_branch: 'feat/cool', 153 destination_branch: 'develop', 154 title: 'New PR', 155 description: 'Some description', 156 }); 157 158 expect(createPullRequest).toHaveBeenCalledWith({ 159 workspace: ws, 160 repoSlug: repo, 161 sourceBranch: 'feat/cool', 162 destinationBranch: 'develop', 163 title: 'New PR', 164 description: 'Some description', 165 }); 166 }); 167 }); 168 169 describe('update_pull_request', () => { 170 it('builds updates object from optional fields', async () => { 171 vi.mocked(updatePullRequest).mockResolvedValue({ 172 ok: true, 173 data: { id: prId } as never, 174 }); 175 176 await callTool(client, 'update_pull_request', { 177 workspace: ws, 178 repo_slug: repo, 179 pull_request_id: prId, 180 title: 'Updated title', 181 destination_branch: 'develop', 182 }); 183 184 expect(updatePullRequest).toHaveBeenCalledWith({ 185 workspace: ws, 186 repoSlug: repo, 187 prId, 188 updates: { 189 title: 'Updated title', 190 destination: { branch: { name: 'develop' } }, 191 }, 192 }); 193 }); 194 195 it('omits undefined fields from updates', async () => { 196 vi.mocked(updatePullRequest).mockResolvedValue({ 197 ok: true, 198 data: { id: prId } as never, 199 }); 200 201 await callTool(client, 'update_pull_request', { 202 workspace: ws, 203 repo_slug: repo, 204 pull_request_id: prId, 205 title: 'Only title', 206 }); 207 208 expect(updatePullRequest).toHaveBeenCalledWith({ 209 workspace: ws, 210 repoSlug: repo, 211 prId, 212 updates: { title: 'Only title' }, 213 }); 214 }); 215 }); 216 217 describe('decline_pull_request', () => { 218 it('passes params to core', async () => { 219 vi.mocked(declinePullRequest).mockResolvedValue({ ok: true, data: undefined as never }); 220 221 const result = await callTool(client, 'decline_pull_request', { 222 workspace: ws, 223 repo_slug: repo, 224 pull_request_id: prId, 225 }); 226 227 expect(declinePullRequest).toHaveBeenCalledWith({ 228 workspace: ws, 229 repoSlug: repo, 230 prId, 231 }); 232 expect(result.isError).toBeFalsy(); 233 expect(textOf(result)).toBe('Pull request declined.'); 234 }); 235 }); 236 237 describe('get_pull_request_comments', () => { 238 it('passes params including pagination to core', async () => { 239 const comments = [{ id: 1, content: { raw: 'LGTM' } }]; 240 vi.mocked(getPullRequestComments).mockResolvedValue({ 241 ok: true, 242 data: comments as never, 243 }); 244 245 const result = await callTool(client, 'get_pull_request_comments', { 246 workspace: ws, 247 repo_slug: repo, 248 pull_request_id: prId, 249 page: 2, 250 pagelen: 10, 251 }); 252 253 expect(getPullRequestComments).toHaveBeenCalledWith({ 254 workspace: ws, 255 repoSlug: repo, 256 prId, 257 page: 2, 258 pagelen: 10, 259 }); 260 expect(JSON.parse(textOf(result))).toEqual(comments); 261 }); 262 }); 263 264 describe('add_pull_request_comment', () => { 265 it('passes params to core', async () => { 266 vi.mocked(addComment).mockResolvedValue({ ok: true, data: undefined as never }); 267 268 const result = await callTool(client, 'add_pull_request_comment', { 269 workspace: ws, 270 repo_slug: repo, 271 pull_request_id: prId, 272 content: 'Nice work!', 273 }); 274 275 expect(addComment).toHaveBeenCalledWith({ 276 workspace: ws, 277 repoSlug: repo, 278 prId, 279 content: 'Nice work!', 280 }); 281 expect(result.isError).toBeFalsy(); 282 expect(textOf(result)).toBe('Comment added.'); 283 }); 284 }); 285 286 describe('get_pull_request_diff', () => { 287 it('returns raw text diff (not JSON)', async () => { 288 const diff = '--- a/file.ts\n+++ b/file.ts\n@@ -1 +1 @@\n-old\n+new'; 289 vi.mocked(getPullRequestDiff).mockResolvedValue({ ok: true, data: diff }); 290 291 const result = await callTool(client, 'get_pull_request_diff', { 292 workspace: ws, 293 repo_slug: repo, 294 pull_request_id: prId, 295 }); 296 297 expect(textOf(result)).toBe(diff); 298 }); 299 300 it('returns error on failure', async () => { 301 vi.mocked(getPullRequestDiff).mockResolvedValue({ 302 ok: false, 303 error: { status: 404, message: 'PR not found' }, 304 }); 305 306 const result = await callTool(client, 'get_pull_request_diff', { 307 workspace: ws, 308 repo_slug: repo, 309 pull_request_id: 999, 310 }); 311 312 expect(result.isError).toBe(true); 313 expect(textOf(result)).toBe('PR not found (404)'); 314 }); 315 }); 316});