@stubbedev/atlassian-mcp 0.3.1 → 0.3.3

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.
package/dist/bitbucket.js CHANGED
@@ -84,6 +84,7 @@ function commentMatchesState(comment, state) {
84
84
  const threadState = comment.threadResolved ? 'RESOLVED' : 'OPEN';
85
85
  if (threadState === state)
86
86
  return true;
87
+ return (comment.comments ?? []).some((child) => commentMatchesState(child, state));
87
88
  }
88
89
  const currentState = comment.state ?? 'OPEN';
89
90
  if (currentState === state)
@@ -125,18 +126,27 @@ function uniqueCommentsFromActivities(activities) {
125
126
  }
126
127
  return Array.from(byId.values())
127
128
  .filter((comment) => !comment.deleted)
128
- .sort((a, b) => (a.createdDate ?? 0) - (b.createdDate ?? 0));
129
+ .sort((a, b) => (b.createdDate ?? 0) - (a.createdDate ?? 0));
129
130
  }
130
131
  function pageHint(data) {
131
132
  return data.isLastPage ? '' : ` (use start=${data.nextPageStart} for next page)`;
132
133
  }
133
134
  function formatDiff(data, maxChars = 8000) {
134
135
  const parts = [];
136
+ if (data.fromHash && data.toHash) {
137
+ parts.push(`# fromHash=${data.fromHash} toHash=${data.toHash}`);
138
+ parts.push('# Pass these to bitbucket_comment as fromHash/toHash to anchor inline comments to this exact diff.');
139
+ }
135
140
  for (const diff of data.diffs) {
136
141
  const from = diff.source?.toString ?? '/dev/null';
137
142
  const to = diff.destination?.toString ?? '/dev/null';
138
143
  parts.push(`--- a/${from}\n+++ b/${to}`);
139
144
  for (const hunk of diff.hunks ?? []) {
145
+ const srcLine = hunk.sourceLine ?? 0;
146
+ const srcSpan = hunk.sourceSpan ?? 0;
147
+ const dstLine = hunk.destinationLine ?? 0;
148
+ const dstSpan = hunk.destinationSpan ?? 0;
149
+ parts.push(`@@ -${srcLine},${srcSpan} +${dstLine},${dstSpan} @@`);
140
150
  for (const segment of hunk.segments ?? []) {
141
151
  const prefix = segment.type === 'ADDED' ? '+' : segment.type === 'REMOVED' ? '-' : ' ';
142
152
  for (const line of segment.lines ?? []) {
@@ -326,6 +336,49 @@ export class BitbucketClient {
326
336
  }
327
337
  return res.status === 204 ? null : res.json();
328
338
  }
339
+ /**
340
+ * Remap a source-side line number through an interim diff.
341
+ *
342
+ * Returns the destination line if the source line survives unchanged through the diff
343
+ * (in a CONTEXT segment), or null if the line was modified/removed and cannot be remapped.
344
+ */
345
+ async remapLineThroughDiff(projectKey, repoSlug, filePath, sinceHash, untilHash, sourceLine) {
346
+ const diff = await this.request('GET', `${this.rp(projectKey, repoSlug)}/diff/${filePath.split('/').map(encodeURIComponent).join('/')}?since=${encodeURIComponent(sinceHash)}&until=${encodeURIComponent(untilHash)}&contextLines=0`).catch(() => null);
347
+ if (!diff || !diff.diffs?.length)
348
+ return sourceLine;
349
+ let offset = 0;
350
+ for (const fileDiff of diff.diffs) {
351
+ for (const hunk of fileDiff.hunks ?? []) {
352
+ const srcStart = hunk.sourceLine ?? 0;
353
+ const srcSpan = hunk.sourceSpan ?? 0;
354
+ const srcEnd = srcStart + srcSpan - 1;
355
+ if (srcSpan > 0 && sourceLine >= srcStart && sourceLine <= srcEnd) {
356
+ for (const segment of hunk.segments ?? []) {
357
+ if (segment.type === 'ADDED')
358
+ continue;
359
+ for (const ln of segment.lines ?? []) {
360
+ if (ln.source === sourceLine) {
361
+ if (segment.type === 'CONTEXT' && ln.destination !== undefined)
362
+ return ln.destination;
363
+ return null;
364
+ }
365
+ }
366
+ }
367
+ return null;
368
+ }
369
+ if (sourceLine > srcEnd || srcSpan === 0) {
370
+ const dstSpan = hunk.destinationSpan ?? 0;
371
+ if (srcSpan === 0 && (hunk.destinationLine ?? 0) <= sourceLine + offset) {
372
+ offset += dstSpan;
373
+ }
374
+ else if (sourceLine > srcEnd) {
375
+ offset += dstSpan - srcSpan;
376
+ }
377
+ }
378
+ }
379
+ }
380
+ return sourceLine + offset;
381
+ }
329
382
  /** Returns true if the given remote URL belongs to this Bitbucket instance. */
330
383
  isRemoteForThisInstance(remoteUrl) {
331
384
  return this.remoteMatchesInstance(remoteUrl);
@@ -494,11 +547,17 @@ export class BitbucketClient {
494
547
  collectAttachmentRefs(pr.description, 'description', attachmentRefs);
495
548
  const reviewers = pr.reviewers.map((r) => `${r.user.displayName}${r.approved ? ' ✓' : ''}`).join(', ');
496
549
  const url = pr.links?.self?.[0]?.href;
550
+ const fromHash = pr.toRef.latestCommit;
551
+ const toHash = pr.fromRef.latestCommit;
552
+ const commitsLine = fromHash && toHash
553
+ ? `Commits: fromHash=${fromHash} toHash=${toHash} (pass to bitbucket_comment to anchor inline comments to this exact state)`
554
+ : '';
497
555
  const header = [
498
556
  `PR #${pr.id}: ${pr.title}`,
499
557
  `State: ${pr.state}`,
500
558
  `Author: ${pr.author.user.displayName}`,
501
559
  `Branch: ${pr.fromRef.displayId} → ${pr.toRef.displayId}`,
560
+ commitsLine,
502
561
  `Reviewers: ${reviewers || 'None'}`,
503
562
  url ? `URL: ${url}` : '',
504
563
  '',
@@ -535,13 +594,15 @@ export class BitbucketClient {
535
594
  if (includeComments) {
536
595
  const commentsLimit = args.commentsLimit ?? 50;
537
596
  const commentsStart = args.commentsStart ?? 0;
538
- const commentsState = args.commentsState ?? 'OPEN';
597
+ const commentsState = args.commentsState ?? 'ALL';
539
598
  const commentsSeverity = args.commentsSeverity ?? 'ALL';
540
599
  if (commentsSeverity === 'BLOCKER' && commentsState === 'PENDING') {
541
- throw new Error('commentsState=PENDING is not valid when commentsSeverity=BLOCKER. Use OPEN or RESOLVED.');
600
+ throw new Error('commentsState=PENDING is not valid when commentsSeverity=BLOCKER. Use OPEN, RESOLVED, or ALL.');
542
601
  }
543
602
  if (commentsSeverity === 'BLOCKER') {
544
- const qs = new URLSearchParams({ limit: String(commentsLimit), start: String(commentsStart), state: commentsState });
603
+ const qs = new URLSearchParams({ limit: String(commentsLimit), start: String(commentsStart) });
604
+ if (commentsState !== 'ALL')
605
+ qs.set('state', commentsState);
545
606
  const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${prId}/blocker-comments?${qs}`);
546
607
  if (!data || data.values.length === 0) {
547
608
  sections.push(`Comments:\n(no ${commentsState} BLOCKER comments)`);
@@ -556,7 +617,7 @@ export class BitbucketClient {
556
617
  else {
557
618
  const activityData = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${prId}/activities?limit=${commentsLimit}&start=${commentsStart}`);
558
619
  const comments = uniqueCommentsFromActivities(activityData?.values ?? []).filter((comment) => {
559
- const matchesState = commentMatchesState(comment, commentsState);
620
+ const matchesState = commentsState === 'ALL' ? true : commentMatchesState(comment, commentsState);
560
621
  return matchesState && commentMatchesSeverity(comment, commentsSeverity);
561
622
  });
562
623
  for (const comment of comments)
@@ -567,7 +628,7 @@ export class BitbucketClient {
567
628
  else {
568
629
  const blocks = comments.flatMap((comment) => formatCommentThread(comment));
569
630
  const paging = activityData ? pageHint(activityData) : '';
570
- sections.push(`Comments (${comments.length} thread(s))${paging}:\n\n${blocks.join('\n\n')}`);
631
+ sections.push(`Comments (${comments.length} thread(s), newest first)${paging}:\n\n${blocks.join('\n\n')}`);
571
632
  }
572
633
  }
573
634
  }
@@ -946,7 +1007,9 @@ export class BitbucketClient {
946
1007
  || args.lineType !== undefined
947
1008
  || args.fileType !== undefined
948
1009
  || args.multilineStartLine !== undefined
949
- || args.multilineStartLineType !== undefined)) {
1010
+ || args.multilineStartLineType !== undefined
1011
+ || args.fromHash !== undefined
1012
+ || args.toHash !== undefined)) {
950
1013
  throw new Error('Replies must target an existing comment thread only. Omit filePath/line and other anchor fields when replying.');
951
1014
  }
952
1015
  if (args.text === undefined && args.suggestion === undefined) {
@@ -971,11 +1034,14 @@ export class BitbucketClient {
971
1034
  if (replyToCommentId !== undefined)
972
1035
  body.parent = { id: replyToCommentId };
973
1036
  let inlineAnchor;
1037
+ let usedFallbackHashes = false;
1038
+ let currentToHash;
1039
+ let currentFromHash;
1040
+ let remapNote;
974
1041
  if (args.filePath !== undefined || args.line !== undefined) {
975
1042
  if (args.filePath === undefined || args.line === undefined) {
976
1043
  throw new Error('filePath and line must be provided together for inline comments.');
977
1044
  }
978
- const pr = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}`);
979
1045
  inlineAnchor = {
980
1046
  diffType: 'EFFECTIVE',
981
1047
  fileType: args.fileType ?? 'TO',
@@ -986,14 +1052,49 @@ export class BitbucketClient {
986
1052
  if (args.srcPath !== undefined) {
987
1053
  inlineAnchor.srcPath = args.srcPath;
988
1054
  }
989
- const fromHash = pr?.toRef.latestCommit;
990
- const toHash = pr?.fromRef.latestCommit;
1055
+ const pr = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}`).catch(() => null);
1056
+ currentToHash = pr?.fromRef.latestCommit;
1057
+ currentFromHash = pr?.toRef.latestCommit;
1058
+ let fromHash = args.fromHash ?? currentFromHash;
1059
+ let toHash = args.toHash ?? currentToHash;
1060
+ usedFallbackHashes = args.fromHash === undefined && args.toHash === undefined;
1061
+ const fileType = args.fileType ?? 'TO';
1062
+ const reviewedToHash = args.toHash;
1063
+ if (reviewedToHash
1064
+ && currentToHash
1065
+ && reviewedToHash !== currentToHash
1066
+ && fileType === 'TO') {
1067
+ const remappedLine = await this.remapLineThroughDiff(projectKey, repoSlug, args.filePath, reviewedToHash, currentToHash, args.line);
1068
+ let remappedMultilineStart;
1069
+ if (args.multilineStartLine !== undefined) {
1070
+ remappedMultilineStart = await this.remapLineThroughDiff(projectKey, repoSlug, args.filePath, reviewedToHash, currentToHash, args.multilineStartLine);
1071
+ }
1072
+ const lineOk = remappedLine !== null;
1073
+ const multilineOk = remappedMultilineStart === undefined || remappedMultilineStart !== null;
1074
+ if (lineOk && multilineOk) {
1075
+ if (remappedLine !== args.line) {
1076
+ remapNote = `Reviewed line ${args.line} remapped to ${remappedLine} on current head ${currentToHash.slice(0, 8)}.`;
1077
+ }
1078
+ inlineAnchor.line = remappedLine;
1079
+ if (remappedMultilineStart !== undefined && remappedMultilineStart !== null) {
1080
+ inlineAnchor.multilineStartLine = remappedMultilineStart;
1081
+ }
1082
+ toHash = currentToHash;
1083
+ if (!args.fromHash)
1084
+ fromHash = currentFromHash;
1085
+ }
1086
+ else {
1087
+ remapNote = `Reviewed line ${args.line} was modified or removed in interim commits; anchoring to reviewed commit ${reviewedToHash.slice(0, 8)} (Bitbucket will mark the comment outdated, which is correct — the line you reviewed no longer exists at current head).`;
1088
+ }
1089
+ }
991
1090
  if (fromHash && toHash) {
992
1091
  inlineAnchor.fromHash = fromHash;
993
1092
  inlineAnchor.toHash = toHash;
994
1093
  }
995
- if (args.multilineStartLine !== undefined) {
1094
+ if (args.multilineStartLine !== undefined && inlineAnchor.multilineStartLine === undefined) {
996
1095
  inlineAnchor.multilineStartLine = args.multilineStartLine;
1096
+ }
1097
+ if (inlineAnchor.multilineStartLine !== undefined) {
997
1098
  inlineAnchor.multilineStartLineType = args.multilineStartLineType ?? args.lineType ?? 'ADDED';
998
1099
  }
999
1100
  body.anchor = inlineAnchor;
@@ -1017,7 +1118,14 @@ export class BitbucketClient {
1017
1118
  return text(`Reply #${created.id} added to comment #${replyToCommentId} on PR #${args.prId}.`);
1018
1119
  }
1019
1120
  const location = args.filePath && args.line ? ` on ${args.filePath}:${args.line}` : '';
1020
- return text(`Comment #${created.id} added to PR #${args.prId}${location}.`);
1121
+ const warnings = [];
1122
+ if (inlineAnchor && usedFallbackHashes) {
1123
+ warnings.push('No fromHash/toHash passed — anchored to latest PR head. If you reviewed an older commit, the line may now point at unrelated code. Pass fromHash/toHash from bitbucket_pr_diff or bitbucket_get_pr to bind comments to the exact commit you reviewed.');
1124
+ }
1125
+ if (remapNote)
1126
+ warnings.push(remapNote);
1127
+ const warnSuffix = warnings.length ? `\n\nNote: ${warnings.join(' ')}` : '';
1128
+ return text(`Comment #${created.id} added to PR #${args.prId}${location}.${warnSuffix}`);
1021
1129
  }
1022
1130
  async updatePrComment(args) {
1023
1131
  const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
package/dist/index.js CHANGED
@@ -372,7 +372,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
372
372
  includeComments: { type: 'boolean', description: 'Include review comments and blockers (default true)', default: true },
373
373
  includeDiff: { type: 'boolean', description: 'Include diff text (default false)', default: false },
374
374
  includeBuildStatus: { type: 'boolean', description: 'Include CI/build status for the head commit (default true)', default: true },
375
- commentsState: { type: 'string', enum: ['OPEN', 'RESOLVED', 'PENDING'], description: 'Comment state filter (default OPEN)', default: 'OPEN' },
375
+ commentsState: { type: 'string', enum: ['ALL', 'OPEN', 'RESOLVED', 'PENDING'], description: 'Comment state filter (default ALL — returns every comment with its state badge so nothing is silently hidden). Pass OPEN/RESOLVED only when explicitly narrowing.', default: 'ALL' },
376
376
  commentsSeverity: { type: 'string', enum: ['ALL', 'NORMAL', 'BLOCKER'], description: 'Comment severity filter (default ALL)', default: 'ALL' },
377
377
  commentsLimit: { type: 'number', description: 'Max comments (default 50)', default: 50 },
378
378
  commentsStart: { type: 'number', description: 'Comment pagination offset (default 0)', default: 0 },
@@ -422,7 +422,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
422
422
  },
423
423
  {
424
424
  name: 'bitbucket_comment',
425
- description: `Add, update, or delete a PR comment. action defaults to "add". For code changes, ALWAYS use inline comments with suggestion when exact replacement code is available. Keep any explanatory text before the suggestion block only (never after), or Bitbucket may hide Apply suggestion. Replies MUST use commentId. Keep comments concise, no emojis. Only call proactively (without being asked) when you are a reviewer on the PR (i.e. "Viewing as" says "you are a reviewer") — never post unsolicited comments on PRs you authored.`,
425
+ description: `Add, update, or delete a PR comment. action defaults to "add". For code changes, ALWAYS use inline comments with suggestion when exact replacement code is available. Keep any explanatory text before the suggestion block only (never after), or Bitbucket may hide Apply suggestion. Replies MUST use commentId. Keep comments concise, no emojis. Only call proactively (without being asked) when you are a reviewer on the PR (i.e. "Viewing as" says "you are a reviewer") — never post unsolicited comments on PRs you authored. For inline comments: ALWAYS pass fromHash + toHash matching the commit you actually reviewed (read from bitbucket_pr_diff or bitbucket_get_pr output). Without them the anchor falls back to current PR head, and if the branch advanced between review and post the line number will point at unrelated code.`,
426
426
  inputSchema: {
427
427
  type: 'object',
428
428
  properties: {
@@ -441,6 +441,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
441
441
  fileType: { type: 'string', enum: ['TO', 'FROM'], description: 'Diff side: TO (new, default) or FROM (old)' },
442
442
  multilineStartLine: { type: 'number', description: 'First line of multiline anchor (pair with line as last line)' },
443
443
  multilineStartLineType: { type: 'string', enum: ['ADDED', 'REMOVED', 'CONTEXT'], description: 'Line type for multilineStartLine' },
444
+ fromHash: { type: 'string', description: 'Base/target commit of the diff you reviewed (from bitbucket_pr_diff or bitbucket_get_pr). Pair with toHash so the anchor binds to the exact commit, not whatever the PR head happens to be at post time.' },
445
+ toHash: { type: 'string', description: 'Source/feature commit of the diff you reviewed (from bitbucket_pr_diff or bitbucket_get_pr). Pair with fromHash. If the PR has advanced since, Bitbucket will mark the comment outdated — that is the correct behaviour.' },
444
446
  suggestion: { type: 'string', description: 'Replacement code to suggest. Use whenever proposing a concrete code change. Posted as the final ```suggestion``` block so Apply suggestion appears. Requires filePath + line.' },
445
447
  state: { type: 'string', enum: ['OPEN', 'RESOLVED'], description: 'Task state for BLOCKER comments (update only)' },
446
448
  threadResolved: { type: 'boolean', description: 'Resolve/reopen normal comment thread (update only)' },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stubbedev/atlassian-mcp",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "MCP server for self-hosted Jira and Bitbucket",
5
5
  "license": "MIT",
6
6
  "type": "module",