@atlassian-dc-mcp/bitbucket 0.29.0 → 0.31.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 +25 -0
  2. package/README.md +56 -2
  3. package/build/__tests__/bitbucket-service.test.js +60 -2
  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 +68 -9
  22. package/build/bitbucket-service.d.ts.map +1 -1
  23. package/build/bitbucket-service.js +91 -15
  24. package/build/bitbucket-service.js.map +1 -1
  25. package/build/index.js +34 -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 +154 -2
  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 +100 -14
  47. package/src/index.ts +55 -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
@@ -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,
@@ -214,6 +215,25 @@ export class BitbucketService {
214
215
  );
215
216
  }
216
217
 
218
+ /**
219
+ * Get the raw content of a file in a repository
220
+ * @param projectKey The project key
221
+ * @param repositorySlug The repository slug
222
+ * @param path The path to the file in the repository
223
+ * @param at Optional branch, tag or commit to read the file at. Defaults to the repository default branch
224
+ * @returns Promise with the raw file content
225
+ */
226
+ async getFileContent(projectKey: string, repositorySlug: string, path: string, at?: string) {
227
+ projectKey = projectKey.toUpperCase();
228
+ repositorySlug = repositorySlug.toLowerCase();
229
+ // Leading slashes would produce a double slash in the URL and a 404 from Bitbucket.
230
+ const filePath = path.replace(/^\/+/, '');
231
+ return handleApiOperation(
232
+ () => RepositoryService.streamRaw(filePath, projectKey, repositorySlug, at),
233
+ 'Error fetching file content'
234
+ );
235
+ }
236
+
217
237
  /**
218
238
  * Get pull requests for a repository
219
239
  * @param projectKey The project key
@@ -460,18 +480,20 @@ export class BitbucketService {
460
480
  }
461
481
 
462
482
  /**
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.
483
+ * Update an existing pull request comment. Use this to edit text, change severity, change state, or
484
+ * resolve/reopen a comment thread. State and thread resolution are independent: `state` is the task
485
+ * state of a BLOCKER comment ('RESOLVED' ticks the task, 'OPEN' un-ticks it), while `threadResolved`
486
+ * toggles the thread-level "Resolve" button. A root BLOCKER comment can hold these independently, so
487
+ * they are sent as separate fields.
467
488
  * @param projectKey The project key
468
489
  * @param repositorySlug The repository slug
469
490
  * @param pullRequestId The pull request ID
470
491
  * @param commentId The comment ID to update
471
492
  * @param version The current version of the comment (required for optimistic locking)
472
493
  * @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.
494
+ * @param state Optional new task state. On a BLOCKER comment, 'RESOLVED' ticks the task and 'OPEN' un-ticks it.
474
495
  * @param severity Optional new severity. 'BLOCKER' converts a comment into a task, 'NORMAL' converts a task back to a regular comment.
496
+ * @param threadResolved Optional thread resolution. `true` resolves the comment thread, `false` reopens it.
475
497
  * @returns Promise with updated comment data
476
498
  */
477
499
  async updatePullRequestComment(
@@ -483,6 +505,7 @@ export class BitbucketService {
483
505
  text?: string,
484
506
  state?: 'OPEN' | 'RESOLVED',
485
507
  severity?: 'NORMAL' | 'BLOCKER',
508
+ threadResolved?: boolean,
486
509
  output: BitbucketMutationOutputMode = 'ack'
487
510
  ) {
488
511
  projectKey = projectKey.toUpperCase();
@@ -501,6 +524,10 @@ export class BitbucketService {
501
524
  comment.severity = severity;
502
525
  }
503
526
 
527
+ if (threadResolved !== undefined) {
528
+ comment.threadResolved = threadResolved;
529
+ }
530
+
504
531
  const result = await handleApiOperation(
505
532
  () => PullRequestsService.updateComment2(
506
533
  projectKey,
@@ -522,6 +549,36 @@ export class BitbucketService {
522
549
  return result;
523
550
  }
524
551
 
552
+ /**
553
+ * Check whether a pull request can be merged. Read-only: reports conflicts and any
554
+ * merge-check vetoes without changing anything.
555
+ * @param projectKey The project key
556
+ * @param repositorySlug The repository slug
557
+ * @param pullRequestId The pull request ID
558
+ * @returns Promise with the mergeability status
559
+ */
560
+ async getPullRequestMergeability(projectKey: string, repositorySlug: string, pullRequestId: string) {
561
+ return fetchMergeability(projectKey.toUpperCase(), repositorySlug.toLowerCase(), pullRequestId);
562
+ }
563
+
564
+ /**
565
+ * Merge a pull request. Only permitted for repositories (and target branches) the operator
566
+ * allowed through the merge gateway; see merge-gateway.ts. A conflicted or vetoed pull
567
+ * request is refused before the merge request is sent.
568
+ * @param params.version The current pull request version, required for optimistic locking
569
+ * @param params.strategyId Optional merge strategy id (e.g. 'no-ff', 'squash', 'ff')
570
+ * @param params.message Optional merge commit message
571
+ * @param params.gateway The resolved operator merge policy
572
+ * @returns Promise with the merged pull request
573
+ */
574
+ async mergePullRequest(params: MergePullRequestParams) {
575
+ return mergePullRequest({
576
+ ...params,
577
+ projectKey: params.projectKey.toUpperCase(),
578
+ repositorySlug: params.repositorySlug.toLowerCase(),
579
+ });
580
+ }
581
+
525
582
  /**
526
583
  * Get a user by slug, or search for users by name/email filter
527
584
  * @param userSlug Optional exact slug to look up a specific user
@@ -649,8 +706,8 @@ export class BitbucketService {
649
706
 
650
707
  /**
651
708
  * Create a pull request
652
- * @param projectKey The project key
653
- * @param repositorySlug The repository slug
709
+ * @param projectKey The project key (also used as the destination repository's project)
710
+ * @param repositorySlug The repository slug (also used as the destination repository's slug)
654
711
  * @param title The pull request title
655
712
  * @param description Optional pull request description
656
713
  * @param fromRefId The source branch (e.g., 'refs/heads/feature-branch')
@@ -658,6 +715,10 @@ export class BitbucketService {
658
715
  * @param reviewers Optional array of reviewer usernames
659
716
  * @param draft Optional flag to create the pull request as a draft
660
717
  * @param output Return a compact acknowledgement or the full API response. Defaults to 'ack'.
718
+ * @param fromProjectKey Optional source repository's project key, when it differs from
719
+ * projectKey (fork-based pull requests). Defaults to projectKey.
720
+ * @param fromRepositorySlug Optional source repository's slug, when it differs from
721
+ * repositorySlug (fork-based pull requests). Defaults to repositorySlug.
661
722
  * @returns Promise with created pull request data
662
723
  */
663
724
  async createPullRequest(
@@ -669,7 +730,9 @@ export class BitbucketService {
669
730
  toRefId: string,
670
731
  reviewers?: string[],
671
732
  draft?: boolean,
672
- output: BitbucketMutationOutputMode = 'ack'
733
+ output: BitbucketMutationOutputMode = 'ack',
734
+ fromProjectKey?: string,
735
+ fromRepositorySlug?: string
673
736
  ) {
674
737
  projectKey = projectKey.toUpperCase();
675
738
  repositorySlug = repositorySlug.toLowerCase();
@@ -679,9 +742,9 @@ export class BitbucketService {
679
742
  fromRef: {
680
743
  id: fromRefId,
681
744
  repository: {
682
- slug: repositorySlug,
745
+ slug: (fromRepositorySlug ?? repositorySlug).toLowerCase(),
683
746
  project: {
684
- key: projectKey
747
+ key: (fromProjectKey ?? projectKey).toUpperCase()
685
748
  }
686
749
  }
687
750
  },
@@ -984,6 +1047,12 @@ export const bitbucketToolSchemas = {
984
1047
  projectKey: z.string().describe("The project key"),
985
1048
  repositorySlug: z.string().describe("The repository slug")
986
1049
  },
1050
+ getFileContent: {
1051
+ projectKey: z.string().describe("The project key"),
1052
+ repositorySlug: z.string().describe("The repository slug"),
1053
+ path: z.string().describe("Path to the file in the repository (e.g. 'src/index.ts')"),
1054
+ at: z.string().optional().describe("A branch, tag or commit to read the file at (e.g. 'refs/heads/main', 'feature/x', or a commit id). Defaults to the repository's default branch")
1055
+ },
987
1056
  getCommits: {
988
1057
  projectKey: z.string().describe("The project key"),
989
1058
  repositorySlug: z.string().describe("The repository slug"),
@@ -1035,8 +1104,9 @@ export const bitbucketToolSchemas = {
1035
1104
  commentId: z.string().describe("The ID of the comment to update"),
1036
1105
  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
1106
  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."),
1107
+ 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 only — to resolve or reopen the comment thread itself use threadResolved."),
1039
1108
  severity: z.enum(['NORMAL', 'BLOCKER']).optional().describe("New severity. Use 'BLOCKER' to convert a comment into a task, 'NORMAL' to convert it back."),
1109
+ 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
1110
  output: z.enum(['ack', 'full']).optional().describe("Return a compact acknowledgement or the full API response. Defaults to ack.")
1041
1111
  },
1042
1112
  getUser: {
@@ -1064,15 +1134,17 @@ export const bitbucketToolSchemas = {
1064
1134
  whitespace: z.string().optional().describe("Optional whitespace flag which can be set to 'ignore-all'")
1065
1135
  },
1066
1136
  createPullRequest: {
1067
- projectKey: z.string().describe("The project key"),
1068
- repositorySlug: z.string().describe("The repository slug"),
1137
+ projectKey: z.string().describe("The destination repository's project key"),
1138
+ repositorySlug: z.string().describe("The destination repository's slug"),
1069
1139
  title: z.string().describe("The pull request title"),
1070
1140
  description: z.string().optional().describe("The pull request description"),
1071
1141
  fromRefId: z.string().describe("The source branch reference ID (e.g., 'refs/heads/feature-branch')"),
1072
1142
  toRefId: z.string().describe("The destination branch reference ID (e.g., 'refs/heads/main')"),
1073
1143
  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
1144
  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.")
1145
+ output: z.enum(['ack', 'full']).optional().describe("Return a compact acknowledgement or the full API response. Defaults to ack."),
1146
+ 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."),
1147
+ 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
1148
  },
1077
1149
  updatePullRequest: {
1078
1150
  projectKey: z.string().describe("The project key"),
@@ -1085,6 +1157,20 @@ export const bitbucketToolSchemas = {
1085
1157
  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
1158
  output: z.enum(['ack', 'full']).optional().describe("Return a compact acknowledgement or the full API response. Defaults to ack.")
1087
1159
  },
1160
+ canMergePullRequest: {
1161
+ projectKey: z.string().describe("The project key"),
1162
+ repositorySlug: z.string().describe("The repository slug"),
1163
+ pullRequestId: z.string().describe("The pull request ID")
1164
+ },
1165
+ mergePullRequest: {
1166
+ projectKey: z.string().describe("The project key"),
1167
+ repositorySlug: z.string().describe("The repository slug"),
1168
+ pullRequestId: z.string().describe("The pull request ID"),
1169
+ 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."),
1170
+ 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."),
1171
+ message: z.string().optional().describe("Commit message for the merge commit. Omit to let Bitbucket generate it."),
1172
+ output: z.enum(['ack', 'full']).optional().describe("Return a compact acknowledgement or the full API response. Defaults to ack.")
1173
+ },
1088
1174
  getRequiredReviewers: {
1089
1175
  projectKey: z.string().describe("The project key"),
1090
1176
  repositorySlug: z.string().describe("The repository slug"),
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { connectServer, createMcpServer, formatToolResponse, initializeRuntimeConfig } from '@atlassian-dc-mcp/common';
2
2
  import { BitbucketService, bitbucketToolSchemas } from './bitbucket-service.js';
3
3
  import { getBitbucketRuntimeConfig, getDefaultPageSize } from './config.js';
4
+ import { resolveMergeGateway } from './merge-gateway.js';
4
5
  import { createRequire } from 'node:module';
5
6
 
6
7
  const require = createRequire(import.meta.url);
@@ -67,6 +68,16 @@ server.tool(
67
68
  }
68
69
  );
69
70
 
71
+ server.tool(
72
+ "bitbucket_getFileContent",
73
+ "Get the raw content of a file in a Bitbucket repository, at a branch, tag or commit. Use this to read a source file without cloning the repository. 'at' defaults to the repository's default branch. Pointing 'path' at a directory returns that directory's git tree listing instead of file content.",
74
+ bitbucketToolSchemas.getFileContent,
75
+ async ({ projectKey, repositorySlug, path, at }) => {
76
+ const result = await bitbucketService.getFileContent(projectKey, repositorySlug, path, at);
77
+ return formatToolResponse(result);
78
+ }
79
+ );
80
+
70
81
  server.tool(
71
82
  "bitbucket_getCommits",
72
83
  "Get commits for a Bitbucket repository",
@@ -139,10 +150,10 @@ server.tool(
139
150
 
140
151
  server.tool(
141
152
  "bitbucket_updatePullRequestComment",
142
- "Update an existing pull request comment. Use to edit text, change severity, or change state. On a BLOCKER (task) comment, state: 'RESOLVED' ticks the task. NOTE: this is the task-tick, not the thread-level 'Resolve' button on regular comment threads — that is a separate concept (CommentThread.resolved) and is not exposed by this endpoint. Requires the current 'version' from optimistic locking; fetch it via bitbucket_getPR_CommentsAndAction or use the version returned when the comment was created.",
153
+ "Update an existing pull request comment. Use to edit text, change severity, change task state, or resolve/reopen the comment thread. On a BLOCKER (task) comment, state: 'RESOLVED' ticks the task; threadResolved: true toggles the thread-level 'Resolve' button. These are independent and can be set separately. Requires the current 'version' from optimistic locking; fetch it via bitbucket_getPR_CommentsAndAction or use the version returned when the comment was created.",
143
154
  bitbucketToolSchemas.updatePullRequestComment,
144
- async ({ projectKey, repositorySlug, pullRequestId, commentId, version, text, state, severity, output }) => {
145
- const result = await bitbucketService.updatePullRequestComment(projectKey, repositorySlug, pullRequestId, commentId, version, text, state, severity, output);
155
+ async ({ projectKey, repositorySlug, pullRequestId, commentId, version, text, state, severity, threadResolved, output }) => {
156
+ const result = await bitbucketService.updatePullRequestComment(projectKey, repositorySlug, pullRequestId, commentId, version, text, state, severity, threadResolved, output);
146
157
  return formatToolResponse(result);
147
158
  }
148
159
  );
@@ -170,10 +181,10 @@ server.tool(
170
181
 
171
182
  server.tool(
172
183
  "bitbucket_createPullRequest",
173
- "Create a new pull request in a Bitbucket repository. IMPORTANT: Before creating a PR, use bitbucket_getRequiredReviewers to fetch required reviewers for the source and target branches to ensure the PR is not created without mandatory reviewers.",
184
+ "Create a new pull request in a Bitbucket repository. Supports fork-based pull requests by passing fromProjectKey/fromRepositorySlug when the source repository differs from the destination (projectKey/repositorySlug). IMPORTANT: Before creating a PR, use bitbucket_getRequiredReviewers to fetch required reviewers for the source and target branches to ensure the PR is not created without mandatory reviewers.",
174
185
  bitbucketToolSchemas.createPullRequest,
175
- async ({ projectKey, repositorySlug, title, description, fromRefId, toRefId, reviewers, draft, output }) => {
176
- const result = await bitbucketService.createPullRequest(projectKey, repositorySlug, title, description, fromRefId, toRefId, reviewers, draft, output);
186
+ async ({ projectKey, repositorySlug, title, description, fromRefId, toRefId, reviewers, draft, output, fromProjectKey, fromRepositorySlug }) => {
187
+ const result = await bitbucketService.createPullRequest(projectKey, repositorySlug, title, description, fromRefId, toRefId, reviewers, draft, output, fromProjectKey, fromRepositorySlug);
177
188
  return formatToolResponse(result);
178
189
  }
179
190
  );
@@ -188,6 +199,44 @@ server.tool(
188
199
  }
189
200
  );
190
201
 
202
+ server.tool(
203
+ "bitbucket_canMergePullRequest",
204
+ "Check whether a pull request can be merged. Read-only: returns canMerge, whether the pull request is conflicted, and any merge-check vetoes (e.g. missing approvals, unresolved tasks, failed builds). Use this before bitbucket_mergePullRequest to see what is blocking a merge.",
205
+ bitbucketToolSchemas.canMergePullRequest,
206
+ async ({ projectKey, repositorySlug, pullRequestId }) => {
207
+ const result = await bitbucketService.getPullRequestMergeability(projectKey, repositorySlug, pullRequestId);
208
+ return formatToolResponse(result);
209
+ }
210
+ );
211
+
212
+ const mergeGateway = resolveMergeGateway();
213
+
214
+ // Merging lands code on a shared branch and cannot be undone through the API, so the tool is
215
+ // only registered when the operator has explicitly enabled it for specific repositories.
216
+ if (mergeGateway.enabled) {
217
+ const scope = `Allowed on this server: ${mergeGateway.repos.join(', ')}`
218
+ + (mergeGateway.targetRefs.length > 0 ? `; target branches: ${mergeGateway.targetRefs.join(', ')}` : '')
219
+ + '.';
220
+ server.tool(
221
+ "bitbucket_mergePullRequest",
222
+ `Merge a pull request. IMPORTANT: you MUST first call bitbucket_getPullRequest to get the current 'version' — it is required for optimistic locking and a stale version is rejected. The merge is refused if the pull request is conflicted or a merge check vetoes it; use bitbucket_canMergePullRequest to inspect blockers first. This operation is not reversible through the API. ${scope}`,
223
+ bitbucketToolSchemas.mergePullRequest,
224
+ async ({ projectKey, repositorySlug, pullRequestId, version, strategyId, message, output }) => {
225
+ const result = await bitbucketService.mergePullRequest({
226
+ projectKey,
227
+ repositorySlug,
228
+ pullRequestId,
229
+ version,
230
+ strategyId,
231
+ message,
232
+ output,
233
+ gateway: mergeGateway,
234
+ });
235
+ return formatToolResponse(result);
236
+ }
237
+ );
238
+ }
239
+
191
240
  server.tool(
192
241
  "bitbucket_getRequiredReviewers",
193
242
  "Get required reviewers for pull request creation. Returns a set of users who are required reviewers for pull requests created from the given source repository and ref to the given target ref in this repository.",