@atlassian-dc-mcp/bitbucket 0.24.0 → 0.25.1

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.
@@ -16,6 +16,7 @@ jest.mock('../bitbucket-client/index.js', () => ({
16
16
  PullRequestsService: {
17
17
  streamRawDiff2: jest.fn(),
18
18
  createComment2: jest.fn(),
19
+ updateComment2: jest.fn(),
19
20
  streamChanges1: jest.fn(),
20
21
  create: jest.fn(),
21
22
  update: jest.fn(),
@@ -860,10 +861,8 @@ describe('BitbucketService', () => {
860
861
  line: 15,
861
862
  lineType: 'ADDED',
862
863
  fileType: 'TO',
863
- multilineAnchor: true,
864
- multilineStartLine: 10,
865
- multilineStartLineType: 'ADDED',
866
- multilineDestinationRange: { minimum: 10, maximum: 15 }
864
+ multilineMarker: { startLine: 10, startLineType: 'ADDED' },
865
+ multilineSpan: { dstSpanStart: 10, dstSpanEnd: 15 }
867
866
  }
868
867
  };
869
868
  (PullRequestsService.createComment2 as jest.Mock).mockResolvedValue(mockComment);
@@ -905,10 +904,8 @@ describe('BitbucketService', () => {
905
904
  line: 15,
906
905
  lineType: 'ADDED',
907
906
  fileType: 'TO',
908
- multilineAnchor: true,
909
- multilineStartLine: 10,
910
- multilineStartLineType: 'ADDED',
911
- multilineDestinationRange: { minimum: 10, maximum: 15 }
907
+ multilineMarker: { startLine: 10, startLineType: 'ADDED' },
908
+ multilineSpan: { dstSpanStart: 10, dstSpanEnd: 15 }
912
909
  }
913
910
  }
914
911
  );
@@ -925,10 +922,8 @@ describe('BitbucketService', () => {
925
922
  line: 8,
926
923
  lineType: 'ADDED',
927
924
  fileType: 'TO',
928
- multilineAnchor: true,
929
- multilineStartLine: 5,
930
- multilineStartLineType: 'CONTEXT',
931
- multilineDestinationRange: { minimum: 5, maximum: 8 }
925
+ multilineMarker: { startLine: 5, startLineType: 'CONTEXT' },
926
+ multilineSpan: { dstSpanStart: 5, dstSpanEnd: 8 }
932
927
  }
933
928
  };
934
929
  (PullRequestsService.createComment2 as jest.Mock).mockResolvedValue(mockComment);
@@ -958,15 +953,133 @@ describe('BitbucketService', () => {
958
953
  line: 8,
959
954
  lineType: 'ADDED',
960
955
  fileType: 'TO',
961
- multilineAnchor: true,
962
- multilineStartLine: 5,
963
- multilineStartLineType: 'CONTEXT',
964
- multilineDestinationRange: { minimum: 5, maximum: 8 }
956
+ multilineMarker: { startLine: 5, startLineType: 'CONTEXT' },
957
+ multilineSpan: { dstSpanStart: 5, dstSpanEnd: 8 }
965
958
  }
966
959
  }
967
960
  );
968
961
  });
969
962
 
963
+ it('should normalize a reversed multiline range (startLine > line)', async () => {
964
+ (PullRequestsService.createComment2 as jest.Mock).mockResolvedValue({ id: 1, anchor: { path: 'src/test.js' } });
965
+
966
+ await bitbucketService.postPullRequestComment(
967
+ mockProjectKey,
968
+ mockRepositorySlug,
969
+ mockPullRequestId,
970
+ 'Reversed range',
971
+ undefined, // parentId
972
+ 'src/test.js', // filePath
973
+ 15, // startLine (greater than line)
974
+ 'ADDED', // startLineType
975
+ 10, // line (end line, smaller)
976
+ 'ADDED' // lineType
977
+ );
978
+
979
+ expect(PullRequestsService.createComment2).toHaveBeenCalledWith(
980
+ mockProjectKey,
981
+ mockPullRequestId,
982
+ mockRepositorySlug,
983
+ {
984
+ text: 'Reversed range',
985
+ anchor: {
986
+ path: 'src/test.js',
987
+ diffType: 'EFFECTIVE',
988
+ line: 15,
989
+ lineType: 'ADDED',
990
+ fileType: 'TO',
991
+ multilineMarker: { startLine: 10, startLineType: 'ADDED' },
992
+ multilineSpan: { dstSpanStart: 10, dstSpanEnd: 15 }
993
+ }
994
+ }
995
+ );
996
+ });
997
+
998
+ it('should anchor a REMOVED multiline range to the source file', async () => {
999
+ (PullRequestsService.createComment2 as jest.Mock).mockResolvedValue({ id: 1, anchor: { path: 'src/test.js' } });
1000
+
1001
+ await bitbucketService.postPullRequestComment(
1002
+ mockProjectKey,
1003
+ mockRepositorySlug,
1004
+ mockPullRequestId,
1005
+ 'Removed range',
1006
+ undefined, // parentId
1007
+ 'src/test.js', // filePath
1008
+ 2, // startLine
1009
+ 'REMOVED', // startLineType
1010
+ 5, // line (end line)
1011
+ 'REMOVED' // lineType
1012
+ );
1013
+
1014
+ expect(PullRequestsService.createComment2).toHaveBeenCalledWith(
1015
+ mockProjectKey,
1016
+ mockPullRequestId,
1017
+ mockRepositorySlug,
1018
+ {
1019
+ text: 'Removed range',
1020
+ anchor: {
1021
+ path: 'src/test.js',
1022
+ diffType: 'EFFECTIVE',
1023
+ line: 5,
1024
+ lineType: 'REMOVED',
1025
+ fileType: 'FROM',
1026
+ multilineMarker: { startLine: 2, startLineType: 'REMOVED' },
1027
+ multilineSpan: { srcSpanStart: 2, srcSpanEnd: 5 }
1028
+ }
1029
+ }
1030
+ );
1031
+ });
1032
+
1033
+ it('should warn when a multi-line suggestion is anchored to a single line', async () => {
1034
+ (PullRequestsService.createComment2 as jest.Mock).mockResolvedValue({
1035
+ id: 99,
1036
+ anchor: { path: 'src/test.js', line: 5, lineType: 'ADDED' }
1037
+ });
1038
+
1039
+ const result = await bitbucketService.postPullRequestComment(
1040
+ mockProjectKey,
1041
+ mockRepositorySlug,
1042
+ mockPullRequestId,
1043
+ '```suggestion\nconst a = 1;\nconst b = 2;\n```',
1044
+ undefined, // parentId
1045
+ 'src/test.js', // filePath
1046
+ undefined, // startLine (single-line anchor — the bug shape)
1047
+ undefined, // startLineType
1048
+ 5, // line
1049
+ 'ADDED' // lineType
1050
+ );
1051
+
1052
+ expect(result.success).toBe(true);
1053
+ expect((result.data as any).warning).toMatch(/multi-line ```suggestion/);
1054
+ // single-line anchor: no multiline fields sent
1055
+ const sentAnchor = (PullRequestsService.createComment2 as jest.Mock).mock.calls[0][3].anchor;
1056
+ expect(sentAnchor.multilineMarker).toBeUndefined();
1057
+ expect(sentAnchor.multilineSpan).toBeUndefined();
1058
+ });
1059
+
1060
+ it('should not warn when a multi-line suggestion has a proper multiline range', async () => {
1061
+ (PullRequestsService.createComment2 as jest.Mock).mockResolvedValue({
1062
+ id: 100,
1063
+ anchor: { path: 'src/test.js', line: 5, lineType: 'ADDED', multilineMarker: { startLine: 4, startLineType: 'ADDED' } }
1064
+ });
1065
+
1066
+ const result = await bitbucketService.postPullRequestComment(
1067
+ mockProjectKey,
1068
+ mockRepositorySlug,
1069
+ mockPullRequestId,
1070
+ '```suggestion\nconst a = 1;\nconst b = 2;\n```',
1071
+ undefined, // parentId
1072
+ 'src/test.js', // filePath
1073
+ 4, // startLine
1074
+ 'ADDED', // startLineType
1075
+ 5, // line
1076
+ 'ADDED' // lineType
1077
+ );
1078
+
1079
+ expect(result.success).toBe(true);
1080
+ expect((result.data as any).warning).toBeUndefined();
1081
+ });
1082
+
970
1083
  it('should handle API errors gracefully', async () => {
971
1084
  const mockError = new Error('API Error');
972
1085
  (PullRequestsService.createComment2 as jest.Mock).mockRejectedValue(mockError);
@@ -2002,6 +2115,181 @@ describe('BitbucketService', () => {
2002
2115
  });
2003
2116
  });
2004
2117
 
2118
+ describe('updatePullRequestComment', () => {
2119
+ it('should resolve a task by sending state RESOLVED with the version', async () => {
2120
+ const mockComment = { id: 500, version: 2, state: 'RESOLVED', text: 'Task body' };
2121
+ (PullRequestsService.updateComment2 as jest.Mock).mockResolvedValue(mockComment);
2122
+
2123
+ const result = await bitbucketService.updatePullRequestComment(
2124
+ mockProjectKey,
2125
+ mockRepositorySlug,
2126
+ mockPullRequestId,
2127
+ '500', // commentId
2128
+ 1, // version
2129
+ undefined, // text
2130
+ 'RESOLVED' // state
2131
+ );
2132
+
2133
+ expect(result.success).toBe(true);
2134
+ expect(PullRequestsService.updateComment2).toHaveBeenCalledWith(
2135
+ mockProjectKey,
2136
+ '500',
2137
+ mockPullRequestId,
2138
+ mockRepositorySlug,
2139
+ { version: 1, state: 'RESOLVED' }
2140
+ );
2141
+ });
2142
+
2143
+ it('should edit the comment text without changing state or severity', async () => {
2144
+ const mockComment = { id: 501, version: 3, text: 'Edited text' };
2145
+ (PullRequestsService.updateComment2 as jest.Mock).mockResolvedValue(mockComment);
2146
+
2147
+ await bitbucketService.updatePullRequestComment(
2148
+ mockProjectKey,
2149
+ mockRepositorySlug,
2150
+ mockPullRequestId,
2151
+ '501',
2152
+ 2,
2153
+ 'Edited text'
2154
+ );
2155
+
2156
+ expect(PullRequestsService.updateComment2).toHaveBeenCalledWith(
2157
+ mockProjectKey,
2158
+ '501',
2159
+ mockPullRequestId,
2160
+ mockRepositorySlug,
2161
+ { version: 2, text: 'Edited text' }
2162
+ );
2163
+ });
2164
+
2165
+ it('should support combining text, state, and severity in a single update', async () => {
2166
+ const mockComment = { id: 502, version: 4, text: 'New body', state: 'RESOLVED', severity: 'BLOCKER' };
2167
+ (PullRequestsService.updateComment2 as jest.Mock).mockResolvedValue(mockComment);
2168
+
2169
+ await bitbucketService.updatePullRequestComment(
2170
+ mockProjectKey,
2171
+ mockRepositorySlug,
2172
+ mockPullRequestId,
2173
+ '502',
2174
+ 3,
2175
+ 'New body',
2176
+ 'RESOLVED',
2177
+ 'BLOCKER'
2178
+ );
2179
+
2180
+ expect(PullRequestsService.updateComment2).toHaveBeenCalledWith(
2181
+ mockProjectKey,
2182
+ '502',
2183
+ mockPullRequestId,
2184
+ mockRepositorySlug,
2185
+ { version: 3, text: 'New body', state: 'RESOLVED', severity: 'BLOCKER' }
2186
+ );
2187
+ });
2188
+
2189
+ it('should propagate API errors', async () => {
2190
+ (PullRequestsService.updateComment2 as jest.Mock).mockRejectedValue(new Error('Conflict'));
2191
+
2192
+ const result = await bitbucketService.updatePullRequestComment(
2193
+ mockProjectKey,
2194
+ mockRepositorySlug,
2195
+ mockPullRequestId,
2196
+ '503',
2197
+ 1,
2198
+ undefined,
2199
+ 'RESOLVED'
2200
+ );
2201
+
2202
+ expect(result.success).toBe(false);
2203
+ expect(result.error).toBe('Conflict');
2204
+ });
2205
+ });
2206
+
2207
+ describe('postPullRequestComment - severity flag', () => {
2208
+ it('should include severity: BLOCKER in the request body when severity is BLOCKER', async () => {
2209
+ const mockComment = { id: 200, text: 'Task comment', author: { displayName: 'Test User' } };
2210
+ (PullRequestsService.createComment2 as jest.Mock).mockResolvedValue(mockComment);
2211
+
2212
+ const result = await bitbucketService.postPullRequestComment(
2213
+ mockProjectKey,
2214
+ mockRepositorySlug,
2215
+ mockPullRequestId,
2216
+ 'Task comment',
2217
+ undefined, // parentId
2218
+ undefined, // filePath
2219
+ undefined, // startLine
2220
+ undefined, // startLineType
2221
+ undefined, // line
2222
+ undefined, // lineType
2223
+ undefined, // pending
2224
+ 'BLOCKER' // severity
2225
+ );
2226
+
2227
+ expect(result.success).toBe(true);
2228
+ expect(PullRequestsService.createComment2).toHaveBeenCalledWith(
2229
+ mockProjectKey,
2230
+ mockPullRequestId,
2231
+ mockRepositorySlug,
2232
+ { text: 'Task comment', severity: 'BLOCKER' }
2233
+ );
2234
+ });
2235
+
2236
+ it('should NOT include severity in the request body when severity is omitted', async () => {
2237
+ const mockComment = { id: 201, text: 'Normal comment', author: { displayName: 'Test User' } };
2238
+ (PullRequestsService.createComment2 as jest.Mock).mockResolvedValue(mockComment);
2239
+
2240
+ await bitbucketService.postPullRequestComment(
2241
+ mockProjectKey,
2242
+ mockRepositorySlug,
2243
+ mockPullRequestId,
2244
+ 'Normal comment'
2245
+ );
2246
+
2247
+ expect(PullRequestsService.createComment2).toHaveBeenCalledWith(
2248
+ mockProjectKey,
2249
+ mockPullRequestId,
2250
+ mockRepositorySlug,
2251
+ { text: 'Normal comment' } // no severity field
2252
+ );
2253
+ });
2254
+
2255
+ it('should support severity BLOCKER combined with a file/line anchor', async () => {
2256
+ const mockComment = { id: 202, text: 'Inline task', author: { displayName: 'Test User' } };
2257
+ (PullRequestsService.createComment2 as jest.Mock).mockResolvedValue(mockComment);
2258
+
2259
+ await bitbucketService.postPullRequestComment(
2260
+ mockProjectKey,
2261
+ mockRepositorySlug,
2262
+ mockPullRequestId,
2263
+ 'Inline task',
2264
+ undefined, // parentId
2265
+ 'src/index.ts', // filePath
2266
+ undefined, // startLine
2267
+ undefined, // startLineType
2268
+ 10, // line
2269
+ 'ADDED', // lineType
2270
+ undefined, // pending
2271
+ 'BLOCKER' // severity
2272
+ );
2273
+
2274
+ expect(PullRequestsService.createComment2).toHaveBeenCalledWith(
2275
+ mockProjectKey,
2276
+ mockPullRequestId,
2277
+ mockRepositorySlug,
2278
+ {
2279
+ text: 'Inline task',
2280
+ severity: 'BLOCKER',
2281
+ anchor: {
2282
+ path: 'src/index.ts',
2283
+ diffType: 'EFFECTIVE',
2284
+ line: 10,
2285
+ lineType: 'ADDED',
2286
+ fileType: 'TO'
2287
+ }
2288
+ }
2289
+ );
2290
+ });
2291
+ });
2292
+
2005
2293
  describe('postPullRequestComment - pending flag', () => {
2006
2294
  it('should include state: PENDING in the request body when pending is true', async () => {
2007
2295
  const mockComment = { id: 99, text: 'Draft comment', author: { displayName: 'Test User' } };
@@ -390,7 +390,7 @@ describe('BitbucketService token optimization paths', () => {
390
390
  };
391
391
  (PullRequestsService.createComment2 as jest.Mock).mockResolvedValue(mockComment);
392
392
 
393
- const result = await service.postPullRequestComment('TEST', 'repo', '123', 'Raw comment', undefined, undefined, undefined, undefined, undefined, undefined, undefined, 'full');
393
+ const result = await service.postPullRequestComment('TEST', 'repo', '123', 'Raw comment', undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, 'full');
394
394
 
395
395
  expect(result.success).toBe(true);
396
396
  expect(result.data).toBe(mockComment);
@@ -113,10 +113,10 @@ export function shapePullRequestCommentAck(comment: any): Record<string, any> {
113
113
  path: comment.anchor.path,
114
114
  ...(comment.anchor.line !== undefined ? { line: comment.anchor.line } : {}),
115
115
  ...(typeof comment.anchor.lineType === 'string' ? { lineType: comment.anchor.lineType } : {}),
116
- ...(comment.anchor.multilineAnchor
116
+ ...(comment.anchor.multilineMarker
117
117
  ? {
118
- startLine: comment.anchor.multilineStartLine,
119
- startLineType: comment.anchor.multilineStartLineType,
118
+ startLine: comment.anchor.multilineMarker.startLine,
119
+ startLineType: comment.anchor.multilineMarker.startLineType,
120
120
  }
121
121
  : {}),
122
122
  },
@@ -23,6 +23,84 @@ function resolveToken(token: string | (() => string | undefined), missingTokenMe
23
23
  };
24
24
  }
25
25
 
26
+ type DiffLineType = 'ADDED' | 'REMOVED' | 'CONTEXT';
27
+
28
+ /**
29
+ * Build a Bitbucket DC inline comment anchor.
30
+ *
31
+ * For a multiline range, Bitbucket DC (>= 9.3.0) requires `multilineMarker` + `multilineSpan`.
32
+ * The legacy `multilineStartLine`/`multilineStartLineType`/`multilineAnchor`/`multilineDestinationRange`
33
+ * fields are silently ignored by the server, which then stores a single-line anchor — so a multiline
34
+ * `​```suggestion` only replaces its first line on Apply. Field names verified against BB DC 9.3.2.
35
+ */
36
+ function buildCommentAnchor(params: {
37
+ filePath: string;
38
+ line?: number;
39
+ lineType?: DiffLineType;
40
+ startLine?: number;
41
+ startLineType?: DiffLineType;
42
+ }): Record<string, any> {
43
+ const { filePath, line, lineType } = params;
44
+ const anchor: Record<string, any> = { path: filePath, diffType: 'EFFECTIVE' };
45
+ if (line === undefined || !lineType) {
46
+ return anchor;
47
+ }
48
+
49
+ if (params.startLine === undefined) {
50
+ anchor.line = line;
51
+ anchor.lineType = lineType;
52
+ anchor.fileType = lineType === 'REMOVED' ? 'FROM' : 'TO';
53
+ return anchor;
54
+ }
55
+
56
+ // Normalize so the marker sits on the lower line and `line` on the upper line.
57
+ let startLine = params.startLine;
58
+ let startLineType: DiffLineType = params.startLineType ?? lineType;
59
+ let endLine = line;
60
+ let endLineType: DiffLineType = lineType;
61
+ if (startLine > endLine) {
62
+ [startLine, endLine] = [endLine, startLine];
63
+ [startLineType, endLineType] = [endLineType, startLineType];
64
+ }
65
+
66
+ const fileType = endLineType === 'REMOVED' ? 'FROM' : 'TO';
67
+ anchor.line = endLine;
68
+ anchor.lineType = endLineType;
69
+ anchor.fileType = fileType;
70
+ anchor.multilineMarker = { startLine, startLineType };
71
+ anchor.multilineSpan =
72
+ fileType === 'FROM'
73
+ ? { srcSpanStart: startLine, srcSpanEnd: endLine }
74
+ : { dstSpanStart: startLine, dstSpanEnd: endLine };
75
+ return anchor;
76
+ }
77
+
78
+ const SUGGESTION_BLOCK_RE = /```suggestion\b[^\n]*\n([\s\S]*?)```/;
79
+
80
+ /**
81
+ * Warn when a multi-line `​```suggestion` block is anchored to a single line: Bitbucket only replaces
82
+ * the anchored line on Apply, leaving the rest (duplicated code). Returns undefined when there is no
83
+ * concern. Heuristic — a 1-line anchor with a multi-line suggestion is the common failure shape.
84
+ */
85
+ function multilineSuggestionWarning(text: string, startLine?: number): string | undefined {
86
+ if (startLine !== undefined) {
87
+ return undefined;
88
+ }
89
+ const match = SUGGESTION_BLOCK_RE.exec(text);
90
+ if (!match) {
91
+ return undefined;
92
+ }
93
+ const suggestionLineCount = match[1].replace(/\n$/, '').split('\n').length;
94
+ if (suggestionLineCount <= 1) {
95
+ return undefined;
96
+ }
97
+ return (
98
+ 'The comment contains a multi-line ```suggestion block but is anchored to a single line. ' +
99
+ 'On Apply, Bitbucket replaces only that one line and leaves the rest. To replace multiple lines, ' +
100
+ 'set startLine (first replaced line) and line (last replaced line) to span the whole range.'
101
+ );
102
+ }
103
+
26
104
  export class BitbucketService {
27
105
  private readonly getPageSize: () => number;
28
106
 
@@ -301,13 +379,14 @@ export class BitbucketService {
301
379
  * @param text The comment text
302
380
  * @param parentId Optional parent comment ID for replies
303
381
  * @param filePath Optional file path for file-specific comments
304
- * @param line Optional end line number for line/multiline comments
305
- * @param lineType Optional line type ('ADDED', 'REMOVED', 'CONTEXT') for the end line
306
382
  * @param startLine Optional start line for multiline comments; when provided together with line, creates a range comment
307
383
  * @param startLineType Optional line type for the start line; defaults to lineType if omitted
384
+ * @param line Optional end line number for line/multiline comments
385
+ * @param lineType Optional line type ('ADDED', 'REMOVED', 'CONTEXT') for the end line
308
386
  * @param pending Optional flag to create a pending (draft) comment, not visible to others until a review is submitted.
309
387
  * Only works when filePath is provided (file-level or inline comments).
310
388
  * Top-level PR comments (no filePath) are always posted live regardless of this flag.
389
+ * @param severity Optional severity for the comment. 'BLOCKER' posts the comment as a task that must be resolved before the PR can be merged. Defaults to 'NORMAL' (regular comment) when omitted.
311
390
  * @returns Promise with created comment data
312
391
  */
313
392
  async postPullRequestComment(
@@ -322,6 +401,7 @@ export class BitbucketService {
322
401
  line?: number,
323
402
  lineType?: 'ADDED' | 'REMOVED' | 'CONTEXT',
324
403
  pending?: boolean,
404
+ severity?: 'NORMAL' | 'BLOCKER',
325
405
  output: BitbucketMutationOutputMode = 'ack'
326
406
  ) {
327
407
  projectKey = projectKey.toUpperCase();
@@ -330,6 +410,10 @@ export class BitbucketService {
330
410
  text
331
411
  };
332
412
 
413
+ if (severity) {
414
+ comment.severity = severity;
415
+ }
416
+
333
417
  // Mark comment as pending (draft) — not visible to others until review is submitted
334
418
  // Note: Bitbucket DC uses state:'PENDING' not pending:true
335
419
  if (pending) {
@@ -343,27 +427,11 @@ export class BitbucketService {
343
427
 
344
428
  // Add anchor for file/line comments
345
429
  if (filePath) {
346
- comment.anchor = {
347
- path: filePath,
348
- diffType: 'EFFECTIVE'
349
- };
350
-
351
- // Add line-specific anchor properties
352
- if (line !== undefined && lineType) {
353
- comment.anchor.line = line;
354
- comment.anchor.lineType = lineType;
355
- comment.anchor.fileType = 'TO'; // Default to destination file
356
-
357
- if (startLine !== undefined) {
358
- const resolvedStartLineType = startLineType ?? lineType;
359
- comment.anchor.multilineAnchor = true;
360
- comment.anchor.multilineStartLine = startLine;
361
- comment.anchor.multilineStartLineType = resolvedStartLineType;
362
- comment.anchor.multilineDestinationRange = { minimum: startLine, maximum: line };
363
- }
364
- }
430
+ comment.anchor = buildCommentAnchor({ filePath, line, lineType, startLine, startLineType });
365
431
  }
366
432
 
433
+ const suggestionWarning = filePath ? multilineSuggestionWarning(text, startLine) : undefined;
434
+
367
435
  const result = await handleApiOperation(
368
436
  () => PullRequestsService.createComment2(
369
437
  projectKey,
@@ -374,6 +442,76 @@ export class BitbucketService {
374
442
  'Error posting pull request comment'
375
443
  );
376
444
 
445
+ if (result.success && result.data && output !== 'full') {
446
+ return {
447
+ ...result,
448
+ data: {
449
+ ...shapePullRequestCommentAck(result.data),
450
+ ...(suggestionWarning ? { warning: suggestionWarning } : {}),
451
+ },
452
+ };
453
+ }
454
+
455
+ if (result.success && suggestionWarning) {
456
+ return { ...result, warning: suggestionWarning };
457
+ }
458
+
459
+ return result;
460
+ }
461
+
462
+ /**
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.
467
+ * @param projectKey The project key
468
+ * @param repositorySlug The repository slug
469
+ * @param pullRequestId The pull request ID
470
+ * @param commentId The comment ID to update
471
+ * @param version The current version of the comment (required for optimistic locking)
472
+ * @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.
474
+ * @param severity Optional new severity. 'BLOCKER' converts a comment into a task, 'NORMAL' converts a task back to a regular comment.
475
+ * @returns Promise with updated comment data
476
+ */
477
+ async updatePullRequestComment(
478
+ projectKey: string,
479
+ repositorySlug: string,
480
+ pullRequestId: string,
481
+ commentId: string,
482
+ version: number,
483
+ text?: string,
484
+ state?: 'OPEN' | 'RESOLVED',
485
+ severity?: 'NORMAL' | 'BLOCKER',
486
+ output: BitbucketMutationOutputMode = 'ack'
487
+ ) {
488
+ projectKey = projectKey.toUpperCase();
489
+ repositorySlug = repositorySlug.toLowerCase();
490
+ const comment: any = { version };
491
+
492
+ if (text !== undefined) {
493
+ comment.text = text;
494
+ }
495
+
496
+ if (state) {
497
+ comment.state = state;
498
+ }
499
+
500
+ if (severity) {
501
+ comment.severity = severity;
502
+ }
503
+
504
+ const result = await handleApiOperation(
505
+ () => PullRequestsService.updateComment2(
506
+ projectKey,
507
+ commentId,
508
+ pullRequestId,
509
+ repositorySlug,
510
+ comment
511
+ ),
512
+ 'Error updating pull request comment'
513
+ );
514
+
377
515
  if (result.success && result.data && output !== 'full') {
378
516
  return {
379
517
  ...result,
@@ -842,11 +980,23 @@ export const bitbucketToolSchemas = {
842
980
  text: z.string().describe("The comment text"),
843
981
  parentId: z.number().optional().describe("Parent comment ID for replies"),
844
982
  filePath: z.string().optional().describe("File path for file-specific comments"),
845
- startLine: z.number().optional().describe("Start line of a multiline comment range. When provided together with 'line' (end line), creates a multiline comment spanning from startLine to line."),
846
- startLineType: z.enum(['ADDED', 'REMOVED', 'CONTEXT']).optional().describe("Line type for the start line of a multiline comment. Defaults to the same value as lineType if omitted."),
847
- line: z.number().optional().describe("Line number for single-line comments, or end line for multiline comments"),
848
- lineType: z.enum(['ADDED', 'REMOVED', 'CONTEXT']).optional().describe("Line type for the end line (or the only line for single-line comments)"),
983
+ startLine: z.number().optional().describe("First line of a multiline range. Provide together with 'line' (the last line) to span multiple lines. REQUIRED for a multi-line ```suggestion: the anchored range (startLine..line) is exactly what 'Apply suggestion' replaces — omit it and only the single 'line' is replaced, leaving the rest. Requires Bitbucket DC >= 9.3.0 for multiline suggestions."),
984
+ startLineType: z.enum(['ADDED', 'REMOVED', 'CONTEXT']).optional().describe("Line type for the start line of a multiline range. Defaults to the same value as lineType if omitted."),
985
+ line: z.number().optional().describe("Single-line comments: the line. Multiline: the LAST line of the range (use startLine for the first). For a ```suggestion this is the last replaced line."),
986
+ lineType: z.enum(['ADDED', 'REMOVED', 'CONTEXT']).optional().describe("Line type for 'line' (the end line, or the only line for single-line comments). Use 'ADDED'/'CONTEXT' for the new/target file, 'REMOVED' for the original/source file."),
849
987
  pending: z.boolean().optional().describe("If true, creates a pending (draft) comment not visible to others until the review is submitted via bitbucket_submitPullRequestReview. Only works when filePath is provided — top-level PR comments (no filePath) are always posted live."),
988
+ severity: z.enum(['NORMAL', 'BLOCKER']).optional().describe("Comment severity. Use 'BLOCKER' to post the comment as a task that must be resolved before the PR can be merged. Defaults to 'NORMAL' (regular comment)."),
989
+ output: z.enum(['ack', 'full']).optional().describe("Return a compact acknowledgement or the full API response. Defaults to ack.")
990
+ },
991
+ updatePullRequestComment: {
992
+ projectKey: z.string().describe("The project key"),
993
+ repositorySlug: z.string().describe("The repository slug"),
994
+ pullRequestId: z.string().describe("The pull request ID"),
995
+ commentId: z.string().describe("The ID of the comment to update"),
996
+ 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."),
997
+ text: z.string().optional().describe("New comment text. Omit to leave unchanged."),
998
+ 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."),
999
+ severity: z.enum(['NORMAL', 'BLOCKER']).optional().describe("New severity. Use 'BLOCKER' to convert a comment into a task, 'NORMAL' to convert it back."),
850
1000
  output: z.enum(['ack', 'full']).optional().describe("Return a compact acknowledgement or the full API response. Defaults to ack.")
851
1001
  },
852
1002
  getUser: {
package/src/index.ts CHANGED
@@ -129,10 +129,20 @@ server.tool(
129
129
 
130
130
  server.tool(
131
131
  "bitbucket_postPullRequestComment",
132
- "Post a comment to a Bitbucket pull request. Use pending: true to create a draft comment that is only visible to you until you call bitbucket_submitPullRequestReview. NOTE: pending only works when filePath is provided (file-level or inline comments). True top-level PR comments (no filePath) are always posted live and cannot be drafted.",
132
+ "Post a comment to a Bitbucket pull request. Use pending: true to create a draft comment that is only visible to you until you call bitbucket_submitPullRequestReview. NOTE: pending only works when filePath is provided (file-level or inline comments). True top-level PR comments (no filePath) are always posted live and cannot be drafted. Use severity: 'BLOCKER' to post the comment as a task — tasks must be resolved before the PR can be merged.",
133
133
  bitbucketToolSchemas.postPullRequestComment,
134
- async ({ projectKey, repositorySlug, pullRequestId, text, parentId, filePath, startLine, startLineType, line, lineType, pending, output }) => {
135
- const result = await bitbucketService.postPullRequestComment(projectKey, repositorySlug, pullRequestId, text, parentId, filePath, startLine, startLineType, line, lineType, pending, output);
134
+ async ({ projectKey, repositorySlug, pullRequestId, text, parentId, filePath, startLine, startLineType, line, lineType, pending, severity, output }) => {
135
+ const result = await bitbucketService.postPullRequestComment(projectKey, repositorySlug, pullRequestId, text, parentId, filePath, startLine, startLineType, line, lineType, pending, severity, output);
136
+ return formatToolResponse(result);
137
+ }
138
+ );
139
+
140
+ server.tool(
141
+ "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
+ 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);
136
146
  return formatToolResponse(result);
137
147
  }
138
148
  );