@stubbedev/atlassian-mcp 0.3.2 → 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
@@ -133,11 +133,20 @@ function pageHint(data) {
133
133
  }
134
134
  function formatDiff(data, maxChars = 8000) {
135
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
+ }
136
140
  for (const diff of data.diffs) {
137
141
  const from = diff.source?.toString ?? '/dev/null';
138
142
  const to = diff.destination?.toString ?? '/dev/null';
139
143
  parts.push(`--- a/${from}\n+++ b/${to}`);
140
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} @@`);
141
150
  for (const segment of hunk.segments ?? []) {
142
151
  const prefix = segment.type === 'ADDED' ? '+' : segment.type === 'REMOVED' ? '-' : ' ';
143
152
  for (const line of segment.lines ?? []) {
@@ -327,6 +336,49 @@ export class BitbucketClient {
327
336
  }
328
337
  return res.status === 204 ? null : res.json();
329
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
+ }
330
382
  /** Returns true if the given remote URL belongs to this Bitbucket instance. */
331
383
  isRemoteForThisInstance(remoteUrl) {
332
384
  return this.remoteMatchesInstance(remoteUrl);
@@ -495,11 +547,17 @@ export class BitbucketClient {
495
547
  collectAttachmentRefs(pr.description, 'description', attachmentRefs);
496
548
  const reviewers = pr.reviewers.map((r) => `${r.user.displayName}${r.approved ? ' ✓' : ''}`).join(', ');
497
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
+ : '';
498
555
  const header = [
499
556
  `PR #${pr.id}: ${pr.title}`,
500
557
  `State: ${pr.state}`,
501
558
  `Author: ${pr.author.user.displayName}`,
502
559
  `Branch: ${pr.fromRef.displayId} → ${pr.toRef.displayId}`,
560
+ commitsLine,
503
561
  `Reviewers: ${reviewers || 'None'}`,
504
562
  url ? `URL: ${url}` : '',
505
563
  '',
@@ -949,7 +1007,9 @@ export class BitbucketClient {
949
1007
  || args.lineType !== undefined
950
1008
  || args.fileType !== undefined
951
1009
  || args.multilineStartLine !== undefined
952
- || args.multilineStartLineType !== undefined)) {
1010
+ || args.multilineStartLineType !== undefined
1011
+ || args.fromHash !== undefined
1012
+ || args.toHash !== undefined)) {
953
1013
  throw new Error('Replies must target an existing comment thread only. Omit filePath/line and other anchor fields when replying.');
954
1014
  }
955
1015
  if (args.text === undefined && args.suggestion === undefined) {
@@ -974,11 +1034,14 @@ export class BitbucketClient {
974
1034
  if (replyToCommentId !== undefined)
975
1035
  body.parent = { id: replyToCommentId };
976
1036
  let inlineAnchor;
1037
+ let usedFallbackHashes = false;
1038
+ let currentToHash;
1039
+ let currentFromHash;
1040
+ let remapNote;
977
1041
  if (args.filePath !== undefined || args.line !== undefined) {
978
1042
  if (args.filePath === undefined || args.line === undefined) {
979
1043
  throw new Error('filePath and line must be provided together for inline comments.');
980
1044
  }
981
- const pr = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}`);
982
1045
  inlineAnchor = {
983
1046
  diffType: 'EFFECTIVE',
984
1047
  fileType: args.fileType ?? 'TO',
@@ -989,14 +1052,49 @@ export class BitbucketClient {
989
1052
  if (args.srcPath !== undefined) {
990
1053
  inlineAnchor.srcPath = args.srcPath;
991
1054
  }
992
- const fromHash = pr?.toRef.latestCommit;
993
- 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
+ }
994
1090
  if (fromHash && toHash) {
995
1091
  inlineAnchor.fromHash = fromHash;
996
1092
  inlineAnchor.toHash = toHash;
997
1093
  }
998
- if (args.multilineStartLine !== undefined) {
1094
+ if (args.multilineStartLine !== undefined && inlineAnchor.multilineStartLine === undefined) {
999
1095
  inlineAnchor.multilineStartLine = args.multilineStartLine;
1096
+ }
1097
+ if (inlineAnchor.multilineStartLine !== undefined) {
1000
1098
  inlineAnchor.multilineStartLineType = args.multilineStartLineType ?? args.lineType ?? 'ADDED';
1001
1099
  }
1002
1100
  body.anchor = inlineAnchor;
@@ -1020,7 +1118,14 @@ export class BitbucketClient {
1020
1118
  return text(`Reply #${created.id} added to comment #${replyToCommentId} on PR #${args.prId}.`);
1021
1119
  }
1022
1120
  const location = args.filePath && args.line ? ` on ${args.filePath}:${args.line}` : '';
1023
- 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}`);
1024
1129
  }
1025
1130
  async updatePrComment(args) {
1026
1131
  const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
package/dist/index.js CHANGED
@@ -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.2",
3
+ "version": "0.3.3",
4
4
  "description": "MCP server for self-hosted Jira and Bitbucket",
5
5
  "license": "MIT",
6
6
  "type": "module",