@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
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);
@@ -139,10 +140,10 @@ server.tool(
139
140
 
140
141
  server.tool(
141
142
  "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.",
143
+ "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
144
  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);
145
+ async ({ projectKey, repositorySlug, pullRequestId, commentId, version, text, state, severity, threadResolved, output }) => {
146
+ const result = await bitbucketService.updatePullRequestComment(projectKey, repositorySlug, pullRequestId, commentId, version, text, state, severity, threadResolved, output);
146
147
  return formatToolResponse(result);
147
148
  }
148
149
  );
@@ -170,10 +171,10 @@ server.tool(
170
171
 
171
172
  server.tool(
172
173
  "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.",
174
+ "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
175
  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);
176
+ async ({ projectKey, repositorySlug, title, description, fromRefId, toRefId, reviewers, draft, output, fromProjectKey, fromRepositorySlug }) => {
177
+ const result = await bitbucketService.createPullRequest(projectKey, repositorySlug, title, description, fromRefId, toRefId, reviewers, draft, output, fromProjectKey, fromRepositorySlug);
177
178
  return formatToolResponse(result);
178
179
  }
179
180
  );
@@ -188,6 +189,44 @@ server.tool(
188
189
  }
189
190
  );
190
191
 
192
+ server.tool(
193
+ "bitbucket_canMergePullRequest",
194
+ "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.",
195
+ bitbucketToolSchemas.canMergePullRequest,
196
+ async ({ projectKey, repositorySlug, pullRequestId }) => {
197
+ const result = await bitbucketService.getPullRequestMergeability(projectKey, repositorySlug, pullRequestId);
198
+ return formatToolResponse(result);
199
+ }
200
+ );
201
+
202
+ const mergeGateway = resolveMergeGateway();
203
+
204
+ // Merging lands code on a shared branch and cannot be undone through the API, so the tool is
205
+ // only registered when the operator has explicitly enabled it for specific repositories.
206
+ if (mergeGateway.enabled) {
207
+ const scope = `Allowed on this server: ${mergeGateway.repos.join(', ')}`
208
+ + (mergeGateway.targetRefs.length > 0 ? `; target branches: ${mergeGateway.targetRefs.join(', ')}` : '')
209
+ + '.';
210
+ server.tool(
211
+ "bitbucket_mergePullRequest",
212
+ `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}`,
213
+ bitbucketToolSchemas.mergePullRequest,
214
+ async ({ projectKey, repositorySlug, pullRequestId, version, strategyId, message, output }) => {
215
+ const result = await bitbucketService.mergePullRequest({
216
+ projectKey,
217
+ repositorySlug,
218
+ pullRequestId,
219
+ version,
220
+ strategyId,
221
+ message,
222
+ output,
223
+ gateway: mergeGateway,
224
+ });
225
+ return formatToolResponse(result);
226
+ }
227
+ );
228
+ }
229
+
191
230
  server.tool(
192
231
  "bitbucket_getRequiredReviewers",
193
232
  "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.",
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Operator-controlled gate for merging pull requests.
3
+ *
4
+ * A merge writes to a shared branch and cannot be undone through the API, so it is
5
+ * disabled by default: `bitbucket_mergePullRequest` is not even registered unless the
6
+ * operator enables it and names the repositories it may merge in. The allowed scope is
7
+ * read from the environment once at startup, so the model can never select or widen it.
8
+ * Read-only mergeability checks are not gated.
9
+ */
10
+ export interface MergeGateway {
11
+ /** Whether merging is enabled and at least one valid repository pattern resolved. */
12
+ enabled: boolean;
13
+ /** Allowed `PROJECT/repository-slug` targets; a `PROJECT/*` entry allows the whole project. */
14
+ repos: string[];
15
+ /** Allowed target refs; a trailing `*` matches a prefix. Empty means any ref in an allowed repository. */
16
+ targetRefs: string[];
17
+ }
18
+
19
+ type Env = Record<string, string | undefined>;
20
+ type Warn = (message: string) => void;
21
+
22
+ const DISABLED: MergeGateway = { enabled: false, repos: [], targetRefs: [] };
23
+
24
+ const REPO_ENTRY_RE = /^[^\s/]+\/[^\s/]+$/;
25
+
26
+ function readBool(env: Env, name: string): boolean {
27
+ const value = env[name]?.trim().toLowerCase();
28
+ return value === 'true' || value === '1' || value === 'yes';
29
+ }
30
+
31
+ function parseList(raw: string | undefined): string[] {
32
+ return (raw ?? '').split(/[,;\s]+/).map(entry => entry.trim()).filter(Boolean);
33
+ }
34
+
35
+ function dedupe(values: string[]): string[] {
36
+ return [...new Set(values)];
37
+ }
38
+
39
+ /**
40
+ * Accepts `PROJECT/repository-slug` or `PROJECT/*`. The project key is upper-cased and
41
+ * the slug lower-cased to match the casing the REST API uses, so comparisons are exact.
42
+ */
43
+ function normalizeRepoEntry(entry: string, warn: Warn): string | undefined {
44
+ if (!REPO_ENTRY_RE.test(entry)) {
45
+ warn(`Ignoring merge repository entry that is not "PROJECT/repository-slug" or "PROJECT/*": "${entry}"`);
46
+ return undefined;
47
+ }
48
+ const [projectKey, slug] = entry.split('/');
49
+ if (projectKey === '*') {
50
+ warn(`Ignoring merge repository entry that would allow every project: "${entry}"`);
51
+ return undefined;
52
+ }
53
+ return `${projectKey.toUpperCase()}/${slug.toLowerCase()}`;
54
+ }
55
+
56
+ /** Bare branch names are expanded so operators can write `develop` instead of `refs/heads/develop`. */
57
+ function normalizeRefEntry(entry: string): string {
58
+ return entry.startsWith('refs/') ? entry : `refs/heads/${entry}`;
59
+ }
60
+
61
+ function matchesPattern(value: string, pattern: string): boolean {
62
+ return pattern.endsWith('*') ? value.startsWith(pattern.slice(0, -1)) : value === pattern;
63
+ }
64
+
65
+ /**
66
+ * Reads the merge gateway configuration from the environment. Merging only activates
67
+ * when the flag is set and at least one repository entry is valid; otherwise a warning
68
+ * is logged and the gateway stays disabled.
69
+ */
70
+ export function resolveMergeGateway(options?: { env?: Env; warn?: Warn }): MergeGateway {
71
+ const env = options?.env ?? process.env;
72
+ const warn = options?.warn ?? ((message: string) => console.error(`[merge-gateway] ${message}`));
73
+
74
+ if (!readBool(env, 'BITBUCKET_MERGE_ENABLED')) {
75
+ return DISABLED;
76
+ }
77
+
78
+ const repos = dedupe(
79
+ parseList(env.BITBUCKET_MERGE_ALLOWED_REPOS)
80
+ .map(entry => normalizeRepoEntry(entry, warn))
81
+ .filter((entry): entry is string => Boolean(entry)),
82
+ );
83
+
84
+ if (repos.length === 0) {
85
+ warn(
86
+ 'Merging was enabled but no valid repository is configured (set BITBUCKET_MERGE_ALLOWED_REPOS ' +
87
+ 'to a list of "PROJECT/repository-slug" or "PROJECT/*" entries); the merge tool will stay disabled.',
88
+ );
89
+ return DISABLED;
90
+ }
91
+
92
+ return {
93
+ enabled: true,
94
+ repos,
95
+ targetRefs: dedupe(parseList(env.BITBUCKET_MERGE_ALLOWED_TARGET_REFS).map(normalizeRefEntry)),
96
+ };
97
+ }
98
+
99
+ /** Throws unless the gateway allows merging in this repository. No network call. */
100
+ export function assertRepoMergeAllowed(gateway: MergeGateway, projectKey: string, repositorySlug: string): void {
101
+ if (!gateway.enabled) {
102
+ throw new Error(
103
+ 'Merging pull requests is disabled on this server. Enable it with BITBUCKET_MERGE_ENABLED ' +
104
+ 'and list the allowed repositories in BITBUCKET_MERGE_ALLOWED_REPOS.',
105
+ );
106
+ }
107
+ const target = `${projectKey.toUpperCase()}/${repositorySlug.toLowerCase()}`;
108
+ if (!gateway.repos.some(pattern => matchesPattern(target, pattern))) {
109
+ throw new Error(
110
+ `Merging is not allowed in ${target} on this server. Allowed: ${gateway.repos.join(', ')}.`,
111
+ );
112
+ }
113
+ }
114
+
115
+ /** Throws unless the gateway allows merging into this target ref. A gateway with no ref restriction allows all. */
116
+ export function assertTargetRefMergeAllowed(gateway: MergeGateway, targetRefId: string | undefined): void {
117
+ if (gateway.targetRefs.length === 0) {
118
+ return;
119
+ }
120
+ if (!targetRefId) {
121
+ throw new Error(
122
+ 'Could not determine the target branch of the pull request, and this server restricts which ' +
123
+ 'branches may be merged into (BITBUCKET_MERGE_ALLOWED_TARGET_REFS); refusing to merge.',
124
+ );
125
+ }
126
+ if (!gateway.targetRefs.some(pattern => matchesPattern(targetRefId, pattern))) {
127
+ throw new Error(
128
+ `Merging into ${targetRefId} is not allowed on this server. ` +
129
+ `Allowed target refs: ${gateway.targetRefs.join(', ')}.`,
130
+ );
131
+ }
132
+ }
@@ -148,6 +148,7 @@ interface SimplifiedComment {
148
148
  comments: SimplifiedComment[];
149
149
  threadResolved: boolean;
150
150
  state: string;
151
+ severity: string;
151
152
  }
152
153
 
153
154
  interface SimplifiedActivity {
@@ -167,6 +168,8 @@ export interface SimplifiedPRResponse {
167
168
  prAuthor?: SimplifiedUser;
168
169
  commentCount: number;
169
170
  unresolvedCount: number;
171
+ blockerCount: number;
172
+ unresolvedBlockerCount: number;
170
173
  };
171
174
  }
172
175
 
@@ -271,7 +274,8 @@ function simplifyComment(comment: Comment, ancestorIds: Set<number> = new Set())
271
274
  .filter(childComment => !nextAncestorIds.has(childComment.id))
272
275
  .map(childComment => simplifyComment(childComment, nextAncestorIds)),
273
276
  threadResolved: comment.threadResolved,
274
- state: comment.state
277
+ state: comment.state,
278
+ severity: comment.severity
275
279
  };
276
280
  }
277
281
 
@@ -361,9 +365,11 @@ export function simplifyBitbucketPRComments(
361
365
  // Find PR author (usually the one who OPENED the PR)
362
366
  const prAuthor = activities.find(a => a.action === 'OPENED')?.user;
363
367
 
364
- // Count comments and unresolved threads
368
+ // Count comments, unresolved threads and blocker tasks
365
369
  const comments = activities.filter(a => a.action === 'COMMENTED' && a.comment);
366
370
  const unresolvedCount = comments.filter(a => a.comment && !a.comment.threadResolved).length;
371
+ const blockers = comments.filter(a => a.comment && a.comment.severity === 'BLOCKER');
372
+ const unresolvedBlockerCount = blockers.filter(a => a.comment && a.comment.state !== 'RESOLVED').length;
367
373
 
368
374
  return {
369
375
  isLastPage: filteredResponse.isLastPage ?? true,
@@ -372,7 +378,9 @@ export function simplifyBitbucketPRComments(
372
378
  totalActivities: activities.length,
373
379
  ...(prAuthor && { prAuthor }),
374
380
  commentCount: comments.length,
375
- unresolvedCount
381
+ unresolvedCount,
382
+ blockerCount: blockers.length,
383
+ unresolvedBlockerCount
376
384
  }
377
385
  };
378
386
  }
@@ -0,0 +1,94 @@
1
+ import { handleApiOperation } from '@atlassian-dc-mcp/common';
2
+ import { PullRequestsService } from './bitbucket-client/index.js';
3
+ import {
4
+ BitbucketMutationOutputMode,
5
+ shapeMergeability,
6
+ shapePullRequestAck,
7
+ } from './bitbucket-response-mapper.js';
8
+ import { assertRepoMergeAllowed, assertTargetRefMergeAllowed, type MergeGateway } from './merge-gateway.js';
9
+
10
+ export interface MergePullRequestParams {
11
+ projectKey: string;
12
+ repositorySlug: string;
13
+ pullRequestId: string;
14
+ /** Current PR version, required for optimistic locking. */
15
+ version: number;
16
+ gateway: MergeGateway;
17
+ strategyId?: string;
18
+ message?: string;
19
+ output?: BitbucketMutationOutputMode;
20
+ }
21
+
22
+ /** Read-only mergeability check: reports conflicts and merge-check vetoes. */
23
+ export async function fetchMergeability(projectKey: string, repositorySlug: string, pullRequestId: string) {
24
+ const result = await handleApiOperation(
25
+ () => PullRequestsService.canMerge(projectKey, pullRequestId, repositorySlug),
26
+ 'Error checking pull request mergeability',
27
+ );
28
+
29
+ if (result.success && result.data) {
30
+ return { ...result, data: shapeMergeability(result.data) };
31
+ }
32
+
33
+ return result;
34
+ }
35
+
36
+ function describeVetoes(vetoes: Array<{ summary?: string; detail?: string }>): string {
37
+ return vetoes
38
+ .map(veto => [veto.summary, veto.detail].filter(Boolean).join(' — '))
39
+ .filter(Boolean)
40
+ .join('; ');
41
+ }
42
+
43
+ async function assertMergeable(projectKey: string, repositorySlug: string, pullRequestId: string): Promise<void> {
44
+ const mergeability = shapeMergeability(
45
+ await PullRequestsService.canMerge(projectKey, pullRequestId, repositorySlug),
46
+ );
47
+ if (mergeability.canMerge) {
48
+ return;
49
+ }
50
+ const cause = mergeability.conflicted ? 'it has conflicts' : 'a merge check vetoed the merge';
51
+ const reasons = describeVetoes(mergeability.vetoes);
52
+ throw new Error(`Pull request cannot be merged: ${cause}${reasons ? `. ${reasons}` : ''}`);
53
+ }
54
+
55
+ /** Only fetches the pull request when the operator restricts target branches. */
56
+ async function assertTargetRefAllowed(params: MergePullRequestParams): Promise<void> {
57
+ if (params.gateway.targetRefs.length === 0) {
58
+ return;
59
+ }
60
+ const pullRequest: any = await PullRequestsService.get3(
61
+ params.projectKey,
62
+ params.pullRequestId,
63
+ params.repositorySlug,
64
+ );
65
+ assertTargetRefMergeAllowed(params.gateway, pullRequest?.toRef?.id);
66
+ }
67
+
68
+ /**
69
+ * Merge a pull request under the operator's merge policy. Order matters: the repository
70
+ * (and target branch, when restricted) is checked before any request, and the mergeability
71
+ * check runs before the POST so a conflicted or vetoed pull request is refused without
72
+ * issuing the write.
73
+ */
74
+ export async function mergePullRequest(params: MergePullRequestParams) {
75
+ const { projectKey, repositorySlug, pullRequestId, version, gateway } = params;
76
+
77
+ const result = await handleApiOperation(async () => {
78
+ assertRepoMergeAllowed(gateway, projectKey, repositorySlug);
79
+ await assertTargetRefAllowed(params);
80
+ await assertMergeable(projectKey, repositorySlug, pullRequestId);
81
+
82
+ return PullRequestsService.merge(projectKey, pullRequestId, repositorySlug, String(version), {
83
+ version,
84
+ ...(params.strategyId ? { strategyId: params.strategyId } : {}),
85
+ ...(params.message ? { message: params.message } : {}),
86
+ });
87
+ }, 'Error merging pull request');
88
+
89
+ if (result.success && result.data && params.output !== 'full') {
90
+ return { ...result, data: shapePullRequestAck(result.data) };
91
+ }
92
+
93
+ return result;
94
+ }