@atlassian-dc-mcp/bitbucket 0.25.0 → 0.26.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.
@@ -861,10 +861,8 @@ describe('BitbucketService', () => {
861
861
  line: 15,
862
862
  lineType: 'ADDED',
863
863
  fileType: 'TO',
864
- multilineAnchor: true,
865
- multilineStartLine: 10,
866
- multilineStartLineType: 'ADDED',
867
- multilineDestinationRange: { minimum: 10, maximum: 15 }
864
+ multilineMarker: { startLine: 10, startLineType: 'ADDED' },
865
+ multilineSpan: { dstSpanStart: 10, dstSpanEnd: 15 }
868
866
  }
869
867
  };
870
868
  (PullRequestsService.createComment2 as jest.Mock).mockResolvedValue(mockComment);
@@ -906,10 +904,8 @@ describe('BitbucketService', () => {
906
904
  line: 15,
907
905
  lineType: 'ADDED',
908
906
  fileType: 'TO',
909
- multilineAnchor: true,
910
- multilineStartLine: 10,
911
- multilineStartLineType: 'ADDED',
912
- multilineDestinationRange: { minimum: 10, maximum: 15 }
907
+ multilineMarker: { startLine: 10, startLineType: 'ADDED' },
908
+ multilineSpan: { dstSpanStart: 10, dstSpanEnd: 15 }
913
909
  }
914
910
  }
915
911
  );
@@ -926,10 +922,8 @@ describe('BitbucketService', () => {
926
922
  line: 8,
927
923
  lineType: 'ADDED',
928
924
  fileType: 'TO',
929
- multilineAnchor: true,
930
- multilineStartLine: 5,
931
- multilineStartLineType: 'CONTEXT',
932
- multilineDestinationRange: { minimum: 5, maximum: 8 }
925
+ multilineMarker: { startLine: 5, startLineType: 'CONTEXT' },
926
+ multilineSpan: { dstSpanStart: 5, dstSpanEnd: 8 }
933
927
  }
934
928
  };
935
929
  (PullRequestsService.createComment2 as jest.Mock).mockResolvedValue(mockComment);
@@ -959,15 +953,133 @@ describe('BitbucketService', () => {
959
953
  line: 8,
960
954
  lineType: 'ADDED',
961
955
  fileType: 'TO',
962
- multilineAnchor: true,
963
- multilineStartLine: 5,
964
- multilineStartLineType: 'CONTEXT',
965
- multilineDestinationRange: { minimum: 5, maximum: 8 }
956
+ multilineMarker: { startLine: 5, startLineType: 'CONTEXT' },
957
+ multilineSpan: { dstSpanStart: 5, dstSpanEnd: 8 }
966
958
  }
967
959
  }
968
960
  );
969
961
  });
970
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
+
971
1083
  it('should handle API errors gracefully', async () => {
972
1084
  const mockError = new Error('API Error');
973
1085
  (PullRequestsService.createComment2 as jest.Mock).mockRejectedValue(mockError);
@@ -2473,4 +2585,63 @@ describe('BitbucketService', () => {
2473
2585
  );
2474
2586
  });
2475
2587
  });
2588
+
2589
+ describe('searchCode', () => {
2590
+ const { request: mockRequest } = require('../bitbucket-client/core/request.js');
2591
+
2592
+ it('should search code with the default page size', async () => {
2593
+ const mockData = { code: { count: 1, values: [{ repository: { slug: 'demo' } }] } };
2594
+ mockRequest.mockResolvedValue(mockData);
2595
+
2596
+ const result = await bitbucketService.searchCode('app');
2597
+
2598
+ expect(result.success).toBe(true);
2599
+ expect(result.data).toBe(mockData);
2600
+ expect(mockRequest).toHaveBeenCalledWith(
2601
+ expect.any(Object),
2602
+ {
2603
+ method: 'POST',
2604
+ url: '/search/latest/search',
2605
+ body: {
2606
+ query: 'app',
2607
+ entities: { code: {} },
2608
+ limits: { primary: 25 }
2609
+ },
2610
+ mediaType: 'application/json',
2611
+ errors: {
2612
+ 400: 'The search query was malformed.',
2613
+ 401: 'The currently authenticated user is not permitted to search.',
2614
+ },
2615
+ }
2616
+ );
2617
+ });
2618
+
2619
+ it('should pass explicit primary and secondary limits', async () => {
2620
+ mockRequest.mockResolvedValue({ code: { count: 0, values: [] } });
2621
+
2622
+ await bitbucketService.searchCode('repo:demo TODO', 10, 5);
2623
+
2624
+ expect(mockRequest).toHaveBeenCalledWith(
2625
+ expect.any(Object),
2626
+ expect.objectContaining({
2627
+ method: 'POST',
2628
+ url: '/search/latest/search',
2629
+ body: {
2630
+ query: 'repo:demo TODO',
2631
+ entities: { code: {} },
2632
+ limits: { primary: 10, secondary: 5 }
2633
+ },
2634
+ })
2635
+ );
2636
+ });
2637
+
2638
+ it('should handle errors when searching', async () => {
2639
+ mockRequest.mockRejectedValue(new Error('API Error'));
2640
+
2641
+ const result = await bitbucketService.searchCode('app');
2642
+
2643
+ expect(result.success).toBe(false);
2644
+ expect(result.error).toBeDefined();
2645
+ });
2646
+ });
2476
2647
  });
@@ -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
 
@@ -349,27 +427,11 @@ export class BitbucketService {
349
427
 
350
428
  // Add anchor for file/line comments
351
429
  if (filePath) {
352
- comment.anchor = {
353
- path: filePath,
354
- diffType: 'EFFECTIVE'
355
- };
356
-
357
- // Add line-specific anchor properties
358
- if (line !== undefined && lineType) {
359
- comment.anchor.line = line;
360
- comment.anchor.lineType = lineType;
361
- comment.anchor.fileType = 'TO'; // Default to destination file
362
-
363
- if (startLine !== undefined) {
364
- const resolvedStartLineType = startLineType ?? lineType;
365
- comment.anchor.multilineAnchor = true;
366
- comment.anchor.multilineStartLine = startLine;
367
- comment.anchor.multilineStartLineType = resolvedStartLineType;
368
- comment.anchor.multilineDestinationRange = { minimum: startLine, maximum: line };
369
- }
370
- }
430
+ comment.anchor = buildCommentAnchor({ filePath, line, lineType, startLine, startLineType });
371
431
  }
372
432
 
433
+ const suggestionWarning = filePath ? multilineSuggestionWarning(text, startLine) : undefined;
434
+
373
435
  const result = await handleApiOperation(
374
436
  () => PullRequestsService.createComment2(
375
437
  projectKey,
@@ -383,10 +445,17 @@ export class BitbucketService {
383
445
  if (result.success && result.data && output !== 'full') {
384
446
  return {
385
447
  ...result,
386
- data: shapePullRequestCommentAck(result.data),
448
+ data: {
449
+ ...shapePullRequestCommentAck(result.data),
450
+ ...(suggestionWarning ? { warning: suggestionWarning } : {}),
451
+ },
387
452
  };
388
453
  }
389
454
 
455
+ if (result.success && suggestionWarning) {
456
+ return { ...result, warning: suggestionWarning };
457
+ }
458
+
390
459
  return result;
391
460
  }
392
461
 
@@ -824,6 +893,46 @@ export class BitbucketService {
824
893
  return result;
825
894
  }
826
895
 
896
+ /**
897
+ * Search code across Bitbucket using the search REST module.
898
+ *
899
+ * The query string supports Bitbucket search modifiers, e.g. `project:TEST`,
900
+ * `repo:projectkey/repositoryslug` (e.g. `repo:TEST/demo`), `ext:js`, so scoping to a
901
+ * project or repository is done inside the query text. Note: `repo:` must include the
902
+ * project key (`repo:KEY/slug`); a bare `repo:slug` is rejected by the server.
903
+ *
904
+ * Note: the `/rest/search` endpoint is a separate Bitbucket REST module that is not part of
905
+ * the generated client, so this is issued via the low-level request helper (same approach as
906
+ * the dashboard/inbox endpoints).
907
+ *
908
+ * @param query The search query (may include modifiers like `repo:`, `project:`, `ext:`)
909
+ * @param limit Optional primary result limit (defaults to the package page size)
910
+ * @param secondaryLimit Optional secondary limit (number of hit contexts per match)
911
+ * @returns Promise with the code search results
912
+ */
913
+ async searchCode(query: string, limit?: number, secondaryLimit?: number) {
914
+ return handleApiOperation(
915
+ () => __request(OpenAPI, {
916
+ method: 'POST',
917
+ url: '/search/latest/search',
918
+ body: {
919
+ query,
920
+ entities: { code: {} },
921
+ limits: {
922
+ primary: limit ?? this.getPageSize(),
923
+ ...(secondaryLimit !== undefined ? { secondary: secondaryLimit } : {}),
924
+ },
925
+ },
926
+ mediaType: 'application/json',
927
+ errors: {
928
+ 400: 'The search query was malformed.',
929
+ 401: 'The currently authenticated user is not permitted to search.',
930
+ },
931
+ }),
932
+ 'Error searching code'
933
+ );
934
+ }
935
+
827
936
  async validateSetup(): Promise<void> {
828
937
  await __request(OpenAPI, {
829
938
  method: 'GET',
@@ -911,10 +1020,10 @@ export const bitbucketToolSchemas = {
911
1020
  text: z.string().describe("The comment text"),
912
1021
  parentId: z.number().optional().describe("Parent comment ID for replies"),
913
1022
  filePath: z.string().optional().describe("File path for file-specific comments"),
914
- 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."),
915
- 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."),
916
- line: z.number().optional().describe("Line number for single-line comments, or end line for multiline comments"),
917
- lineType: z.enum(['ADDED', 'REMOVED', 'CONTEXT']).optional().describe("Line type for the end line (or the only line for single-line comments)"),
1023
+ 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."),
1024
+ 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."),
1025
+ 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."),
1026
+ 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."),
918
1027
  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."),
919
1028
  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)."),
920
1029
  output: z.enum(['ack', 'full']).optional().describe("Return a compact acknowledgement or the full API response. Defaults to ack.")
@@ -995,5 +1104,10 @@ export const bitbucketToolSchemas = {
995
1104
  getInboxPullRequests: {
996
1105
  start: z.number().optional().describe("Start number for the page (inclusive). If not passed, first page is assumed"),
997
1106
  limit: z.number().optional().describe("Number of items to return. If not passed, the package default page size is used.")
1107
+ },
1108
+ searchCode: {
1109
+ query: z.string().describe("The search query. Supports Bitbucket search modifiers, e.g. 'project:TEST authenticate', 'repo:TEST/demo TODO' (the repo modifier must be 'repo:projectkey/repositoryslug'), 'ext:ts useState'. Scope to a project or repository inside the query text."),
1110
+ limit: z.number().optional().describe("Maximum number of matching files to return. If not passed, the package default page size is used."),
1111
+ secondaryLimit: z.number().optional().describe("Maximum number of hit contexts (matching code snippets) to return per file")
998
1112
  }
999
1113
  };
package/src/index.ts CHANGED
@@ -218,4 +218,14 @@ server.tool(
218
218
  }
219
219
  );
220
220
 
221
+ server.tool(
222
+ "bitbucket_searchCode",
223
+ "Search code across Bitbucket. The query supports search modifiers like 'project:<key>', 'repo:<key>/<slug>', and 'ext:<extension>' to scope or filter results (e.g. 'project:TEST authenticate', 'repo:TEST/demo ext:js TODO'). NOTE: the 'repo:' modifier requires the project key — 'repo:projectkey/repositoryslug', not a bare slug. Returns matching files with hit contexts (snippets).",
224
+ bitbucketToolSchemas.searchCode,
225
+ async ({ query, limit, secondaryLimit }) => {
226
+ const result = await bitbucketService.searchCode(query, limit, secondaryLimit);
227
+ return formatToolResponse(result);
228
+ }
229
+ );
230
+
221
231
  await connectServer(server);
@@ -24,6 +24,10 @@ interface CommentAnchor {
24
24
  path: string;
25
25
  diffType: string;
26
26
  orphaned: boolean;
27
+ multilineMarker?: {
28
+ startLine: number;
29
+ startLineType: string;
30
+ };
27
31
  }
28
32
 
29
33
  interface CommentProperties {
@@ -131,6 +135,8 @@ interface SimplifiedAnchor {
131
135
  line: number;
132
136
  path: string;
133
137
  fileType?: string;
138
+ startLine?: number;
139
+ startLineType?: string;
134
140
  }
135
141
 
136
142
  interface SimplifiedComment {
@@ -240,7 +246,13 @@ function simplifyAnchor(anchor: CommentAnchor): SimplifiedAnchor {
240
246
  return {
241
247
  line: anchor.line,
242
248
  path: anchor.path,
243
- fileType: anchor.fileType
249
+ fileType: anchor.fileType,
250
+ ...(anchor.multilineMarker
251
+ ? {
252
+ startLine: anchor.multilineMarker.startLine,
253
+ startLineType: anchor.multilineMarker.startLineType
254
+ }
255
+ : {})
244
256
  };
245
257
  }
246
258