@atlassian-dc-mcp/bitbucket 0.28.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +53 -1
  3. package/build/__tests__/bitbucket-service.test.js +25 -1
  4. package/build/__tests__/bitbucket-service.test.js.map +1 -1
  5. package/build/__tests__/bitbucket-token-optimization.test.js +11 -0
  6. package/build/__tests__/bitbucket-token-optimization.test.js.map +1 -1
  7. package/build/__tests__/merge-gateway.test.d.ts +2 -0
  8. package/build/__tests__/merge-gateway.test.d.ts.map +1 -0
  9. package/build/__tests__/merge-gateway.test.js +98 -0
  10. package/build/__tests__/merge-gateway.test.js.map +1 -0
  11. package/build/__tests__/pr-comment-mapper.test.js +91 -11
  12. package/build/__tests__/pr-comment-mapper.test.js.map +1 -1
  13. package/build/__tests__/pr-merge.test.d.ts +2 -0
  14. package/build/__tests__/pr-merge.test.d.ts.map +1 -0
  15. package/build/__tests__/pr-merge.test.js +166 -0
  16. package/build/__tests__/pr-merge.test.js.map +1 -0
  17. package/build/bitbucket-response-mapper.d.ts +10 -0
  18. package/build/bitbucket-response-mapper.d.ts.map +1 -1
  19. package/build/bitbucket-response-mapper.js +16 -0
  20. package/build/bitbucket-response-mapper.js.map +1 -1
  21. package/build/bitbucket-service.d.ts +53 -9
  22. package/build/bitbucket-service.d.ts.map +1 -1
  23. package/build/bitbucket-service.js +70 -15
  24. package/build/bitbucket-service.js.map +1 -1
  25. package/build/index.js +30 -4
  26. package/build/index.js.map +1 -1
  27. package/build/merge-gateway.d.ts +34 -0
  28. package/build/merge-gateway.d.ts.map +1 -0
  29. package/build/merge-gateway.js +86 -0
  30. package/build/merge-gateway.js.map +1 -0
  31. package/build/pr-comment-mapper.d.ts +3 -0
  32. package/build/pr-comment-mapper.d.ts.map +1 -1
  33. package/build/pr-comment-mapper.js +8 -3
  34. package/build/pr-comment-mapper.js.map +1 -1
  35. package/build/pr-merge.d.ts +23 -0
  36. package/build/pr-merge.d.ts.map +1 -0
  37. package/build/pr-merge.js +59 -0
  38. package/build/pr-merge.js.map +1 -0
  39. package/package.json +3 -3
  40. package/src/__tests__/bitbucket-service.test.ts +78 -1
  41. package/src/__tests__/bitbucket-token-optimization.test.ts +11 -0
  42. package/src/__tests__/merge-gateway.test.ts +119 -0
  43. package/src/__tests__/pr-comment-mapper.test.ts +95 -11
  44. package/src/__tests__/pr-merge.test.ts +206 -0
  45. package/src/bitbucket-response-mapper.ts +27 -0
  46. package/src/bitbucket-service.ts +75 -14
  47. package/src/index.ts +45 -6
  48. package/src/merge-gateway.ts +132 -0
  49. package/src/pr-comment-mapper.ts +11 -3
  50. package/src/pr-merge.ts +94 -0
  51. package/tsconfig.tsbuildinfo +1 -1
@@ -140,7 +140,8 @@ describe('PR Comment Mapper', () => {
140
140
  },
141
141
  comments: [],
142
142
  threadResolved: false,
143
- state: "OPEN"
143
+ state: "OPEN",
144
+ severity: "NORMAL"
144
145
  }
145
146
  },
146
147
  {
@@ -160,13 +161,85 @@ describe('PR Comment Mapper', () => {
160
161
  displayName: "User B"
161
162
  },
162
163
  commentCount: 1,
163
- unresolvedCount: 1
164
+ unresolvedCount: 1,
165
+ blockerCount: 0,
166
+ unresolvedBlockerCount: 0
164
167
  }
165
168
  };
166
169
 
167
170
  expect(result).toEqual(expectedResult);
168
171
  });
169
172
 
173
+ it('should expose BLOCKER severity so tasks are distinguishable from normal comments', () => {
174
+ const blockerResponse: BitbucketPRApiResponse = {
175
+ ...validPRResponse,
176
+ values: [
177
+ {
178
+ id: 1001,
179
+ createdDate: 1600000000000,
180
+ user: createUser('reviewer1', 106, 'Reviewer One'),
181
+ action: 'COMMENTED',
182
+ commentAction: 'ADDED',
183
+ comment: createComment({
184
+ id: 2010,
185
+ text: 'Blocking: fix this',
186
+ severity: 'BLOCKER',
187
+ comments: [
188
+ createComment({
189
+ id: 2011,
190
+ text: 'Reply keeps its own severity',
191
+ anchor: undefined,
192
+ severity: 'NORMAL'
193
+ })
194
+ ]
195
+ })
196
+ }
197
+ ]
198
+ };
199
+
200
+ const result = simplifyBitbucketPRComments(blockerResponse) as SimplifiedPRResponse;
201
+ expect(result.activities[0].comment?.severity).toBe('BLOCKER');
202
+ expect(result.activities[0].comment?.comments[0].severity).toBe('NORMAL');
203
+ });
204
+
205
+ it('should count blockers and unresolved blockers by task state in the summary', () => {
206
+ const blockerResponse: BitbucketPRApiResponse = {
207
+ ...validPRResponse,
208
+ values: [
209
+ openedActivity!,
210
+ {
211
+ id: 1010,
212
+ createdDate: 1600000003000,
213
+ user: createUser('reviewer1', 106, 'Reviewer One'),
214
+ action: 'COMMENTED',
215
+ commentAction: 'ADDED',
216
+ comment: createComment({ id: 2020, text: 'Unresolved task', severity: 'BLOCKER', state: 'OPEN' })
217
+ },
218
+ {
219
+ id: 1011,
220
+ createdDate: 1600000004000,
221
+ user: createUser('reviewer1', 106, 'Reviewer One'),
222
+ action: 'COMMENTED',
223
+ commentAction: 'ADDED',
224
+ comment: createComment({ id: 2021, text: 'Ticked task', severity: 'BLOCKER', state: 'RESOLVED' })
225
+ },
226
+ {
227
+ id: 1012,
228
+ createdDate: 1600000005000,
229
+ user: createUser('reviewer1', 106, 'Reviewer One'),
230
+ action: 'COMMENTED',
231
+ commentAction: 'ADDED',
232
+ comment: createComment({ id: 2022, text: 'Just a note', severity: 'NORMAL', state: 'OPEN' })
233
+ }
234
+ ]
235
+ };
236
+
237
+ const result = simplifyBitbucketPRComments(blockerResponse) as SimplifiedPRResponse;
238
+ expect(result.summary.commentCount).toBe(3);
239
+ expect(result.summary.blockerCount).toBe(2);
240
+ expect(result.summary.unresolvedBlockerCount).toBe(1);
241
+ });
242
+
170
243
  it('should preserve nested replies recursively', () => {
171
244
  const threadedResponse: BitbucketPRApiResponse = {
172
245
  ...validPRResponse,
@@ -220,11 +293,13 @@ describe('PR Comment Mapper', () => {
220
293
  createdDate: 1600000000000,
221
294
  comments: [],
222
295
  threadResolved: false,
223
- state: "OPEN"
296
+ state: "OPEN",
297
+ severity: "NORMAL"
224
298
  }
225
299
  ],
226
300
  threadResolved: false,
227
- state: "OPEN"
301
+ state: "OPEN",
302
+ severity: "NORMAL"
228
303
  }
229
304
  ]);
230
305
  });
@@ -294,7 +369,8 @@ describe('PR Comment Mapper', () => {
294
369
  },
295
370
  comments: [],
296
371
  threadResolved: false,
297
- state: 'OPEN'
372
+ state: 'OPEN',
373
+ severity: 'NORMAL'
298
374
  }
299
375
  }
300
376
  ]);
@@ -355,11 +431,13 @@ describe('PR Comment Mapper', () => {
355
431
  createdDate: 1600000000000,
356
432
  comments: [],
357
433
  threadResolved: true,
358
- state: 'OPEN'
434
+ state: 'OPEN',
435
+ severity: 'NORMAL'
359
436
  }
360
437
  ],
361
438
  threadResolved: true,
362
- state: 'OPEN'
439
+ state: 'OPEN',
440
+ severity: 'NORMAL'
363
441
  });
364
442
  expect(getCommentSummary(resolvedThreadResponse, { includeResolved: true })).toEqual([
365
443
  'User A on config.yml:6: Resolved thread root'
@@ -402,7 +480,8 @@ describe('PR Comment Mapper', () => {
402
480
  createdDate: 1600000000000,
403
481
  comments: [],
404
482
  threadResolved: false,
405
- state: "OPEN"
483
+ state: "OPEN",
484
+ severity: "NORMAL"
406
485
  }
407
486
  ]);
408
487
  });
@@ -447,7 +526,8 @@ describe('PR Comment Mapper', () => {
447
526
  createdDate: 1600000000000,
448
527
  comments: [],
449
528
  threadResolved: false,
450
- state: "OPEN"
529
+ state: "OPEN",
530
+ severity: "NORMAL"
451
531
  }
452
532
  ]);
453
533
 
@@ -465,7 +545,9 @@ describe('PR Comment Mapper', () => {
465
545
  summary: {
466
546
  totalActivities: 0,
467
547
  commentCount: 0,
468
- unresolvedCount: 0
548
+ unresolvedCount: 0,
549
+ blockerCount: 0,
550
+ unresolvedBlockerCount: 0
469
551
  }
470
552
  };
471
553
 
@@ -489,7 +571,9 @@ describe('PR Comment Mapper', () => {
489
571
  summary: {
490
572
  totalActivities: 0,
491
573
  commentCount: 0,
492
- unresolvedCount: 0
574
+ unresolvedCount: 0,
575
+ blockerCount: 0,
576
+ unresolvedBlockerCount: 0
493
577
  }
494
578
  };
495
579
 
@@ -0,0 +1,206 @@
1
+ import { PullRequestsService } from '../bitbucket-client/index.js';
2
+ import { fetchMergeability, mergePullRequest } from '../pr-merge.js';
3
+ import type { MergeGateway } from '../merge-gateway.js';
4
+
5
+ jest.mock('../bitbucket-client/index.js', () => ({
6
+ PullRequestsService: {
7
+ canMerge: jest.fn(),
8
+ merge: jest.fn(),
9
+ get3: jest.fn(),
10
+ },
11
+ OpenAPI: { BASE: '', TOKEN: '', VERSION: '' },
12
+ }));
13
+
14
+ const canMerge = PullRequestsService.canMerge as jest.Mock;
15
+ const merge = PullRequestsService.merge as jest.Mock;
16
+ const getPullRequest = PullRequestsService.get3 as jest.Mock;
17
+
18
+ const OPEN_GATEWAY: MergeGateway = { enabled: true, repos: ['PROJ/demo'], targetRefs: [] };
19
+ const CLEAN = { canMerge: true, conflicted: false, outcome: 'CLEAN', vetoes: [] };
20
+ const MERGED_PR = {
21
+ id: 42,
22
+ version: 3,
23
+ title: 'Add merge tool',
24
+ state: 'MERGED',
25
+ fromRef: { id: 'refs/heads/feature' },
26
+ toRef: { id: 'refs/heads/develop' },
27
+ reviewers: [],
28
+ };
29
+
30
+ const baseParams = {
31
+ projectKey: 'PROJ',
32
+ repositorySlug: 'demo',
33
+ pullRequestId: '42',
34
+ version: 2,
35
+ };
36
+
37
+ describe('fetchMergeability', () => {
38
+ beforeEach(() => jest.clearAllMocks());
39
+
40
+ it('shapes vetoes into summary/detail pairs', async () => {
41
+ canMerge.mockResolvedValue({
42
+ canMerge: false,
43
+ conflicted: false,
44
+ outcome: 'VETOED',
45
+ vetoes: [{ summaryMessage: 'Not enough approvals', detailedMessage: '2 required, 1 given' }],
46
+ });
47
+
48
+ const result = await fetchMergeability('PROJ', 'demo', '42');
49
+
50
+ expect(canMerge).toHaveBeenCalledWith('PROJ', '42', 'demo');
51
+ expect(result.data).toEqual({
52
+ canMerge: false,
53
+ conflicted: false,
54
+ outcome: 'VETOED',
55
+ vetoes: [{ summary: 'Not enough approvals', detail: '2 required, 1 given' }],
56
+ });
57
+ });
58
+
59
+ it('reports API failures without shaping', async () => {
60
+ canMerge.mockRejectedValue(new Error('boom'));
61
+
62
+ const result = await fetchMergeability('PROJ', 'demo', '42');
63
+
64
+ expect(result.success).toBe(false);
65
+ expect(result.error).toBe('boom');
66
+ });
67
+ });
68
+
69
+ describe('mergePullRequest', () => {
70
+ beforeEach(() => jest.clearAllMocks());
71
+
72
+ it('merges with the version as an optimistic lock and returns a compact ack', async () => {
73
+ canMerge.mockResolvedValue(CLEAN);
74
+ merge.mockResolvedValue(MERGED_PR);
75
+
76
+ const result = await mergePullRequest({ ...baseParams, gateway: OPEN_GATEWAY });
77
+
78
+ expect(merge).toHaveBeenCalledWith('PROJ', '42', 'demo', '2', { version: 2 });
79
+ expect(result.success).toBe(true);
80
+ expect(result.data).toEqual({
81
+ id: 42,
82
+ version: 3,
83
+ title: 'Add merge tool',
84
+ state: 'MERGED',
85
+ fromRefId: 'refs/heads/feature',
86
+ toRefId: 'refs/heads/develop',
87
+ reviewerCount: 0,
88
+ });
89
+ });
90
+
91
+ it('passes the strategy and message through, and can return the full payload', async () => {
92
+ canMerge.mockResolvedValue(CLEAN);
93
+ merge.mockResolvedValue(MERGED_PR);
94
+
95
+ const result = await mergePullRequest({
96
+ ...baseParams,
97
+ gateway: OPEN_GATEWAY,
98
+ strategyId: 'squash',
99
+ message: 'Squashed',
100
+ output: 'full',
101
+ });
102
+
103
+ expect(merge).toHaveBeenCalledWith('PROJ', '42', 'demo', '2', {
104
+ version: 2,
105
+ strategyId: 'squash',
106
+ message: 'Squashed',
107
+ });
108
+ expect(result.data).toBe(MERGED_PR);
109
+ });
110
+
111
+ it('refuses without any request when merging is disabled', async () => {
112
+ const result = await mergePullRequest({
113
+ ...baseParams,
114
+ gateway: { enabled: false, repos: [], targetRefs: [] },
115
+ });
116
+
117
+ expect(result.success).toBe(false);
118
+ expect(result.error).toMatch(/disabled on this server/);
119
+ expect(canMerge).not.toHaveBeenCalled();
120
+ expect(merge).not.toHaveBeenCalled();
121
+ });
122
+
123
+ it('refuses a repository outside the allowed list without any request', async () => {
124
+ const result = await mergePullRequest({
125
+ ...baseParams,
126
+ repositorySlug: 'other-repo',
127
+ gateway: OPEN_GATEWAY,
128
+ });
129
+
130
+ expect(result.success).toBe(false);
131
+ expect(result.error).toMatch(/not allowed in PROJ\/other-repo/);
132
+ expect(merge).not.toHaveBeenCalled();
133
+ });
134
+
135
+ it('refuses a target branch outside the allowed refs and never merges', async () => {
136
+ getPullRequest.mockResolvedValue({ toRef: { id: 'refs/heads/master' } });
137
+
138
+ const result = await mergePullRequest({
139
+ ...baseParams,
140
+ gateway: { enabled: true, repos: ['PROJ/demo'], targetRefs: ['refs/heads/develop'] },
141
+ });
142
+
143
+ expect(result.success).toBe(false);
144
+ expect(result.error).toMatch(/refs\/heads\/master is not allowed/);
145
+ expect(canMerge).not.toHaveBeenCalled();
146
+ expect(merge).not.toHaveBeenCalled();
147
+ });
148
+
149
+ it('merges when the pull request targets an allowed branch', async () => {
150
+ getPullRequest.mockResolvedValue({ toRef: { id: 'refs/heads/release/1.2' } });
151
+ canMerge.mockResolvedValue(CLEAN);
152
+ merge.mockResolvedValue(MERGED_PR);
153
+
154
+ const result = await mergePullRequest({
155
+ ...baseParams,
156
+ gateway: { enabled: true, repos: ['PROJ/demo'], targetRefs: ['refs/heads/release/*'] },
157
+ });
158
+
159
+ expect(result.success).toBe(true);
160
+ expect(merge).toHaveBeenCalled();
161
+ });
162
+
163
+ it('does not fetch the pull request when target branches are unrestricted', async () => {
164
+ canMerge.mockResolvedValue(CLEAN);
165
+ merge.mockResolvedValue(MERGED_PR);
166
+
167
+ await mergePullRequest({ ...baseParams, gateway: OPEN_GATEWAY });
168
+
169
+ expect(getPullRequest).not.toHaveBeenCalled();
170
+ });
171
+
172
+ it('refuses a conflicted pull request before issuing the merge', async () => {
173
+ canMerge.mockResolvedValue({ canMerge: false, conflicted: true, outcome: 'CONFLICTED', vetoes: [] });
174
+
175
+ const result = await mergePullRequest({ ...baseParams, gateway: OPEN_GATEWAY });
176
+
177
+ expect(result.success).toBe(false);
178
+ expect(result.error).toMatch(/has conflicts/);
179
+ expect(merge).not.toHaveBeenCalled();
180
+ });
181
+
182
+ it('refuses a vetoed pull request and reports the veto reasons', async () => {
183
+ canMerge.mockResolvedValue({
184
+ canMerge: false,
185
+ conflicted: false,
186
+ vetoes: [{ summaryMessage: 'Unresolved tasks', detailedMessage: '1 open task' }],
187
+ });
188
+
189
+ const result = await mergePullRequest({ ...baseParams, gateway: OPEN_GATEWAY });
190
+
191
+ expect(result.success).toBe(false);
192
+ expect(result.error).toMatch(/merge check vetoed the merge\. Unresolved tasks — 1 open task/);
193
+ expect(merge).not.toHaveBeenCalled();
194
+ });
195
+
196
+ it('surfaces a server-side merge failure such as a stale version', async () => {
197
+ canMerge.mockResolvedValue(CLEAN);
198
+ merge.mockRejectedValue({ status: 409, statusText: 'Conflict', body: { errors: [{ message: 'stale' }] } });
199
+
200
+ const result = await mergePullRequest({ ...baseParams, gateway: OPEN_GATEWAY });
201
+
202
+ expect(result.success).toBe(false);
203
+ expect(result.error).toBe('Error merging pull request: 409 Conflict');
204
+ expect(result.details).toEqual({ errors: [{ message: 'stale' }] });
205
+ });
206
+ });
@@ -52,6 +52,8 @@ export function shapePullRequestCommentsResponse(
52
52
  totalActivities: Array.isArray(filteredResponse.values) ? filteredResponse.values.length : 0,
53
53
  commentCount: getCommentSummary(filteredResponse, options).length,
54
54
  unresolvedCount: 0,
55
+ blockerCount: 0,
56
+ unresolvedBlockerCount: 0,
55
57
  },
56
58
  items: getCommentSummary(filteredResponse, options),
57
59
  };
@@ -100,12 +102,37 @@ export function shapePullRequestAck(pullRequest: any): Record<string, any> {
100
102
  };
101
103
  }
102
104
 
105
+ export interface MergeVeto {
106
+ summary?: string;
107
+ detail?: string;
108
+ }
109
+
110
+ export function shapeMergeability(mergeability: any): {
111
+ canMerge: boolean;
112
+ conflicted: boolean;
113
+ outcome?: string;
114
+ vetoes: MergeVeto[];
115
+ } {
116
+ const vetoes = Array.isArray(mergeability?.vetoes) ? mergeability.vetoes : [];
117
+ return {
118
+ canMerge: mergeability?.canMerge === true,
119
+ conflicted: mergeability?.conflicted === true,
120
+ ...(typeof mergeability?.outcome === 'string' ? { outcome: mergeability.outcome } : {}),
121
+ vetoes: vetoes.map((veto: any) => ({
122
+ ...(typeof veto?.summaryMessage === 'string' ? { summary: veto.summaryMessage } : {}),
123
+ ...(typeof veto?.detailedMessage === 'string' ? { detail: veto.detailedMessage } : {}),
124
+ })),
125
+ };
126
+ }
127
+
103
128
  export function shapePullRequestCommentAck(comment: any): Record<string, any> {
104
129
  const link = getLink(comment?.links);
105
130
  return {
106
131
  ...(comment?.id !== undefined ? { id: comment.id } : {}),
107
132
  ...(comment?.parent?.id !== undefined ? { parentId: comment.parent.id } : {}),
108
133
  ...(typeof comment?.state === 'string' ? { state: comment.state } : {}),
134
+ ...(typeof comment?.threadResolved === 'boolean' ? { threadResolved: comment.threadResolved } : {}),
135
+ ...(typeof comment?.severity === 'string' ? { severity: comment.severity } : {}),
109
136
  pending: comment?.state === 'PENDING',
110
137
  ...(typeof comment?.anchor?.path === 'string'
111
138
  ? {
@@ -4,6 +4,7 @@ import { request as __request } from './bitbucket-client/core/request.js';
4
4
  import { handleApiOperation, resolveOpenApiBase } from '@atlassian-dc-mcp/common';
5
5
  import { simplifyInboxPullRequests } from './inbox-pr-mapper.js';
6
6
  import { BITBUCKET_PRODUCT, getDefaultPageSize, getMissingConfig } from './config.js';
7
+ import { fetchMergeability, mergePullRequest, type MergePullRequestParams } from './pr-merge.js';
7
8
  import {
8
9
  BitbucketMutationOutputMode,
9
10
  BitbucketOutputMode,
@@ -460,18 +461,20 @@ export class BitbucketService {
460
461
  }
461
462
 
462
463
  /**
463
- * Update an existing pull request comment. Use this to edit text, change severity, or change state.
464
- * On a BLOCKER (task) comment, setting state to 'RESOLVED' ticks the task. Note: this is NOT the same
465
- * as the thread-level "Resolve" button on regular comment threads that operates on CommentThread.resolved
466
- * and is a separate concept not exposed by this endpoint.
464
+ * Update an existing pull request comment. Use this to edit text, change severity, change state, or
465
+ * resolve/reopen a comment thread. State and thread resolution are independent: `state` is the task
466
+ * state of a BLOCKER comment ('RESOLVED' ticks the task, 'OPEN' un-ticks it), while `threadResolved`
467
+ * toggles the thread-level "Resolve" button. A root BLOCKER comment can hold these independently, so
468
+ * they are sent as separate fields.
467
469
  * @param projectKey The project key
468
470
  * @param repositorySlug The repository slug
469
471
  * @param pullRequestId The pull request ID
470
472
  * @param commentId The comment ID to update
471
473
  * @param version The current version of the comment (required for optimistic locking)
472
474
  * @param text Optional new comment text
473
- * @param state Optional new state. On a BLOCKER comment, 'RESOLVED' ticks the task and 'OPEN' un-ticks it.
475
+ * @param state Optional new task state. On a BLOCKER comment, 'RESOLVED' ticks the task and 'OPEN' un-ticks it.
474
476
  * @param severity Optional new severity. 'BLOCKER' converts a comment into a task, 'NORMAL' converts a task back to a regular comment.
477
+ * @param threadResolved Optional thread resolution. `true` resolves the comment thread, `false` reopens it.
475
478
  * @returns Promise with updated comment data
476
479
  */
477
480
  async updatePullRequestComment(
@@ -483,6 +486,7 @@ export class BitbucketService {
483
486
  text?: string,
484
487
  state?: 'OPEN' | 'RESOLVED',
485
488
  severity?: 'NORMAL' | 'BLOCKER',
489
+ threadResolved?: boolean,
486
490
  output: BitbucketMutationOutputMode = 'ack'
487
491
  ) {
488
492
  projectKey = projectKey.toUpperCase();
@@ -501,6 +505,10 @@ export class BitbucketService {
501
505
  comment.severity = severity;
502
506
  }
503
507
 
508
+ if (threadResolved !== undefined) {
509
+ comment.threadResolved = threadResolved;
510
+ }
511
+
504
512
  const result = await handleApiOperation(
505
513
  () => PullRequestsService.updateComment2(
506
514
  projectKey,
@@ -522,6 +530,36 @@ export class BitbucketService {
522
530
  return result;
523
531
  }
524
532
 
533
+ /**
534
+ * Check whether a pull request can be merged. Read-only: reports conflicts and any
535
+ * merge-check vetoes without changing anything.
536
+ * @param projectKey The project key
537
+ * @param repositorySlug The repository slug
538
+ * @param pullRequestId The pull request ID
539
+ * @returns Promise with the mergeability status
540
+ */
541
+ async getPullRequestMergeability(projectKey: string, repositorySlug: string, pullRequestId: string) {
542
+ return fetchMergeability(projectKey.toUpperCase(), repositorySlug.toLowerCase(), pullRequestId);
543
+ }
544
+
545
+ /**
546
+ * Merge a pull request. Only permitted for repositories (and target branches) the operator
547
+ * allowed through the merge gateway; see merge-gateway.ts. A conflicted or vetoed pull
548
+ * request is refused before the merge request is sent.
549
+ * @param params.version The current pull request version, required for optimistic locking
550
+ * @param params.strategyId Optional merge strategy id (e.g. 'no-ff', 'squash', 'ff')
551
+ * @param params.message Optional merge commit message
552
+ * @param params.gateway The resolved operator merge policy
553
+ * @returns Promise with the merged pull request
554
+ */
555
+ async mergePullRequest(params: MergePullRequestParams) {
556
+ return mergePullRequest({
557
+ ...params,
558
+ projectKey: params.projectKey.toUpperCase(),
559
+ repositorySlug: params.repositorySlug.toLowerCase(),
560
+ });
561
+ }
562
+
525
563
  /**
526
564
  * Get a user by slug, or search for users by name/email filter
527
565
  * @param userSlug Optional exact slug to look up a specific user
@@ -649,8 +687,8 @@ export class BitbucketService {
649
687
 
650
688
  /**
651
689
  * Create a pull request
652
- * @param projectKey The project key
653
- * @param repositorySlug The repository slug
690
+ * @param projectKey The project key (also used as the destination repository's project)
691
+ * @param repositorySlug The repository slug (also used as the destination repository's slug)
654
692
  * @param title The pull request title
655
693
  * @param description Optional pull request description
656
694
  * @param fromRefId The source branch (e.g., 'refs/heads/feature-branch')
@@ -658,6 +696,10 @@ export class BitbucketService {
658
696
  * @param reviewers Optional array of reviewer usernames
659
697
  * @param draft Optional flag to create the pull request as a draft
660
698
  * @param output Return a compact acknowledgement or the full API response. Defaults to 'ack'.
699
+ * @param fromProjectKey Optional source repository's project key, when it differs from
700
+ * projectKey (fork-based pull requests). Defaults to projectKey.
701
+ * @param fromRepositorySlug Optional source repository's slug, when it differs from
702
+ * repositorySlug (fork-based pull requests). Defaults to repositorySlug.
661
703
  * @returns Promise with created pull request data
662
704
  */
663
705
  async createPullRequest(
@@ -669,7 +711,9 @@ export class BitbucketService {
669
711
  toRefId: string,
670
712
  reviewers?: string[],
671
713
  draft?: boolean,
672
- output: BitbucketMutationOutputMode = 'ack'
714
+ output: BitbucketMutationOutputMode = 'ack',
715
+ fromProjectKey?: string,
716
+ fromRepositorySlug?: string
673
717
  ) {
674
718
  projectKey = projectKey.toUpperCase();
675
719
  repositorySlug = repositorySlug.toLowerCase();
@@ -679,9 +723,9 @@ export class BitbucketService {
679
723
  fromRef: {
680
724
  id: fromRefId,
681
725
  repository: {
682
- slug: repositorySlug,
726
+ slug: (fromRepositorySlug ?? repositorySlug).toLowerCase(),
683
727
  project: {
684
- key: projectKey
728
+ key: (fromProjectKey ?? projectKey).toUpperCase()
685
729
  }
686
730
  }
687
731
  },
@@ -1035,8 +1079,9 @@ export const bitbucketToolSchemas = {
1035
1079
  commentId: z.string().describe("The ID of the comment to update"),
1036
1080
  version: z.number().describe("The current version of the comment, required for optimistic locking. Get it from bitbucket_getPR_CommentsAndAction or from the response of the original post/update."),
1037
1081
  text: z.string().optional().describe("New comment text. Omit to leave unchanged."),
1038
- state: z.enum(['OPEN', 'RESOLVED']).optional().describe("New state. On a BLOCKER (task) comment, 'RESOLVED' ticks the task and 'OPEN' un-ticks it. This is NOT the thread-level 'Resolve' button on regular comment threads that is a separate concept (CommentThread.resolved) and is not exposed by this endpoint."),
1082
+ state: z.enum(['OPEN', 'RESOLVED']).optional().describe("New task state. On a BLOCKER (task) comment, 'RESOLVED' ticks the task and 'OPEN' un-ticks it. This is the task state onlyto resolve or reopen the comment thread itself use threadResolved."),
1039
1083
  severity: z.enum(['NORMAL', 'BLOCKER']).optional().describe("New severity. Use 'BLOCKER' to convert a comment into a task, 'NORMAL' to convert it back."),
1084
+ threadResolved: z.boolean().optional().describe("Thread resolution. Set true to resolve the comment thread (the 'Resolve' button) or false to reopen it. Independent of state: a BLOCKER comment's task state and its thread resolution can differ."),
1040
1085
  output: z.enum(['ack', 'full']).optional().describe("Return a compact acknowledgement or the full API response. Defaults to ack.")
1041
1086
  },
1042
1087
  getUser: {
@@ -1064,15 +1109,17 @@ export const bitbucketToolSchemas = {
1064
1109
  whitespace: z.string().optional().describe("Optional whitespace flag which can be set to 'ignore-all'")
1065
1110
  },
1066
1111
  createPullRequest: {
1067
- projectKey: z.string().describe("The project key"),
1068
- repositorySlug: z.string().describe("The repository slug"),
1112
+ projectKey: z.string().describe("The destination repository's project key"),
1113
+ repositorySlug: z.string().describe("The destination repository's slug"),
1069
1114
  title: z.string().describe("The pull request title"),
1070
1115
  description: z.string().optional().describe("The pull request description"),
1071
1116
  fromRefId: z.string().describe("The source branch reference ID (e.g., 'refs/heads/feature-branch')"),
1072
1117
  toRefId: z.string().describe("The destination branch reference ID (e.g., 'refs/heads/main')"),
1073
1118
  draft: z.boolean().optional().describe("If true, the pull request is created as a draft (work-in-progress) and cannot be merged until marked ready."),
1074
1119
  reviewers: z.array(z.string()).optional().describe("Optional array of reviewer usernames (use the 'name' field from Bitbucket user objects, not 'slug')"),
1075
- output: z.enum(['ack', 'full']).optional().describe("Return a compact acknowledgement or the full API response. Defaults to ack.")
1120
+ output: z.enum(['ack', 'full']).optional().describe("Return a compact acknowledgement or the full API response. Defaults to ack."),
1121
+ fromProjectKey: z.string().optional().describe("The source repository's project key, when creating a fork-based pull request where the source repository differs from the destination repository (projectKey). Defaults to projectKey."),
1122
+ fromRepositorySlug: z.string().optional().describe("The source repository's slug, when creating a fork-based pull request where the source repository differs from the destination repository (repositorySlug). Defaults to repositorySlug.")
1076
1123
  },
1077
1124
  updatePullRequest: {
1078
1125
  projectKey: z.string().describe("The project key"),
@@ -1085,6 +1132,20 @@ export const bitbucketToolSchemas = {
1085
1132
  reviewers: z.array(z.string()).optional().describe("Optional array of reviewer usernames to set (use the 'name' field from Bitbucket user objects, not 'slug')"),
1086
1133
  output: z.enum(['ack', 'full']).optional().describe("Return a compact acknowledgement or the full API response. Defaults to ack.")
1087
1134
  },
1135
+ canMergePullRequest: {
1136
+ projectKey: z.string().describe("The project key"),
1137
+ repositorySlug: z.string().describe("The repository slug"),
1138
+ pullRequestId: z.string().describe("The pull request ID")
1139
+ },
1140
+ mergePullRequest: {
1141
+ projectKey: z.string().describe("The project key"),
1142
+ repositorySlug: z.string().describe("The repository slug"),
1143
+ pullRequestId: z.string().describe("The pull request ID"),
1144
+ version: z.number().describe("The current version of the pull request (required for optimistic locking). Fetch it with bitbucket_getPullRequest immediately before merging — the server rejects a stale version, which is what stops a merge of code you have not seen."),
1145
+ strategyId: z.string().optional().describe("Merge strategy id, e.g. 'no-ff', 'ff', 'ff-only', 'rebase-no-ff', 'rebase-ff-only', 'squash', 'squash-ff-only'. Omit to use the strategy configured for the repository."),
1146
+ message: z.string().optional().describe("Commit message for the merge commit. Omit to let Bitbucket generate it."),
1147
+ output: z.enum(['ack', 'full']).optional().describe("Return a compact acknowledgement or the full API response. Defaults to ack.")
1148
+ },
1088
1149
  getRequiredReviewers: {
1089
1150
  projectKey: z.string().describe("The project key"),
1090
1151
  repositorySlug: z.string().describe("The repository slug"),