@stubbedev/atlassian-mcp 0.4.5 → 0.5.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.
- package/README.md +52 -42
- package/bin/cli.mjs +59 -0
- package/package.json +17 -30
- package/scripts/download.mjs +86 -0
- package/scripts/postinstall.mjs +15 -0
- package/dist/attachment.js +0 -350
- package/dist/bitbucket.js +0 -1340
- package/dist/config.js +0 -62
- package/dist/context.js +0 -162
- package/dist/git.js +0 -227
- package/dist/index.js +0 -1055
- package/dist/jira.js +0 -979
- package/dist/video.js +0 -211
package/dist/bitbucket.js
DELETED
|
@@ -1,1340 +0,0 @@
|
|
|
1
|
-
import { execSync } from 'child_process';
|
|
2
|
-
import { createWriteStream } from 'fs';
|
|
3
|
-
import { Readable } from 'stream';
|
|
4
|
-
import { pipeline } from 'stream/promises';
|
|
5
|
-
import { resolve as resolvePath } from 'path';
|
|
6
|
-
import { buildAttachmentResult, formatBytes } from './attachment.js';
|
|
7
|
-
import { MAX_VIDEO_SOURCE_BYTES } from './video.js';
|
|
8
|
-
const EMOJI_RE = /\p{Extended_Pictographic}/u;
|
|
9
|
-
const ATTACHMENT_REF_RE = /!?\[([^\]]*)\]\(attachment:(\d+)\)/g;
|
|
10
|
-
function collectAttachmentRefs(input, source, out) {
|
|
11
|
-
if (!input)
|
|
12
|
-
return;
|
|
13
|
-
ATTACHMENT_REF_RE.lastIndex = 0;
|
|
14
|
-
let match;
|
|
15
|
-
while ((match = ATTACHMENT_REF_RE.exec(input)) !== null) {
|
|
16
|
-
const id = match[2];
|
|
17
|
-
if (!out.has(id)) {
|
|
18
|
-
out.set(id, { id, filename: match[1] || '(unnamed)', source });
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
function collectFromCommentTree(comment, out) {
|
|
23
|
-
if (comment.deleted)
|
|
24
|
-
return;
|
|
25
|
-
collectAttachmentRefs(comment.text, `comment #${comment.id}`, out);
|
|
26
|
-
for (const reply of comment.comments ?? [])
|
|
27
|
-
collectFromCommentTree(reply, out);
|
|
28
|
-
}
|
|
29
|
-
function safeExec(cmd) {
|
|
30
|
-
try {
|
|
31
|
-
return execSync(cmd, { encoding: 'utf-8' }).trim();
|
|
32
|
-
}
|
|
33
|
-
catch {
|
|
34
|
-
return '';
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
/**
|
|
38
|
-
* Parses a Bitbucket Server remote URL into projectKey + repoSlug.
|
|
39
|
-
* Handles SSH (ssh://git@host/PROJ/repo.git), SCP-like (git@host:PROJ/repo.git),
|
|
40
|
-
* and HTTP (https://host/scm/PROJ/repo.git) formats.
|
|
41
|
-
*/
|
|
42
|
-
export function parseBitbucketRemote(remoteUrl) {
|
|
43
|
-
const sshUrl = remoteUrl.match(/ssh:\/\/[^/]+\/([^/]+)\/([^/]+?)(?:\.git)?$/);
|
|
44
|
-
if (sshUrl)
|
|
45
|
-
return { projectKey: sshUrl[1], repoSlug: sshUrl[2] };
|
|
46
|
-
const scpUrl = remoteUrl.match(/^[^@]+@[^:]+:([^/]+)\/([^/]+?)(?:\.git)?$/);
|
|
47
|
-
if (scpUrl)
|
|
48
|
-
return { projectKey: scpUrl[1], repoSlug: scpUrl[2] };
|
|
49
|
-
const httpUrl = remoteUrl.match(/\/scm\/([^/]+)\/([^/]+?)(?:\.git)?$/);
|
|
50
|
-
if (httpUrl)
|
|
51
|
-
return { projectKey: httpUrl[1], repoSlug: httpUrl[2] };
|
|
52
|
-
return null;
|
|
53
|
-
}
|
|
54
|
-
function text(t) {
|
|
55
|
-
return { content: [{ type: 'text', text: t }] };
|
|
56
|
-
}
|
|
57
|
-
function toBranchRef(branch) {
|
|
58
|
-
return branch.startsWith('refs/') ? branch : `refs/heads/${branch}`;
|
|
59
|
-
}
|
|
60
|
-
function branchDisplayId(branch) {
|
|
61
|
-
return branch.replace(/^refs\/heads\//, '');
|
|
62
|
-
}
|
|
63
|
-
function formatDate(ms) {
|
|
64
|
-
return new Date(ms).toISOString().slice(0, 10);
|
|
65
|
-
}
|
|
66
|
-
// Cap long free-text (e.g. PR descriptions) so one verbose PR does not flood
|
|
67
|
-
// the model's context. Returns the text untouched when within cap.
|
|
68
|
-
function capText(value, max) {
|
|
69
|
-
if (max <= 0 || value.length <= max)
|
|
70
|
-
return value;
|
|
71
|
-
const more = value.length - max;
|
|
72
|
-
return `${value.slice(0, max)}\n... (truncated, ${more} more chars — pass fullDescription=true for the rest)`;
|
|
73
|
-
}
|
|
74
|
-
function formatCommentThread(comment, indent = '', depth = 0) {
|
|
75
|
-
if (depth > 20)
|
|
76
|
-
return [`${indent}... (deeply nested replies omitted)`];
|
|
77
|
-
const author = comment.author?.displayName ?? comment.author?.name ?? 'Unknown';
|
|
78
|
-
const date = comment.createdDate ? ` (${formatDate(comment.createdDate)})` : '';
|
|
79
|
-
// Show only non-default flags: OPEN state, NORMAL severity and unresolved
|
|
80
|
-
// threads are the implied baseline, so badge only what deviates. Keeps the
|
|
81
|
-
// RESOLVED/BLOCKER signal while dropping repeated [OPEN/NORMAL thread=OPEN].
|
|
82
|
-
const flags = [];
|
|
83
|
-
if ((comment.state ?? 'OPEN') !== 'OPEN')
|
|
84
|
-
flags.push(comment.state);
|
|
85
|
-
if ((comment.severity ?? 'NORMAL') !== 'NORMAL')
|
|
86
|
-
flags.push(comment.severity);
|
|
87
|
-
if (comment.threadResolved === true)
|
|
88
|
-
flags.push('thread=RESOLVED');
|
|
89
|
-
const flagStr = flags.length > 0 ? ` [${flags.join('/')}]` : '';
|
|
90
|
-
const lines = [
|
|
91
|
-
`${indent}#${comment.id}${flagStr} ${author}${date} (v${comment.version})`,
|
|
92
|
-
`${indent}${comment.text}`,
|
|
93
|
-
];
|
|
94
|
-
if (comment.comments && comment.comments.length > 0) {
|
|
95
|
-
for (const reply of comment.comments) {
|
|
96
|
-
lines.push(...formatCommentThread(reply, `${indent} `, depth + 1));
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
return lines;
|
|
100
|
-
}
|
|
101
|
-
function commentMatchesState(comment, state) {
|
|
102
|
-
if (state !== 'PENDING' && (comment.severity ?? 'NORMAL') !== 'BLOCKER' && comment.threadResolved !== undefined) {
|
|
103
|
-
const threadState = comment.threadResolved ? 'RESOLVED' : 'OPEN';
|
|
104
|
-
if (threadState === state)
|
|
105
|
-
return true;
|
|
106
|
-
return (comment.comments ?? []).some((child) => commentMatchesState(child, state));
|
|
107
|
-
}
|
|
108
|
-
const currentState = comment.state ?? 'OPEN';
|
|
109
|
-
if (currentState === state)
|
|
110
|
-
return true;
|
|
111
|
-
return (comment.comments ?? []).some((child) => commentMatchesState(child, state));
|
|
112
|
-
}
|
|
113
|
-
function commentMatchesSeverity(comment, severity) {
|
|
114
|
-
if (severity === 'ALL')
|
|
115
|
-
return true;
|
|
116
|
-
const currentSeverity = comment.severity ?? 'NORMAL';
|
|
117
|
-
if (currentSeverity === severity)
|
|
118
|
-
return true;
|
|
119
|
-
return (comment.comments ?? []).some((child) => commentMatchesSeverity(child, severity));
|
|
120
|
-
}
|
|
121
|
-
function uniqueCommentsFromActivities(activities) {
|
|
122
|
-
const byId = new Map();
|
|
123
|
-
for (const activity of activities) {
|
|
124
|
-
const comment = activity.comment;
|
|
125
|
-
if (!comment)
|
|
126
|
-
continue;
|
|
127
|
-
const existing = byId.get(comment.id);
|
|
128
|
-
if (!existing) {
|
|
129
|
-
byId.set(comment.id, comment);
|
|
130
|
-
continue;
|
|
131
|
-
}
|
|
132
|
-
const commentVersion = comment.version ?? -1;
|
|
133
|
-
const existingVersion = existing.version ?? -1;
|
|
134
|
-
if (commentVersion > existingVersion) {
|
|
135
|
-
byId.set(comment.id, comment);
|
|
136
|
-
continue;
|
|
137
|
-
}
|
|
138
|
-
if (commentVersion === existingVersion) {
|
|
139
|
-
const commentUpdated = comment.updatedDate ?? comment.createdDate ?? 0;
|
|
140
|
-
const existingUpdated = existing.updatedDate ?? existing.createdDate ?? 0;
|
|
141
|
-
if (commentUpdated > existingUpdated) {
|
|
142
|
-
byId.set(comment.id, comment);
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
return Array.from(byId.values())
|
|
147
|
-
.filter((comment) => !comment.deleted)
|
|
148
|
-
.sort((a, b) => (b.createdDate ?? 0) - (a.createdDate ?? 0));
|
|
149
|
-
}
|
|
150
|
-
function pageHint(data) {
|
|
151
|
-
return data.isLastPage ? '' : ` (use start=${data.nextPageStart} for next page)`;
|
|
152
|
-
}
|
|
153
|
-
function formatDiff(data, maxChars = 8000) {
|
|
154
|
-
const parts = [];
|
|
155
|
-
if (data.fromHash && data.toHash) {
|
|
156
|
-
parts.push(`# fromHash=${data.fromHash} toHash=${data.toHash}`);
|
|
157
|
-
parts.push('# Pass these to bitbucket_comment as fromHash/toHash to anchor inline comments to this exact diff.');
|
|
158
|
-
}
|
|
159
|
-
for (const diff of data.diffs) {
|
|
160
|
-
const from = diff.source?.toString ?? '/dev/null';
|
|
161
|
-
const to = diff.destination?.toString ?? '/dev/null';
|
|
162
|
-
parts.push(`--- a/${from}\n+++ b/${to}`);
|
|
163
|
-
for (const hunk of diff.hunks ?? []) {
|
|
164
|
-
const srcLine = hunk.sourceLine ?? 0;
|
|
165
|
-
const srcSpan = hunk.sourceSpan ?? 0;
|
|
166
|
-
const dstLine = hunk.destinationLine ?? 0;
|
|
167
|
-
const dstSpan = hunk.destinationSpan ?? 0;
|
|
168
|
-
parts.push(`@@ -${srcLine},${srcSpan} +${dstLine},${dstSpan} @@`);
|
|
169
|
-
for (const segment of hunk.segments ?? []) {
|
|
170
|
-
const prefix = segment.type === 'ADDED' ? '+' : segment.type === 'REMOVED' ? '-' : ' ';
|
|
171
|
-
for (const line of segment.lines ?? []) {
|
|
172
|
-
parts.push(`${prefix}${line.line}`);
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
const result = parts.join('\n');
|
|
178
|
-
if (!result)
|
|
179
|
-
return '(no diff)';
|
|
180
|
-
if (result.length > maxChars) {
|
|
181
|
-
return result.slice(0, maxChars) + `\n\n... (truncated, ${result.length - maxChars} more chars)`;
|
|
182
|
-
}
|
|
183
|
-
return result;
|
|
184
|
-
}
|
|
185
|
-
function parseBitbucketErrorDetails(errText) {
|
|
186
|
-
const trimmed = errText.trim();
|
|
187
|
-
if (!trimmed)
|
|
188
|
-
return '';
|
|
189
|
-
try {
|
|
190
|
-
const parsed = JSON.parse(trimmed);
|
|
191
|
-
if (Array.isArray(parsed.errors) && parsed.errors.length > 0) {
|
|
192
|
-
const messages = parsed.errors
|
|
193
|
-
.map((e) => {
|
|
194
|
-
const msg = e.message?.trim() ?? '';
|
|
195
|
-
if (!msg)
|
|
196
|
-
return '';
|
|
197
|
-
return e.context ? `${e.context}: ${msg}` : msg;
|
|
198
|
-
})
|
|
199
|
-
.filter((m) => m.length > 0);
|
|
200
|
-
if (messages.length > 0)
|
|
201
|
-
return messages.join(' | ');
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
catch {
|
|
205
|
-
// Fallback to raw text below
|
|
206
|
-
}
|
|
207
|
-
return trimmed.length > 500 ? `${trimmed.slice(0, 500)}...` : trimmed;
|
|
208
|
-
}
|
|
209
|
-
function formatBitbucketError(status, method, path, details) {
|
|
210
|
-
const prefix = `Bitbucket ${status} ${method} ${path}`;
|
|
211
|
-
if (status === 400)
|
|
212
|
-
return `${prefix}. Invalid request or parameters. ${details}`.trim();
|
|
213
|
-
if (status === 401)
|
|
214
|
-
return `${prefix}. Authentication failed. Check BITBUCKET_ACCESS_TOKEN.`;
|
|
215
|
-
if (status === 403)
|
|
216
|
-
return `${prefix}. Permission denied. Check repository/project permissions for this token.`;
|
|
217
|
-
if (status === 404)
|
|
218
|
-
return `${prefix}. Resource not found. Verify project/repo/PR identifiers and access.`;
|
|
219
|
-
if (status === 409)
|
|
220
|
-
return `${prefix}. Conflict (often stale version/state). Refresh and retry. ${details}`.trim();
|
|
221
|
-
return details ? `${prefix}. ${details}` : prefix;
|
|
222
|
-
}
|
|
223
|
-
function validateCommentText(textValue) {
|
|
224
|
-
const trimmed = textValue.trim();
|
|
225
|
-
if (!trimmed) {
|
|
226
|
-
throw new Error('Bitbucket comment text must not be empty.');
|
|
227
|
-
}
|
|
228
|
-
if (EMOJI_RE.test(trimmed)) {
|
|
229
|
-
throw new Error('Bitbucket comments must not include emoji. Use concise plain text only.');
|
|
230
|
-
}
|
|
231
|
-
return trimmed;
|
|
232
|
-
}
|
|
233
|
-
function validateSuggestionPlacement(textValue) {
|
|
234
|
-
if (!textValue.includes('```suggestion'))
|
|
235
|
-
return;
|
|
236
|
-
const match = textValue.match(/```suggestion[^\n]*\n[\s\S]*?\n```/);
|
|
237
|
-
if (!match || match.index === undefined) {
|
|
238
|
-
throw new Error('Invalid suggestion block format. Use the suggestion field to post code suggestions.');
|
|
239
|
-
}
|
|
240
|
-
const trailingText = textValue.slice(match.index + match[0].length).trim();
|
|
241
|
-
if (trailingText.length > 0) {
|
|
242
|
-
throw new Error('When using ```suggestion```, do not add text after the closing code fence. Put any explanation before the suggestion block or use the suggestion field.');
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
export class BitbucketClient {
|
|
246
|
-
baseUrl;
|
|
247
|
-
headers;
|
|
248
|
-
currentUsernameCache;
|
|
249
|
-
constructor(baseUrl, token) {
|
|
250
|
-
this.baseUrl = baseUrl.replace(/\/$/, '');
|
|
251
|
-
this.headers = {
|
|
252
|
-
Authorization: `Bearer ${token}`,
|
|
253
|
-
'Content-Type': 'application/json',
|
|
254
|
-
Accept: 'application/json',
|
|
255
|
-
};
|
|
256
|
-
}
|
|
257
|
-
/** Returns the slug/username of the authenticated user via the X-AUSERNAME response header. */
|
|
258
|
-
async getCurrentUsername() {
|
|
259
|
-
if (this.currentUsernameCache)
|
|
260
|
-
return this.currentUsernameCache;
|
|
261
|
-
const url = `${this.baseUrl}/rest/api/1.0/application-properties`;
|
|
262
|
-
const res = await fetch(url, { method: 'GET', headers: this.headers });
|
|
263
|
-
const username = res.headers.get('X-AUSERNAME');
|
|
264
|
-
if (!username)
|
|
265
|
-
throw new Error('Could not determine current Bitbucket user. Check token permissions.');
|
|
266
|
-
this.currentUsernameCache = username;
|
|
267
|
-
return username;
|
|
268
|
-
}
|
|
269
|
-
async whoami() {
|
|
270
|
-
return this.getCurrentUsername();
|
|
271
|
-
}
|
|
272
|
-
/** Returns a URL-safe `/projects/.../repos/...` prefix for REST paths. */
|
|
273
|
-
rp(projectKey, repoSlug) {
|
|
274
|
-
return `/projects/${encodeURIComponent(projectKey)}/repos/${encodeURIComponent(repoSlug)}`;
|
|
275
|
-
}
|
|
276
|
-
pullRequestUrl(projectKey, repoSlug, prId, pr) {
|
|
277
|
-
const apiUrl = pr?.links?.self?.[0]?.href?.trim();
|
|
278
|
-
if (apiUrl) {
|
|
279
|
-
return apiUrl;
|
|
280
|
-
}
|
|
281
|
-
return `${this.baseUrl}/projects/${encodeURIComponent(projectKey)}/repos/${encodeURIComponent(repoSlug)}/pull-requests/${prId}`;
|
|
282
|
-
}
|
|
283
|
-
configuredHostname() {
|
|
284
|
-
try {
|
|
285
|
-
return new URL(this.baseUrl).hostname.toLowerCase();
|
|
286
|
-
}
|
|
287
|
-
catch {
|
|
288
|
-
return '';
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
remoteMatchesInstance(remote) {
|
|
292
|
-
const host = this.configuredHostname();
|
|
293
|
-
if (!host)
|
|
294
|
-
return true; // can't validate, allow
|
|
295
|
-
return remote.toLowerCase().includes(host);
|
|
296
|
-
}
|
|
297
|
-
resolveProjectAndRepo(projectKey, repoSlug) {
|
|
298
|
-
if (projectKey && repoSlug)
|
|
299
|
-
return { projectKey, repoSlug };
|
|
300
|
-
const remote = safeExec('git remote get-url origin');
|
|
301
|
-
if (remote) {
|
|
302
|
-
if (!this.remoteMatchesInstance(remote)) {
|
|
303
|
-
throw new Error(`This repo's remote does not point to your configured Bitbucket instance (${this.baseUrl}). ` +
|
|
304
|
-
`Bitbucket tools only work with repos hosted on that instance.`);
|
|
305
|
-
}
|
|
306
|
-
const parsed = parseBitbucketRemote(remote);
|
|
307
|
-
if (parsed) {
|
|
308
|
-
return {
|
|
309
|
-
projectKey: projectKey ?? parsed.projectKey,
|
|
310
|
-
repoSlug: repoSlug ?? parsed.repoSlug,
|
|
311
|
-
};
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
throw new Error('Could not determine projectKey/repoSlug — provide them explicitly or run from a directory with a Bitbucket remote');
|
|
315
|
-
}
|
|
316
|
-
async request(method, path, body) {
|
|
317
|
-
const url = `${this.baseUrl}/rest/api/1.0${path}`;
|
|
318
|
-
const opts = { method, headers: this.headers, signal: AbortSignal.timeout(30_000) };
|
|
319
|
-
if (body !== undefined)
|
|
320
|
-
opts.body = JSON.stringify(body);
|
|
321
|
-
const res = await fetch(url, opts);
|
|
322
|
-
if (!res.ok) {
|
|
323
|
-
const errText = await res.text();
|
|
324
|
-
const details = parseBitbucketErrorDetails(errText);
|
|
325
|
-
throw new Error(formatBitbucketError(res.status, method, path, details));
|
|
326
|
-
}
|
|
327
|
-
return res.status === 204 ? null : res.json();
|
|
328
|
-
}
|
|
329
|
-
async requestText(path) {
|
|
330
|
-
const url = `${this.baseUrl}/rest/api/1.0${path}`;
|
|
331
|
-
const res = await fetch(url, {
|
|
332
|
-
method: 'GET',
|
|
333
|
-
headers: { Authorization: this.headers.Authorization },
|
|
334
|
-
signal: AbortSignal.timeout(30_000),
|
|
335
|
-
});
|
|
336
|
-
if (!res.ok) {
|
|
337
|
-
const errText = await res.text();
|
|
338
|
-
const details = parseBitbucketErrorDetails(errText);
|
|
339
|
-
throw new Error(formatBitbucketError(res.status, 'GET', path, details));
|
|
340
|
-
}
|
|
341
|
-
return res.text();
|
|
342
|
-
}
|
|
343
|
-
async requestBuildStatus(method, path, body) {
|
|
344
|
-
const url = `${this.baseUrl}/rest/build-status/1.0${path}`;
|
|
345
|
-
const opts = { method, headers: this.headers, signal: AbortSignal.timeout(30_000) };
|
|
346
|
-
if (body !== undefined)
|
|
347
|
-
opts.body = JSON.stringify(body);
|
|
348
|
-
const res = await fetch(url, opts);
|
|
349
|
-
if (res.status === 404)
|
|
350
|
-
return null; // no build status yet
|
|
351
|
-
if (!res.ok) {
|
|
352
|
-
const errText = await res.text();
|
|
353
|
-
const details = parseBitbucketErrorDetails(errText);
|
|
354
|
-
throw new Error(formatBitbucketError(res.status, method, path, details));
|
|
355
|
-
}
|
|
356
|
-
return res.status === 204 ? null : res.json();
|
|
357
|
-
}
|
|
358
|
-
/**
|
|
359
|
-
* Remap a source-side line number through an interim diff.
|
|
360
|
-
*
|
|
361
|
-
* Returns the destination line if the source line survives unchanged through the diff
|
|
362
|
-
* (in a CONTEXT segment), or null if the line was modified/removed and cannot be remapped.
|
|
363
|
-
*/
|
|
364
|
-
async remapLineThroughDiff(projectKey, repoSlug, filePath, sinceHash, untilHash, sourceLine) {
|
|
365
|
-
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);
|
|
366
|
-
if (!diff || !diff.diffs?.length)
|
|
367
|
-
return sourceLine;
|
|
368
|
-
let offset = 0;
|
|
369
|
-
for (const fileDiff of diff.diffs) {
|
|
370
|
-
for (const hunk of fileDiff.hunks ?? []) {
|
|
371
|
-
const srcStart = hunk.sourceLine ?? 0;
|
|
372
|
-
const srcSpan = hunk.sourceSpan ?? 0;
|
|
373
|
-
const srcEnd = srcStart + srcSpan - 1;
|
|
374
|
-
if (srcSpan > 0 && sourceLine >= srcStart && sourceLine <= srcEnd) {
|
|
375
|
-
for (const segment of hunk.segments ?? []) {
|
|
376
|
-
if (segment.type === 'ADDED')
|
|
377
|
-
continue;
|
|
378
|
-
for (const ln of segment.lines ?? []) {
|
|
379
|
-
if (ln.source === sourceLine) {
|
|
380
|
-
if (segment.type === 'CONTEXT' && ln.destination !== undefined)
|
|
381
|
-
return ln.destination;
|
|
382
|
-
return null;
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
return null;
|
|
387
|
-
}
|
|
388
|
-
if (sourceLine > srcEnd || srcSpan === 0) {
|
|
389
|
-
const dstSpan = hunk.destinationSpan ?? 0;
|
|
390
|
-
if (srcSpan === 0 && (hunk.destinationLine ?? 0) <= sourceLine + offset) {
|
|
391
|
-
offset += dstSpan;
|
|
392
|
-
}
|
|
393
|
-
else if (sourceLine > srcEnd) {
|
|
394
|
-
offset += dstSpan - srcSpan;
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
return sourceLine + offset;
|
|
400
|
-
}
|
|
401
|
-
/** Returns true if the given remote URL belongs to this Bitbucket instance. */
|
|
402
|
-
isRemoteForThisInstance(remoteUrl) {
|
|
403
|
-
return this.remoteMatchesInstance(remoteUrl);
|
|
404
|
-
}
|
|
405
|
-
// Used internally by context tools — finds the open PR for a given source branch.
|
|
406
|
-
// Uses the `at` filter to avoid paginating all open PRs.
|
|
407
|
-
async findOpenPrForBranch(projectKey, repoSlug, branch) {
|
|
408
|
-
const atRef = encodeURIComponent(toBranchRef(branch));
|
|
409
|
-
const encodedProject = encodeURIComponent(projectKey);
|
|
410
|
-
const encodedRepo = encodeURIComponent(repoSlug);
|
|
411
|
-
const data = await this.request('GET', `/projects/${encodedProject}/repos/${encodedRepo}/pull-requests?state=OPEN&direction=OUTGOING&at=${atRef}&limit=1`);
|
|
412
|
-
return data?.values[0] ?? null;
|
|
413
|
-
}
|
|
414
|
-
// Fallback: search branches matching filterText and check each for an open PR.
|
|
415
|
-
// Used when exact branch name lookup yields no result (e.g. LLM provides a partial branch name).
|
|
416
|
-
async findOpenPrByBranchFilter(projectKey, repoSlug, filterText) {
|
|
417
|
-
const branches = await this.request('GET', `${this.rp(projectKey, repoSlug)}/branches?limit=25&filterText=${encodeURIComponent(filterText)}`);
|
|
418
|
-
if (!branches?.values?.length)
|
|
419
|
-
return null;
|
|
420
|
-
for (const b of branches.values) {
|
|
421
|
-
const pr = await this.findOpenPrForBranch(projectKey, repoSlug, b.displayId);
|
|
422
|
-
if (pr)
|
|
423
|
-
return pr;
|
|
424
|
-
}
|
|
425
|
-
return null;
|
|
426
|
-
}
|
|
427
|
-
async listRepos(args) {
|
|
428
|
-
const { limit = 50, start = 0 } = args;
|
|
429
|
-
const qs = `?limit=${limit}&start=${start}`;
|
|
430
|
-
const path = args.projectKey
|
|
431
|
-
? `/projects/${encodeURIComponent(args.projectKey)}/repos${qs}`
|
|
432
|
-
: `/repos${qs}`;
|
|
433
|
-
const data = await this.request('GET', path);
|
|
434
|
-
if (!data || data.values.length === 0)
|
|
435
|
-
return text('No repositories found.');
|
|
436
|
-
const lines = data.values.map((r, i) => `${start + i + 1}. ${r.project.key}/${r.slug} — ${r.name}`);
|
|
437
|
-
return text(`${data.values.length} repo(s)${pageHint(data)}:\n${lines.join('\n')}`);
|
|
438
|
-
}
|
|
439
|
-
async searchUsers(args) {
|
|
440
|
-
const params = new URLSearchParams();
|
|
441
|
-
if (args.query)
|
|
442
|
-
params.set('filter', args.query);
|
|
443
|
-
params.set('limit', String(args.limit ?? 25));
|
|
444
|
-
if (args.start)
|
|
445
|
-
params.set('start', String(args.start));
|
|
446
|
-
let path;
|
|
447
|
-
if (args.projectKey && args.repoSlug) {
|
|
448
|
-
path = `${this.rp(args.projectKey, args.repoSlug)}/permissions/users?${params}`;
|
|
449
|
-
}
|
|
450
|
-
else if (args.projectKey) {
|
|
451
|
-
path = `/projects/${encodeURIComponent(args.projectKey)}/permissions/users?${params}`;
|
|
452
|
-
}
|
|
453
|
-
else {
|
|
454
|
-
path = `/users?${params}`;
|
|
455
|
-
}
|
|
456
|
-
const data = await this.request('GET', path);
|
|
457
|
-
if (!data || data.values.length === 0)
|
|
458
|
-
return text('No users found.');
|
|
459
|
-
const lines = data.values.map((entry, i) => {
|
|
460
|
-
const user = entry.user ?? entry;
|
|
461
|
-
const parts = [`${i + 1}. ${user.displayName} (${user.name})`];
|
|
462
|
-
if (user.emailAddress)
|
|
463
|
-
parts.push(`— ${user.emailAddress}`);
|
|
464
|
-
if (user.active === false)
|
|
465
|
-
parts.push('[inactive]');
|
|
466
|
-
return parts.join(' ');
|
|
467
|
-
});
|
|
468
|
-
return text(`${data.values.length} user(s)${pageHint(data)}:\n${lines.join('\n')}`);
|
|
469
|
-
}
|
|
470
|
-
async searchUsersRaw(args) {
|
|
471
|
-
const params = new URLSearchParams();
|
|
472
|
-
if (args.query)
|
|
473
|
-
params.set('filter', args.query);
|
|
474
|
-
params.set('limit', String(args.limit ?? 50));
|
|
475
|
-
let path;
|
|
476
|
-
if (args.projectKey && args.repoSlug) {
|
|
477
|
-
path = `${this.rp(args.projectKey, args.repoSlug)}/permissions/users?${params}`;
|
|
478
|
-
}
|
|
479
|
-
else if (args.projectKey) {
|
|
480
|
-
path = `/projects/${encodeURIComponent(args.projectKey)}/permissions/users?${params}`;
|
|
481
|
-
}
|
|
482
|
-
else {
|
|
483
|
-
path = `/users?${params}`;
|
|
484
|
-
}
|
|
485
|
-
const data = await this.request('GET', path);
|
|
486
|
-
return (data?.values ?? []).map((entry) => entry.user ?? entry);
|
|
487
|
-
}
|
|
488
|
-
async listPullRequests(args) {
|
|
489
|
-
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
490
|
-
const { state = 'OPEN', fromBranch, text: searchText, limit = 25, start = 0 } = args;
|
|
491
|
-
const qs = new URLSearchParams({ state, limit: String(limit), start: String(start) });
|
|
492
|
-
if (fromBranch) {
|
|
493
|
-
qs.set('at', toBranchRef(fromBranch));
|
|
494
|
-
qs.set('direction', 'OUTGOING');
|
|
495
|
-
}
|
|
496
|
-
if (searchText)
|
|
497
|
-
qs.set('filterText', searchText);
|
|
498
|
-
const path = `/projects/${encodeURIComponent(projectKey)}/repos/${encodeURIComponent(repoSlug)}/pull-requests?${qs}`;
|
|
499
|
-
const data = await this.request('GET', path);
|
|
500
|
-
if (!data || data.values.length === 0)
|
|
501
|
-
return text(`No ${state} pull requests found.`);
|
|
502
|
-
const lines = data.values.map((pr) => `#${pr.id} [${pr.state}] ${pr.title} | ${pr.fromRef.displayId} → ${pr.toRef.displayId} | by ${pr.author.user.displayName}`);
|
|
503
|
-
return text(`${data.values.length} PR(s) (${state})${pageHint(data)}:\n${lines.join('\n')}`);
|
|
504
|
-
}
|
|
505
|
-
async myPrs(args) {
|
|
506
|
-
const { limit = 25, start = 0, role } = args;
|
|
507
|
-
const qs = new URLSearchParams({ limit: String(limit), start: String(start), state: 'OPEN' });
|
|
508
|
-
if (role)
|
|
509
|
-
qs.set('role', role.toUpperCase());
|
|
510
|
-
const data = await this.request('GET', `/dashboard/pull-requests?${qs}`);
|
|
511
|
-
if (!data || data.values.length === 0)
|
|
512
|
-
return text('No pull requests found.');
|
|
513
|
-
const lines = data.values.map((pr) => {
|
|
514
|
-
const repo = `${pr.toRef.repository.project.key}/${pr.toRef.repository.slug}`;
|
|
515
|
-
return `#${pr.id} [${pr.state}] ${pr.title} | ${repo} | ${pr.fromRef.displayId} → ${pr.toRef.displayId}`;
|
|
516
|
-
});
|
|
517
|
-
return text(`${data.values.length} PR(s)${pageHint(data)}:\n${lines.join('\n')}`);
|
|
518
|
-
}
|
|
519
|
-
async getPullRequest(args) {
|
|
520
|
-
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
521
|
-
const data = await this.request('GET', `/projects/${encodeURIComponent(projectKey)}/repos/${encodeURIComponent(repoSlug)}/pull-requests/${args.prId}`);
|
|
522
|
-
if (!data)
|
|
523
|
-
return text('Pull request not found.');
|
|
524
|
-
const reviewers = data.reviewers
|
|
525
|
-
.map((r) => `${r.user.displayName}${r.approved ? ' ✓' : ''}`)
|
|
526
|
-
.join(', ');
|
|
527
|
-
const lines = [
|
|
528
|
-
`PR #${data.id}: ${data.title}`,
|
|
529
|
-
`State: ${data.state}`,
|
|
530
|
-
`Author: ${data.author.user.displayName}`,
|
|
531
|
-
`Branch: ${data.fromRef.displayId} → ${data.toRef.displayId}`,
|
|
532
|
-
`Reviewers: ${reviewers || 'None'}`,
|
|
533
|
-
'',
|
|
534
|
-
'Description:',
|
|
535
|
-
data.description ?? '(no description)',
|
|
536
|
-
];
|
|
537
|
-
return text(lines.join('\n'));
|
|
538
|
-
}
|
|
539
|
-
async getPrOverview(args) {
|
|
540
|
-
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
541
|
-
const includeCommits = args.includeCommits ?? true;
|
|
542
|
-
const includeComments = args.includeComments ?? true;
|
|
543
|
-
const includeDiff = args.includeDiff ?? false;
|
|
544
|
-
const includeBuildStatus = args.includeBuildStatus ?? true;
|
|
545
|
-
const descriptionCap = args.fullDescription ? 0 : args.descriptionMaxChars ?? 2000;
|
|
546
|
-
let prId = args.prId;
|
|
547
|
-
if (prId === undefined) {
|
|
548
|
-
const branch = args.fromBranch ?? safeExec('git rev-parse --abbrev-ref HEAD');
|
|
549
|
-
if (!branch || branch === 'HEAD') {
|
|
550
|
-
throw new Error('Provide prId or fromBranch, or run from a checked-out branch.');
|
|
551
|
-
}
|
|
552
|
-
let found = await this.findOpenPrForBranch(projectKey, repoSlug, branch);
|
|
553
|
-
if (!found) {
|
|
554
|
-
// Fallback: search branches matching the input text, then check those for open PRs
|
|
555
|
-
found = await this.findOpenPrByBranchFilter(projectKey, repoSlug, branchDisplayId(branch));
|
|
556
|
-
}
|
|
557
|
-
if (!found)
|
|
558
|
-
throw new Error(`No open PR found for branch "${branchDisplayId(branch)}".`);
|
|
559
|
-
prId = found.id;
|
|
560
|
-
}
|
|
561
|
-
const pr = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${prId}`);
|
|
562
|
-
if (!pr)
|
|
563
|
-
return text('Pull request not found.');
|
|
564
|
-
const sections = [];
|
|
565
|
-
const attachmentRefs = new Map();
|
|
566
|
-
collectAttachmentRefs(pr.description, 'description', attachmentRefs);
|
|
567
|
-
const reviewers = pr.reviewers.map((r) => `${r.user.displayName}${r.approved ? ' ✓' : ''}`).join(', ');
|
|
568
|
-
const url = pr.links?.self?.[0]?.href;
|
|
569
|
-
const fromHash = pr.toRef.latestCommit;
|
|
570
|
-
const toHash = pr.fromRef.latestCommit;
|
|
571
|
-
const commitsLine = fromHash && toHash
|
|
572
|
-
? `Commits: fromHash=${fromHash} toHash=${toHash} (pass to bitbucket_comment to anchor inline comments to this exact state)`
|
|
573
|
-
: '';
|
|
574
|
-
const header = [
|
|
575
|
-
`PR #${pr.id}: ${pr.title}`,
|
|
576
|
-
`State: ${pr.state}`,
|
|
577
|
-
`Author: ${pr.author.user.displayName}`,
|
|
578
|
-
`Branch: ${pr.fromRef.displayId} → ${pr.toRef.displayId}`,
|
|
579
|
-
commitsLine,
|
|
580
|
-
`Reviewers: ${reviewers || 'None'}`,
|
|
581
|
-
url ? `URL: ${url}` : '',
|
|
582
|
-
'',
|
|
583
|
-
'Description:',
|
|
584
|
-
pr.description ? capText(pr.description, descriptionCap) : '(no description)',
|
|
585
|
-
].filter((line) => line !== '');
|
|
586
|
-
sections.push(header.join('\n'));
|
|
587
|
-
if (includeBuildStatus && pr.fromRef.latestCommit) {
|
|
588
|
-
const statuses = await this.requestBuildStatus('GET', `/commits/${pr.fromRef.latestCommit}`).catch(() => null);
|
|
589
|
-
if (statuses?.values?.length) {
|
|
590
|
-
const statusLines = statuses.values.map((s) => {
|
|
591
|
-
const icon = s.state === 'SUCCESSFUL' ? '✓' : s.state === 'FAILED' ? '✗' : '…';
|
|
592
|
-
const urlPart = s.url ? `\n URL: ${s.url}` : '';
|
|
593
|
-
return `${icon} [${s.state}] ${s.name ?? s.key}${s.description ? ` — ${s.description}` : ''}${urlPart}`;
|
|
594
|
-
});
|
|
595
|
-
sections.push(`Build status (${pr.fromRef.latestCommit.slice(0, 8)}):\n${statusLines.join('\n')}`);
|
|
596
|
-
}
|
|
597
|
-
else {
|
|
598
|
-
sections.push(`Build status: none reported for ${pr.fromRef.latestCommit.slice(0, 8)}`);
|
|
599
|
-
}
|
|
600
|
-
}
|
|
601
|
-
if (includeCommits) {
|
|
602
|
-
const commitsLimit = args.commitsLimit ?? 25;
|
|
603
|
-
const commitsStart = args.commitsStart ?? 0;
|
|
604
|
-
const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${prId}/commits?limit=${commitsLimit}&start=${commitsStart}`);
|
|
605
|
-
if (!data || data.values.length === 0) {
|
|
606
|
-
sections.push('Commits:\n(no commits found)');
|
|
607
|
-
}
|
|
608
|
-
else {
|
|
609
|
-
const lines = data.values.map((c) => `${c.displayId} ${formatDate(c.authorTimestamp)} ${c.author.name}: ${c.message.split('\n')[0]}`);
|
|
610
|
-
sections.push(`Commits (${data.values.length})${pageHint(data)}:\n${lines.join('\n')}`);
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
if (includeComments) {
|
|
614
|
-
const commentsLimit = args.commentsLimit ?? 50;
|
|
615
|
-
const commentsStart = args.commentsStart ?? 0;
|
|
616
|
-
const commentsState = args.commentsState ?? 'ALL';
|
|
617
|
-
const commentsSeverity = args.commentsSeverity ?? 'ALL';
|
|
618
|
-
if (commentsSeverity === 'BLOCKER' && commentsState === 'PENDING') {
|
|
619
|
-
throw new Error('commentsState=PENDING is not valid when commentsSeverity=BLOCKER. Use OPEN, RESOLVED, or ALL.');
|
|
620
|
-
}
|
|
621
|
-
if (commentsSeverity === 'BLOCKER') {
|
|
622
|
-
const qs = new URLSearchParams({ limit: String(commentsLimit), start: String(commentsStart) });
|
|
623
|
-
if (commentsState !== 'ALL')
|
|
624
|
-
qs.set('state', commentsState);
|
|
625
|
-
const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${prId}/blocker-comments?${qs}`);
|
|
626
|
-
if (!data || data.values.length === 0) {
|
|
627
|
-
sections.push(`Comments:\n(no ${commentsState} BLOCKER comments)`);
|
|
628
|
-
}
|
|
629
|
-
else {
|
|
630
|
-
for (const comment of data.values)
|
|
631
|
-
collectFromCommentTree(comment, attachmentRefs);
|
|
632
|
-
const blocks = data.values.flatMap((comment) => formatCommentThread(comment));
|
|
633
|
-
sections.push(`Comments (${data.values.length} BLOCKER thread(s))${pageHint(data)}:\n\n${blocks.join('\n\n')}`);
|
|
634
|
-
}
|
|
635
|
-
}
|
|
636
|
-
else {
|
|
637
|
-
const activityData = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${prId}/activities?limit=${commentsLimit}&start=${commentsStart}`);
|
|
638
|
-
const comments = uniqueCommentsFromActivities(activityData?.values ?? []).filter((comment) => {
|
|
639
|
-
const matchesState = commentsState === 'ALL' ? true : commentMatchesState(comment, commentsState);
|
|
640
|
-
return matchesState && commentMatchesSeverity(comment, commentsSeverity);
|
|
641
|
-
});
|
|
642
|
-
for (const comment of comments)
|
|
643
|
-
collectFromCommentTree(comment, attachmentRefs);
|
|
644
|
-
if (comments.length === 0) {
|
|
645
|
-
sections.push('Comments:\n(no matching comments)');
|
|
646
|
-
}
|
|
647
|
-
else {
|
|
648
|
-
const blocks = comments.flatMap((comment) => formatCommentThread(comment));
|
|
649
|
-
const paging = activityData ? pageHint(activityData) : '';
|
|
650
|
-
sections.push(`Comments (${comments.length} thread(s), newest first)${paging}:\n\n${blocks.join('\n\n')}`);
|
|
651
|
-
}
|
|
652
|
-
}
|
|
653
|
-
}
|
|
654
|
-
if (attachmentRefs.size > 0) {
|
|
655
|
-
const lines = [`Attachments referenced: ${attachmentRefs.size}`];
|
|
656
|
-
for (const ref of attachmentRefs.values()) {
|
|
657
|
-
lines.push(` #${ref.id} ${ref.filename} — in ${ref.source}`);
|
|
658
|
-
}
|
|
659
|
-
lines.push('Use bitbucket_get_attachment with attachmentId to view contents.');
|
|
660
|
-
sections.push(lines.join('\n'));
|
|
661
|
-
}
|
|
662
|
-
if (includeDiff) {
|
|
663
|
-
const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${prId}/diff`);
|
|
664
|
-
sections.push(`Diff:\n${data ? formatDiff(data, args.diffMaxChars ?? 8000) : '(no diff found)'}`);
|
|
665
|
-
}
|
|
666
|
-
return text(sections.join('\n\n'));
|
|
667
|
-
}
|
|
668
|
-
async getPrDiff(args) {
|
|
669
|
-
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
670
|
-
const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/diff`);
|
|
671
|
-
if (!data)
|
|
672
|
-
return text('No diff found.');
|
|
673
|
-
return text(formatDiff(data));
|
|
674
|
-
}
|
|
675
|
-
async getPrCommits(args) {
|
|
676
|
-
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
677
|
-
const limit = args.limit ?? 25;
|
|
678
|
-
const start = args.start ?? 0;
|
|
679
|
-
const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/commits?limit=${limit}&start=${start}`);
|
|
680
|
-
if (!data || data.values.length === 0)
|
|
681
|
-
return text('No commits found.');
|
|
682
|
-
const lines = data.values.map((c) => `${c.displayId} ${formatDate(c.authorTimestamp)} ${c.author.name}: ${c.message.split('\n')[0]}`);
|
|
683
|
-
return text(`${data.values.length} commit(s)${pageHint(data)}:\n${lines.join('\n')}`);
|
|
684
|
-
}
|
|
685
|
-
async createPullRequest(args) {
|
|
686
|
-
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
687
|
-
const sourceBranch = args.fromBranch ?? safeExec('git rev-parse --abbrev-ref HEAD');
|
|
688
|
-
if (!sourceBranch || sourceBranch === 'HEAD') {
|
|
689
|
-
throw new Error('Could not determine source branch. Provide fromBranch or run from a checked-out branch.');
|
|
690
|
-
}
|
|
691
|
-
const sourceBranchName = branchDisplayId(sourceBranch);
|
|
692
|
-
const existing = await this.findOpenPrForBranch(projectKey, repoSlug, sourceBranchName);
|
|
693
|
-
if (existing) {
|
|
694
|
-
const url = this.pullRequestUrl(projectKey, repoSlug, existing.id, existing);
|
|
695
|
-
return text(`Open PR already exists for branch "${sourceBranchName}": #${existing.id} "${existing.title}"\n${url}`);
|
|
696
|
-
}
|
|
697
|
-
const { title, description, reviewers = [] } = args;
|
|
698
|
-
const toRef = args.toBranch
|
|
699
|
-
? toBranchRef(args.toBranch)
|
|
700
|
-
: await this.getDefaultBranchRef(projectKey, repoSlug);
|
|
701
|
-
const body = {
|
|
702
|
-
title,
|
|
703
|
-
description: description ?? '',
|
|
704
|
-
fromRef: { id: toBranchRef(sourceBranch), repository: { slug: repoSlug, project: { key: projectKey } } },
|
|
705
|
-
toRef: { id: toRef, repository: { slug: repoSlug, project: { key: projectKey } } },
|
|
706
|
-
reviewers: reviewers.map((name) => ({ user: { name } })),
|
|
707
|
-
};
|
|
708
|
-
const data = await this.request('POST', `${this.rp(projectKey, repoSlug)}/pull-requests`, body);
|
|
709
|
-
if (!data)
|
|
710
|
-
return text('Pull request created.');
|
|
711
|
-
const url = this.pullRequestUrl(projectKey, repoSlug, data.id, data);
|
|
712
|
-
return text(`Created PR #${data.id}: "${data.title}"\n${url}`);
|
|
713
|
-
}
|
|
714
|
-
async updatePullRequest(args) {
|
|
715
|
-
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
716
|
-
if (args.title === undefined
|
|
717
|
-
&& args.description === undefined
|
|
718
|
-
&& args.toBranch === undefined
|
|
719
|
-
&& args.reviewers === undefined) {
|
|
720
|
-
throw new Error('At least one field is required: title, description, toBranch, or reviewers');
|
|
721
|
-
}
|
|
722
|
-
const existing = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}`);
|
|
723
|
-
if (!existing)
|
|
724
|
-
throw new Error(`PR #${args.prId} not found.`);
|
|
725
|
-
const buildBody = (pr) => {
|
|
726
|
-
const body = { version: pr.version };
|
|
727
|
-
if (args.title !== undefined)
|
|
728
|
-
body.title = args.title;
|
|
729
|
-
if (args.description !== undefined)
|
|
730
|
-
body.description = args.description;
|
|
731
|
-
if (args.toBranch !== undefined) {
|
|
732
|
-
body.toRef = {
|
|
733
|
-
id: toBranchRef(args.toBranch),
|
|
734
|
-
repository: { slug: repoSlug, project: { key: projectKey } },
|
|
735
|
-
};
|
|
736
|
-
}
|
|
737
|
-
// Always include reviewers to avoid Bitbucket clearing them on PUT.
|
|
738
|
-
// Only replace them when explicitly provided by the caller.
|
|
739
|
-
body.reviewers = args.reviewers !== undefined
|
|
740
|
-
? args.reviewers.map((name) => ({ user: { name } }))
|
|
741
|
-
: pr.reviewers.map((r) => ({ user: { name: r.user.name } }));
|
|
742
|
-
return body;
|
|
743
|
-
};
|
|
744
|
-
let updated;
|
|
745
|
-
try {
|
|
746
|
-
updated = await this.request('PUT', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}`, buildBody(existing));
|
|
747
|
-
}
|
|
748
|
-
catch (error) {
|
|
749
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
750
|
-
if (!message.includes('Bitbucket 409'))
|
|
751
|
-
throw error;
|
|
752
|
-
const latest = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}`);
|
|
753
|
-
if (!latest)
|
|
754
|
-
throw error;
|
|
755
|
-
updated = await this.request('PUT', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}`, buildBody(latest));
|
|
756
|
-
}
|
|
757
|
-
if (!updated)
|
|
758
|
-
return text(`Updated PR #${args.prId}.`);
|
|
759
|
-
const url = this.pullRequestUrl(projectKey, repoSlug, updated.id, updated);
|
|
760
|
-
return text(`Updated PR #${updated.id}: "${updated.title}" (${updated.fromRef.displayId} → ${updated.toRef.displayId}).\n${url}`);
|
|
761
|
-
}
|
|
762
|
-
async mutatePullRequest(args) {
|
|
763
|
-
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
764
|
-
const hasUpdate = args.update !== undefined && (args.update.title !== undefined
|
|
765
|
-
|| args.update.description !== undefined
|
|
766
|
-
|| args.update.toBranch !== undefined
|
|
767
|
-
|| args.update.reviewers !== undefined);
|
|
768
|
-
if (args.prId !== undefined) {
|
|
769
|
-
if (!hasUpdate) {
|
|
770
|
-
return this.getPullRequest({ projectKey, repoSlug, prId: args.prId });
|
|
771
|
-
}
|
|
772
|
-
return this.updatePullRequest({
|
|
773
|
-
projectKey,
|
|
774
|
-
repoSlug,
|
|
775
|
-
prId: args.prId,
|
|
776
|
-
...args.update,
|
|
777
|
-
});
|
|
778
|
-
}
|
|
779
|
-
const sourceBranch = args.create?.fromBranch ?? safeExec('git rev-parse --abbrev-ref HEAD');
|
|
780
|
-
if (!sourceBranch || sourceBranch === 'HEAD') {
|
|
781
|
-
if (args.create) {
|
|
782
|
-
return this.createPullRequest({
|
|
783
|
-
projectKey,
|
|
784
|
-
repoSlug,
|
|
785
|
-
title: args.create.title,
|
|
786
|
-
description: args.create.description,
|
|
787
|
-
fromBranch: args.create.fromBranch,
|
|
788
|
-
toBranch: args.create.toBranch,
|
|
789
|
-
reviewers: args.create.reviewers,
|
|
790
|
-
});
|
|
791
|
-
}
|
|
792
|
-
throw new Error('Could not determine source branch. Provide create.fromBranch or run from a checked-out branch.');
|
|
793
|
-
}
|
|
794
|
-
const existing = await this.findOpenPrForBranch(projectKey, repoSlug, sourceBranch);
|
|
795
|
-
if (existing) {
|
|
796
|
-
if (hasUpdate) {
|
|
797
|
-
return this.updatePullRequest({
|
|
798
|
-
projectKey,
|
|
799
|
-
repoSlug,
|
|
800
|
-
prId: existing.id,
|
|
801
|
-
...args.update,
|
|
802
|
-
});
|
|
803
|
-
}
|
|
804
|
-
return this.getPullRequest({ projectKey, repoSlug, prId: existing.id });
|
|
805
|
-
}
|
|
806
|
-
if (!args.create) {
|
|
807
|
-
throw new Error(`No open PR found for branch "${branchDisplayId(sourceBranch)}". Provide create to open one.`);
|
|
808
|
-
}
|
|
809
|
-
return this.createPullRequest({
|
|
810
|
-
projectKey,
|
|
811
|
-
repoSlug,
|
|
812
|
-
title: args.create.title,
|
|
813
|
-
description: args.create.description,
|
|
814
|
-
fromBranch: args.create.fromBranch,
|
|
815
|
-
toBranch: args.create.toBranch,
|
|
816
|
-
reviewers: args.create.reviewers,
|
|
817
|
-
});
|
|
818
|
-
}
|
|
819
|
-
async approvePr(args) {
|
|
820
|
-
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
821
|
-
const data = await this.request('POST', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/approve`);
|
|
822
|
-
const url = this.pullRequestUrl(projectKey, repoSlug, args.prId);
|
|
823
|
-
if (!data)
|
|
824
|
-
return text(`Approved PR #${args.prId}.\n${url}`);
|
|
825
|
-
return text(`Approved PR #${args.prId} as ${data.user.displayName}.\n${url}`);
|
|
826
|
-
}
|
|
827
|
-
async unapprovePr(args) {
|
|
828
|
-
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
829
|
-
await this.request('DELETE', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/approve`);
|
|
830
|
-
return text(`Approval removed from PR #${args.prId}.\n${this.pullRequestUrl(projectKey, repoSlug, args.prId)}`);
|
|
831
|
-
}
|
|
832
|
-
async needsWorkPr(args) {
|
|
833
|
-
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
834
|
-
const userSlug = await this.getCurrentUsername();
|
|
835
|
-
const data = await this.request('PUT', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/participants/${encodeURIComponent(userSlug)}`, { user: { name: userSlug }, approved: false, status: 'NEEDS_WORK' });
|
|
836
|
-
const url = this.pullRequestUrl(projectKey, repoSlug, args.prId);
|
|
837
|
-
if (!data)
|
|
838
|
-
return text(`Marked PR #${args.prId} as Needs work.\n${url}`);
|
|
839
|
-
return text(`Marked PR #${args.prId} as Needs work as ${data.user.displayName}.\n${url}`);
|
|
840
|
-
}
|
|
841
|
-
async declinePr(args) {
|
|
842
|
-
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
843
|
-
const pr = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}`);
|
|
844
|
-
if (!pr)
|
|
845
|
-
throw new Error(`PR #${args.prId} not found.`);
|
|
846
|
-
const body = { version: pr.version };
|
|
847
|
-
if (args.message)
|
|
848
|
-
body.message = args.message;
|
|
849
|
-
const data = await this.request('POST', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/decline`, body);
|
|
850
|
-
if (!data)
|
|
851
|
-
return text(`Declined PR #${args.prId}.\n${this.pullRequestUrl(projectKey, repoSlug, args.prId)}`);
|
|
852
|
-
return text(`Declined PR #${data.id}: "${data.title}".\n${this.pullRequestUrl(projectKey, repoSlug, data.id, data)}`);
|
|
853
|
-
}
|
|
854
|
-
async mergePr(args) {
|
|
855
|
-
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
856
|
-
const pr = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}`);
|
|
857
|
-
if (!pr)
|
|
858
|
-
throw new Error(`PR #${args.prId} not found.`);
|
|
859
|
-
const body = { version: pr.version };
|
|
860
|
-
if (args.mergeStrategy)
|
|
861
|
-
body.strategyId = args.mergeStrategy;
|
|
862
|
-
if (args.message)
|
|
863
|
-
body.message = args.message;
|
|
864
|
-
const data = await this.request('POST', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/merge`, body);
|
|
865
|
-
if (!data)
|
|
866
|
-
return text(`Merged PR #${args.prId}.\n${this.pullRequestUrl(projectKey, repoSlug, args.prId)}`);
|
|
867
|
-
return text(`Merged PR #${data.id}: "${data.title}" (${data.fromRef.displayId} → ${data.toRef.displayId}).\n${this.pullRequestUrl(projectKey, repoSlug, data.id, data)}`);
|
|
868
|
-
}
|
|
869
|
-
async getDefaultBranchRef(projectKey, repoSlug) {
|
|
870
|
-
const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/default-branch`);
|
|
871
|
-
if (data?.displayId)
|
|
872
|
-
return `refs/heads/${data.displayId}`;
|
|
873
|
-
// Fallback: detect from local git
|
|
874
|
-
const head = safeExec('git rev-parse --abbrev-ref origin/HEAD');
|
|
875
|
-
if (head.startsWith('origin/'))
|
|
876
|
-
return `refs/heads/${head.slice('origin/'.length)}`;
|
|
877
|
-
return 'refs/heads/master';
|
|
878
|
-
}
|
|
879
|
-
async createBranch(args) {
|
|
880
|
-
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
881
|
-
const startPoint = args.startPoint ?? await this.getDefaultBranchRef(projectKey, repoSlug);
|
|
882
|
-
const data = await this.request('POST', `${this.rp(projectKey, repoSlug)}/branches`, { name: args.branchName, startPoint });
|
|
883
|
-
if (!data)
|
|
884
|
-
return text(`Branch "${args.branchName}" created.`);
|
|
885
|
-
return text(`Created branch "${data.displayId}" at ${data.latestCommit.slice(0, 8)} in ${projectKey}/${repoSlug}.`);
|
|
886
|
-
}
|
|
887
|
-
async getBranches(args) {
|
|
888
|
-
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
889
|
-
const qs = new URLSearchParams({ limit: String(args.limit ?? 25), start: String(args.start ?? 0) });
|
|
890
|
-
if (args.filter)
|
|
891
|
-
qs.set('filterText', args.filter);
|
|
892
|
-
const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/branches?${qs}`);
|
|
893
|
-
if (!data || data.values.length === 0)
|
|
894
|
-
return text('No branches found.');
|
|
895
|
-
const lines = data.values.map((b) => `${b.displayId}${b.isDefault ? ' (default)' : ''} — ${b.latestCommit.slice(0, 8)}`);
|
|
896
|
-
return text(`${data.values.length} branch(es)${pageHint(data)}:\n${lines.join('\n')}`);
|
|
897
|
-
}
|
|
898
|
-
async getFile(args) {
|
|
899
|
-
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
900
|
-
const qs = args.ref ? `?at=${encodeURIComponent(args.ref)}` : '';
|
|
901
|
-
const encodedPath = args.path.split('/').map(encodeURIComponent).join('/');
|
|
902
|
-
const content = await this.requestText(`${this.rp(projectKey, repoSlug)}/raw/${encodedPath}${qs}`);
|
|
903
|
-
const MAX_CHARS = 10000;
|
|
904
|
-
if (content.length > MAX_CHARS) {
|
|
905
|
-
return text(content.slice(0, MAX_CHARS) + `\n\n... (truncated, ${content.length - MAX_CHARS} more chars)`);
|
|
906
|
-
}
|
|
907
|
-
return text(content);
|
|
908
|
-
}
|
|
909
|
-
async getAttachment(args) {
|
|
910
|
-
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
911
|
-
const id = String(args.attachmentId ?? '').trim();
|
|
912
|
-
if (!id)
|
|
913
|
-
throw new Error('attachmentId is required.');
|
|
914
|
-
const url = `${this.baseUrl}/rest/api/1.0${this.rp(projectKey, repoSlug)}/attachments/${encodeURIComponent(id)}`;
|
|
915
|
-
const fetchTimeoutMs = args.saveTo ? 300_000 : 60_000;
|
|
916
|
-
const res = await fetch(url, {
|
|
917
|
-
method: 'GET',
|
|
918
|
-
headers: { Authorization: this.headers.Authorization },
|
|
919
|
-
signal: AbortSignal.timeout(fetchTimeoutMs),
|
|
920
|
-
});
|
|
921
|
-
if (!res.ok) {
|
|
922
|
-
const errText = await res.text();
|
|
923
|
-
throw new Error(formatBitbucketError(res.status, 'GET', `${this.rp(projectKey, repoSlug)}/attachments/${id}`, parseBitbucketErrorDetails(errText)));
|
|
924
|
-
}
|
|
925
|
-
const contentDisposition = res.headers.get('content-disposition') ?? '';
|
|
926
|
-
const filenameMatch = contentDisposition.match(/filename\*?=(?:UTF-8'')?"?([^";]+)"?/i);
|
|
927
|
-
const filename = filenameMatch ? decodeURIComponent(filenameMatch[1]) : `attachment-${id}`;
|
|
928
|
-
const mimeType = (res.headers.get('content-type') ?? 'application/octet-stream').split(';')[0].trim();
|
|
929
|
-
const declaredLength = parseInt(res.headers.get('content-length') ?? '0', 10);
|
|
930
|
-
// saveTo path: stream directly to disk so we never buffer the whole attachment in memory.
|
|
931
|
-
if (args.saveTo) {
|
|
932
|
-
const path = resolvePath(args.saveTo);
|
|
933
|
-
if (!res.body)
|
|
934
|
-
throw new Error(`Attachment #${id} response has no body.`);
|
|
935
|
-
await pipeline(Readable.fromWeb(res.body), createWriteStream(path));
|
|
936
|
-
const sizeLabel = declaredLength > 0 ? formatBytes(declaredLength) : 'unknown size';
|
|
937
|
-
return { content: [{ type: 'text', text: `Saved attachment #${id} (${filename} — ${mimeType}, ${sizeLabel}) to ${path}` }] };
|
|
938
|
-
}
|
|
939
|
-
if (declaredLength > MAX_VIDEO_SOURCE_BYTES) {
|
|
940
|
-
try {
|
|
941
|
-
await res.body?.cancel();
|
|
942
|
-
}
|
|
943
|
-
catch { /* ignore */ }
|
|
944
|
-
throw new Error(`Attachment #${id} is ${formatBytes(declaredLength)}, exceeds the ${formatBytes(MAX_VIDEO_SOURCE_BYTES)} inline cap. Pass saveTo=/absolute/path to stream it to disk.`);
|
|
945
|
-
}
|
|
946
|
-
const buffer = Buffer.from(await res.arrayBuffer());
|
|
947
|
-
if (buffer.length > MAX_VIDEO_SOURCE_BYTES) {
|
|
948
|
-
throw new Error(`Attachment #${id} downloaded ${formatBytes(buffer.length)}, exceeds the ${formatBytes(MAX_VIDEO_SOURCE_BYTES)} inline cap. Pass saveTo=/absolute/path to stream it to disk.`);
|
|
949
|
-
}
|
|
950
|
-
return buildAttachmentResult({
|
|
951
|
-
id,
|
|
952
|
-
filename,
|
|
953
|
-
mimeType,
|
|
954
|
-
buffer,
|
|
955
|
-
maxDimension: args.maxDimension,
|
|
956
|
-
quality: args.quality,
|
|
957
|
-
frames: args.frames,
|
|
958
|
-
start: args.start,
|
|
959
|
-
end: args.end,
|
|
960
|
-
mode: args.mode,
|
|
961
|
-
sceneThreshold: args.sceneThreshold,
|
|
962
|
-
});
|
|
963
|
-
}
|
|
964
|
-
async fetchFileText(projectKey, repoSlug, filePath) {
|
|
965
|
-
try {
|
|
966
|
-
const encoded = filePath.split('/').map(encodeURIComponent).join('/');
|
|
967
|
-
const content = await this.requestText(`${this.rp(projectKey, repoSlug)}/raw/${encoded}`);
|
|
968
|
-
return content;
|
|
969
|
-
}
|
|
970
|
-
catch {
|
|
971
|
-
return null;
|
|
972
|
-
}
|
|
973
|
-
}
|
|
974
|
-
async getPrComments(args) {
|
|
975
|
-
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
976
|
-
const limit = args.limit ?? 50;
|
|
977
|
-
const start = args.start ?? 0;
|
|
978
|
-
const severity = args.severity ?? 'ALL';
|
|
979
|
-
const state = args.state ?? (args.countOnly ? undefined : 'OPEN');
|
|
980
|
-
if (args.countOnly) {
|
|
981
|
-
if (severity !== 'BLOCKER') {
|
|
982
|
-
throw new Error('countOnly is supported only for BLOCKER severity. Set severity="BLOCKER".');
|
|
983
|
-
}
|
|
984
|
-
if (state === 'PENDING') {
|
|
985
|
-
throw new Error('PENDING is not valid for blocker comment counts. Use OPEN or RESOLVED.');
|
|
986
|
-
}
|
|
987
|
-
const qs = new URLSearchParams({ count: 'true' });
|
|
988
|
-
if (state)
|
|
989
|
-
qs.set('state', state);
|
|
990
|
-
const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/blocker-comments?${qs}`);
|
|
991
|
-
let open = data?.open ?? 0;
|
|
992
|
-
let resolved = data?.resolved ?? 0;
|
|
993
|
-
if ((open === 0 && resolved === 0) && data?.values && data.values.length > 0) {
|
|
994
|
-
for (const v of data.values) {
|
|
995
|
-
if ((v.state ?? '').toUpperCase() === 'OPEN')
|
|
996
|
-
open = v.count ?? open;
|
|
997
|
-
if ((v.state ?? '').toUpperCase() === 'RESOLVED')
|
|
998
|
-
resolved = v.count ?? resolved;
|
|
999
|
-
}
|
|
1000
|
-
}
|
|
1001
|
-
if (state === 'OPEN')
|
|
1002
|
-
return text(`PR #${args.prId} BLOCKER comments: OPEN=${open}`);
|
|
1003
|
-
if (state === 'RESOLVED')
|
|
1004
|
-
return text(`PR #${args.prId} BLOCKER comments: RESOLVED=${resolved}`);
|
|
1005
|
-
return text(`PR #${args.prId} BLOCKER comments: OPEN=${open}, RESOLVED=${resolved}`);
|
|
1006
|
-
}
|
|
1007
|
-
if (severity === 'BLOCKER' && !args.path) {
|
|
1008
|
-
if (state === 'PENDING') {
|
|
1009
|
-
throw new Error('PENDING is not valid for blocker comments. Use OPEN or RESOLVED.');
|
|
1010
|
-
}
|
|
1011
|
-
const qs = new URLSearchParams({ limit: String(limit), start: String(start) });
|
|
1012
|
-
if (state)
|
|
1013
|
-
qs.set('state', state);
|
|
1014
|
-
const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/blocker-comments?${qs}`);
|
|
1015
|
-
if (!data || data.values.length === 0) {
|
|
1016
|
-
return text(`No ${state ?? 'OPEN/RESOLVED'} BLOCKER comments on PR #${args.prId}.`);
|
|
1017
|
-
}
|
|
1018
|
-
const blocks = data.values.flatMap((comment) => formatCommentThread(comment));
|
|
1019
|
-
return text(`${data.values.length} ${state ?? 'OPEN/RESOLVED'} BLOCKER comment thread(s) on PR #${args.prId}${pageHint(data)}:\n\n${blocks.join('\n\n')}`);
|
|
1020
|
-
}
|
|
1021
|
-
if (args.path) {
|
|
1022
|
-
const qs = new URLSearchParams({
|
|
1023
|
-
limit: String(limit),
|
|
1024
|
-
start: String(start),
|
|
1025
|
-
path: args.path,
|
|
1026
|
-
});
|
|
1027
|
-
if (state)
|
|
1028
|
-
qs.set('state', state);
|
|
1029
|
-
const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/comments?${qs}`);
|
|
1030
|
-
const filtered = (data?.values ?? []).filter((comment) => {
|
|
1031
|
-
const matchesState = state ? commentMatchesState(comment, state) : true;
|
|
1032
|
-
return matchesState && commentMatchesSeverity(comment, severity);
|
|
1033
|
-
});
|
|
1034
|
-
if (filtered.length === 0) {
|
|
1035
|
-
return text(`No matching comments on PR #${args.prId} for path ${args.path}.`);
|
|
1036
|
-
}
|
|
1037
|
-
const blocks = filtered.flatMap((comment) => formatCommentThread(comment));
|
|
1038
|
-
const paging = data ? pageHint(data) : '';
|
|
1039
|
-
return text(`${filtered.length} comment thread(s) on PR #${args.prId} for ${args.path}${paging}:\n\n${blocks.join('\n\n')}`);
|
|
1040
|
-
}
|
|
1041
|
-
const activityData = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/activities?limit=${limit}&start=${start}`);
|
|
1042
|
-
const comments = uniqueCommentsFromActivities(activityData?.values ?? []).filter((comment) => {
|
|
1043
|
-
const matchesState = state ? commentMatchesState(comment, state) : true;
|
|
1044
|
-
return matchesState && commentMatchesSeverity(comment, severity);
|
|
1045
|
-
});
|
|
1046
|
-
if (comments.length === 0) {
|
|
1047
|
-
return text(`No matching comments on PR #${args.prId}.`);
|
|
1048
|
-
}
|
|
1049
|
-
const blocks = comments.flatMap((comment) => formatCommentThread(comment));
|
|
1050
|
-
const paging = activityData ? pageHint(activityData) : '';
|
|
1051
|
-
return text(`${comments.length} comment thread(s) on PR #${args.prId}${paging}:\n\n${blocks.join('\n\n')}`);
|
|
1052
|
-
}
|
|
1053
|
-
async addPrComment(args) {
|
|
1054
|
-
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
1055
|
-
const replyToCommentId = args.commentId;
|
|
1056
|
-
if (replyToCommentId !== undefined
|
|
1057
|
-
&& (args.filePath !== undefined
|
|
1058
|
-
|| args.srcPath !== undefined
|
|
1059
|
-
|| args.line !== undefined
|
|
1060
|
-
|| args.lineType !== undefined
|
|
1061
|
-
|| args.fileType !== undefined
|
|
1062
|
-
|| args.multilineStartLine !== undefined
|
|
1063
|
-
|| args.multilineStartLineType !== undefined
|
|
1064
|
-
|| args.fromHash !== undefined
|
|
1065
|
-
|| args.toHash !== undefined)) {
|
|
1066
|
-
throw new Error('Replies must target an existing comment thread only. Omit filePath/line and other anchor fields when replying.');
|
|
1067
|
-
}
|
|
1068
|
-
if (replyToCommentId !== undefined) {
|
|
1069
|
-
const parent = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/comments/${replyToCommentId}`).catch(() => null);
|
|
1070
|
-
if (parent) {
|
|
1071
|
-
const me = await this.getCurrentUsername();
|
|
1072
|
-
const existingReply = (parent.comments ?? []).find((r) => !r.deleted && r.author?.name === me);
|
|
1073
|
-
if (existingReply) {
|
|
1074
|
-
throw new Error(`You already replied to comment #${replyToCommentId} (your reply is #${existingReply.id}). Never post a second reply on the same thread — update your existing reply instead: action=update commentId=${existingReply.id}.`);
|
|
1075
|
-
}
|
|
1076
|
-
}
|
|
1077
|
-
}
|
|
1078
|
-
if (args.text === undefined && args.suggestion === undefined) {
|
|
1079
|
-
throw new Error('Either text or suggestion is required when adding a comment.');
|
|
1080
|
-
}
|
|
1081
|
-
let commentText = args.text ?? '';
|
|
1082
|
-
if (args.suggestion !== undefined) {
|
|
1083
|
-
const suggestion = args.suggestion.trim();
|
|
1084
|
-
if (!suggestion) {
|
|
1085
|
-
throw new Error('suggestion must not be empty.');
|
|
1086
|
-
}
|
|
1087
|
-
const suggestionBlock = `\`\`\`suggestion\n${suggestion}\n\`\`\``;
|
|
1088
|
-
const prefix = (args.text ?? '').trim();
|
|
1089
|
-
commentText = prefix ? `${prefix}\n\n${suggestionBlock}` : suggestionBlock;
|
|
1090
|
-
}
|
|
1091
|
-
else {
|
|
1092
|
-
validateSuggestionPlacement(commentText);
|
|
1093
|
-
}
|
|
1094
|
-
// Adding a comment must never create a task. In Bitbucket Server a comment with
|
|
1095
|
-
// severity=BLOCKER renders as a PR task; tasks are created only via
|
|
1096
|
-
// bitbucket_pr_tasks, and only when explicitly requested. Reject the BLOCKER
|
|
1097
|
-
// severity here so an inline review comment can never silently become a task.
|
|
1098
|
-
if (args.severity === 'BLOCKER') {
|
|
1099
|
-
throw new Error('Adding a comment never creates a task. Omit severity (comments post as NORMAL). To create a task, use bitbucket_pr_tasks (action=create) — only when the user explicitly asks for one.');
|
|
1100
|
-
}
|
|
1101
|
-
const body = { text: validateCommentText(commentText) };
|
|
1102
|
-
if (args.severity)
|
|
1103
|
-
body.severity = args.severity;
|
|
1104
|
-
if (replyToCommentId !== undefined)
|
|
1105
|
-
body.parent = { id: replyToCommentId };
|
|
1106
|
-
let inlineAnchor;
|
|
1107
|
-
let usedFallbackHashes = false;
|
|
1108
|
-
let currentToHash;
|
|
1109
|
-
let currentFromHash;
|
|
1110
|
-
let remapNote;
|
|
1111
|
-
if (args.filePath !== undefined || args.line !== undefined) {
|
|
1112
|
-
if (args.filePath === undefined || args.line === undefined) {
|
|
1113
|
-
throw new Error('filePath and line must be provided together for inline comments.');
|
|
1114
|
-
}
|
|
1115
|
-
inlineAnchor = {
|
|
1116
|
-
diffType: 'EFFECTIVE',
|
|
1117
|
-
fileType: args.fileType ?? 'TO',
|
|
1118
|
-
line: args.line,
|
|
1119
|
-
lineType: args.lineType ?? 'ADDED',
|
|
1120
|
-
path: args.filePath,
|
|
1121
|
-
};
|
|
1122
|
-
if (args.srcPath !== undefined) {
|
|
1123
|
-
inlineAnchor.srcPath = args.srcPath;
|
|
1124
|
-
}
|
|
1125
|
-
const pr = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}`).catch(() => null);
|
|
1126
|
-
currentToHash = pr?.fromRef.latestCommit;
|
|
1127
|
-
currentFromHash = pr?.toRef.latestCommit;
|
|
1128
|
-
let fromHash = args.fromHash ?? currentFromHash;
|
|
1129
|
-
let toHash = args.toHash ?? currentToHash;
|
|
1130
|
-
usedFallbackHashes = args.fromHash === undefined && args.toHash === undefined;
|
|
1131
|
-
const fileType = args.fileType ?? 'TO';
|
|
1132
|
-
const reviewedToHash = args.toHash;
|
|
1133
|
-
if (reviewedToHash
|
|
1134
|
-
&& currentToHash
|
|
1135
|
-
&& reviewedToHash !== currentToHash
|
|
1136
|
-
&& fileType === 'TO') {
|
|
1137
|
-
const remappedLine = await this.remapLineThroughDiff(projectKey, repoSlug, args.filePath, reviewedToHash, currentToHash, args.line);
|
|
1138
|
-
let remappedMultilineStart;
|
|
1139
|
-
if (args.multilineStartLine !== undefined) {
|
|
1140
|
-
remappedMultilineStart = await this.remapLineThroughDiff(projectKey, repoSlug, args.filePath, reviewedToHash, currentToHash, args.multilineStartLine);
|
|
1141
|
-
}
|
|
1142
|
-
const lineOk = remappedLine !== null;
|
|
1143
|
-
const multilineOk = remappedMultilineStart === undefined || remappedMultilineStart !== null;
|
|
1144
|
-
if (lineOk && multilineOk) {
|
|
1145
|
-
if (remappedLine !== args.line) {
|
|
1146
|
-
remapNote = `Reviewed line ${args.line} remapped to ${remappedLine} on current head ${currentToHash.slice(0, 8)}.`;
|
|
1147
|
-
}
|
|
1148
|
-
inlineAnchor.line = remappedLine;
|
|
1149
|
-
if (remappedMultilineStart !== undefined && remappedMultilineStart !== null) {
|
|
1150
|
-
inlineAnchor.multilineStartLine = remappedMultilineStart;
|
|
1151
|
-
}
|
|
1152
|
-
toHash = currentToHash;
|
|
1153
|
-
if (!args.fromHash)
|
|
1154
|
-
fromHash = currentFromHash;
|
|
1155
|
-
}
|
|
1156
|
-
else {
|
|
1157
|
-
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).`;
|
|
1158
|
-
}
|
|
1159
|
-
}
|
|
1160
|
-
if (fromHash && toHash) {
|
|
1161
|
-
inlineAnchor.fromHash = fromHash;
|
|
1162
|
-
inlineAnchor.toHash = toHash;
|
|
1163
|
-
}
|
|
1164
|
-
if (args.multilineStartLine !== undefined && inlineAnchor.multilineStartLine === undefined) {
|
|
1165
|
-
inlineAnchor.multilineStartLine = args.multilineStartLine;
|
|
1166
|
-
}
|
|
1167
|
-
if (inlineAnchor.multilineStartLine !== undefined) {
|
|
1168
|
-
inlineAnchor.multilineStartLineType = args.multilineStartLineType ?? args.lineType ?? 'ADDED';
|
|
1169
|
-
}
|
|
1170
|
-
body.anchor = inlineAnchor;
|
|
1171
|
-
}
|
|
1172
|
-
let created;
|
|
1173
|
-
try {
|
|
1174
|
-
created = await this.request('POST', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/comments`, body);
|
|
1175
|
-
}
|
|
1176
|
-
catch (error) {
|
|
1177
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
1178
|
-
if (!inlineAnchor || !message.includes('Bitbucket 409') || !('fromHash' in inlineAnchor) || !('toHash' in inlineAnchor)) {
|
|
1179
|
-
throw error;
|
|
1180
|
-
}
|
|
1181
|
-
const { fromHash: _fromHash, toHash: _toHash, ...anchorWithoutHashes } = inlineAnchor;
|
|
1182
|
-
body.anchor = anchorWithoutHashes;
|
|
1183
|
-
created = await this.request('POST', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/comments`, body);
|
|
1184
|
-
}
|
|
1185
|
-
if (!created)
|
|
1186
|
-
return text(`Comment added to PR #${args.prId}.`);
|
|
1187
|
-
if (replyToCommentId !== undefined) {
|
|
1188
|
-
return text(`Reply #${created.id} added to comment #${replyToCommentId} on PR #${args.prId}.`);
|
|
1189
|
-
}
|
|
1190
|
-
const location = args.filePath && args.line ? ` on ${args.filePath}:${args.line}` : '';
|
|
1191
|
-
const warnings = [];
|
|
1192
|
-
if (inlineAnchor && usedFallbackHashes) {
|
|
1193
|
-
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.');
|
|
1194
|
-
}
|
|
1195
|
-
if (remapNote)
|
|
1196
|
-
warnings.push(remapNote);
|
|
1197
|
-
const warnSuffix = warnings.length ? `\n\nNote: ${warnings.join(' ')}` : '';
|
|
1198
|
-
return text(`Comment #${created.id} added to PR #${args.prId}${location}.${warnSuffix}`);
|
|
1199
|
-
}
|
|
1200
|
-
async updatePrComment(args) {
|
|
1201
|
-
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
1202
|
-
if (!args.text && !args.state && !args.severity && args.threadResolved === undefined) {
|
|
1203
|
-
throw new Error('At least one field is required: text, state, severity, or threadResolved');
|
|
1204
|
-
}
|
|
1205
|
-
const current = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/comments/${args.commentId}`);
|
|
1206
|
-
if (!current)
|
|
1207
|
-
throw new Error(`Comment #${args.commentId} not found.`);
|
|
1208
|
-
const currentSeverity = current.severity ?? 'NORMAL';
|
|
1209
|
-
const targetSeverity = args.severity ?? currentSeverity;
|
|
1210
|
-
if (args.state && targetSeverity !== 'BLOCKER') {
|
|
1211
|
-
throw new Error('state is only supported for BLOCKER comments (tasks). Use threadResolved for normal comment threads.');
|
|
1212
|
-
}
|
|
1213
|
-
if (args.threadResolved !== undefined && targetSeverity === 'BLOCKER') {
|
|
1214
|
-
throw new Error('threadResolved is only supported for normal comments. Use state for BLOCKER comment tasks.');
|
|
1215
|
-
}
|
|
1216
|
-
const commentPath = (targetSeverity === 'BLOCKER' || current.severity === 'BLOCKER')
|
|
1217
|
-
? `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/blocker-comments/${args.commentId}`
|
|
1218
|
-
: `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/comments/${args.commentId}`;
|
|
1219
|
-
const buildBody = (version) => {
|
|
1220
|
-
const body = { version };
|
|
1221
|
-
if (args.text !== undefined)
|
|
1222
|
-
body.text = validateCommentText(args.text);
|
|
1223
|
-
if (args.state && targetSeverity === 'BLOCKER')
|
|
1224
|
-
body.state = args.state;
|
|
1225
|
-
if (args.severity)
|
|
1226
|
-
body.severity = args.severity;
|
|
1227
|
-
if (args.threadResolved !== undefined) {
|
|
1228
|
-
body.threadResolved = args.threadResolved;
|
|
1229
|
-
}
|
|
1230
|
-
return body;
|
|
1231
|
-
};
|
|
1232
|
-
let updated;
|
|
1233
|
-
try {
|
|
1234
|
-
updated = await this.request('PUT', commentPath, buildBody(current.version));
|
|
1235
|
-
}
|
|
1236
|
-
catch (error) {
|
|
1237
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
1238
|
-
if (!message.includes('Bitbucket 409'))
|
|
1239
|
-
throw error;
|
|
1240
|
-
const latest = await this.request('GET', commentPath);
|
|
1241
|
-
if (!latest)
|
|
1242
|
-
throw error;
|
|
1243
|
-
updated = await this.request('PUT', commentPath, buildBody(latest.version));
|
|
1244
|
-
}
|
|
1245
|
-
if (!updated)
|
|
1246
|
-
return text(`Comment #${args.commentId} updated.`);
|
|
1247
|
-
const state = updated.state ?? current.state ?? 'OPEN';
|
|
1248
|
-
const severity = updated.severity ?? current.severity ?? 'NORMAL';
|
|
1249
|
-
const threadResolved = updated.threadResolved ?? current.threadResolved;
|
|
1250
|
-
const threadStatus = threadResolved === undefined ? '' : `, thread=${threadResolved ? 'RESOLVED' : 'OPEN'}`;
|
|
1251
|
-
return text(`Comment #${updated.id} updated (${state}/${severity}${threadStatus}).`);
|
|
1252
|
-
}
|
|
1253
|
-
async deletePrComment(args) {
|
|
1254
|
-
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
1255
|
-
const current = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/comments/${args.commentId}`);
|
|
1256
|
-
if (!current)
|
|
1257
|
-
throw new Error(`Comment #${args.commentId} not found.`);
|
|
1258
|
-
const commentPath = current.severity === 'BLOCKER'
|
|
1259
|
-
? `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/blocker-comments/${args.commentId}`
|
|
1260
|
-
: `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/comments/${args.commentId}`;
|
|
1261
|
-
const path = `${commentPath}?version=${current.version}`;
|
|
1262
|
-
await this.request('DELETE', path);
|
|
1263
|
-
return text(`Comment #${args.commentId} deleted from PR #${args.prId}.`);
|
|
1264
|
-
}
|
|
1265
|
-
async getPrTasks(args) {
|
|
1266
|
-
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
1267
|
-
const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/tasks`);
|
|
1268
|
-
if (!data || data.values.length === 0)
|
|
1269
|
-
return text(`No tasks on PR #${args.prId}.`);
|
|
1270
|
-
const lines = data.values.map((t) => {
|
|
1271
|
-
const author = t.author?.displayName ?? t.author?.name ?? 'Unknown';
|
|
1272
|
-
const date = t.createdDate ? ` (${formatDate(t.createdDate)})` : '';
|
|
1273
|
-
const anchor = t.anchor?.id ? ` [on comment #${t.anchor.id}]` : '';
|
|
1274
|
-
return `#${t.id} [${t.state}] ${author}${date}${anchor}: ${t.text}`;
|
|
1275
|
-
});
|
|
1276
|
-
const open = data.values.filter((t) => t.state === 'OPEN').length;
|
|
1277
|
-
return text(`${data.values.length} task(s) on PR #${args.prId} (${open} open)${pageHint(data)}:\n${lines.join('\n')}`);
|
|
1278
|
-
}
|
|
1279
|
-
async mutatePrTask(args) {
|
|
1280
|
-
this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
1281
|
-
if (args.action === 'create') {
|
|
1282
|
-
if (!args.text)
|
|
1283
|
-
throw new Error('text is required to create a task.');
|
|
1284
|
-
const body = { text: args.text };
|
|
1285
|
-
if (args.commentId !== undefined) {
|
|
1286
|
-
body.anchor = { id: args.commentId, type: 'COMMENT' };
|
|
1287
|
-
}
|
|
1288
|
-
else if (args.prId !== undefined) {
|
|
1289
|
-
body.anchor = { id: args.prId, type: 'PULL_REQUEST' };
|
|
1290
|
-
}
|
|
1291
|
-
else {
|
|
1292
|
-
throw new Error('Provide prId or commentId to anchor the task.');
|
|
1293
|
-
}
|
|
1294
|
-
const created = await this.request('POST', '/tasks', body);
|
|
1295
|
-
if (!created)
|
|
1296
|
-
return text('Task created.');
|
|
1297
|
-
return text(`Task #${created.id} created: "${created.text}"`);
|
|
1298
|
-
}
|
|
1299
|
-
if (!args.taskId)
|
|
1300
|
-
throw new Error('taskId is required for resolve/reopen/delete.');
|
|
1301
|
-
if (args.action === 'delete') {
|
|
1302
|
-
const task = await this.request('GET', `/tasks/${args.taskId}`);
|
|
1303
|
-
if (!task)
|
|
1304
|
-
throw new Error(`Task #${args.taskId} not found.`);
|
|
1305
|
-
// Verify the task belongs to the given PR (when anchor is a direct PR anchor)
|
|
1306
|
-
if (args.prId !== undefined && task.anchor?.type === 'PULL_REQUEST' && task.anchor.id !== args.prId) {
|
|
1307
|
-
throw new Error(`Task #${args.taskId} does not belong to PR #${args.prId}.`);
|
|
1308
|
-
}
|
|
1309
|
-
await this.request('DELETE', `/tasks/${args.taskId}?version=${task.version}`);
|
|
1310
|
-
return text(`Task #${args.taskId} deleted.`);
|
|
1311
|
-
}
|
|
1312
|
-
// resolve or reopen
|
|
1313
|
-
const task = await this.request('GET', `/tasks/${args.taskId}`);
|
|
1314
|
-
if (!task)
|
|
1315
|
-
throw new Error(`Task #${args.taskId} not found.`);
|
|
1316
|
-
if (args.prId !== undefined && task.anchor?.type === 'PULL_REQUEST' && task.anchor.id !== args.prId) {
|
|
1317
|
-
throw new Error(`Task #${args.taskId} does not belong to PR #${args.prId}.`);
|
|
1318
|
-
}
|
|
1319
|
-
const newState = args.action === 'resolve' ? 'RESOLVED' : 'OPEN';
|
|
1320
|
-
const updated = await this.request('PUT', `/tasks/${args.taskId}?version=${task.version}`, {
|
|
1321
|
-
id: task.id,
|
|
1322
|
-
state: newState,
|
|
1323
|
-
text: task.text,
|
|
1324
|
-
});
|
|
1325
|
-
if (!updated)
|
|
1326
|
-
return text(`Task #${args.taskId} ${newState}.`);
|
|
1327
|
-
return text(`Task #${updated.id} is now ${updated.state}: "${updated.text}"`);
|
|
1328
|
-
}
|
|
1329
|
-
async getBuildStatuses(args) {
|
|
1330
|
-
const data = await this.requestBuildStatus('GET', `/commits/${args.commitSha}`);
|
|
1331
|
-
if (!data?.values?.length)
|
|
1332
|
-
return text(`No build statuses reported for ${args.commitSha}.`);
|
|
1333
|
-
const lines = data.values.map((s) => {
|
|
1334
|
-
const icon = s.state === 'SUCCESSFUL' ? '✓' : s.state === 'FAILED' ? '✗' : '…';
|
|
1335
|
-
const date = s.dateAdded ? ` (${new Date(s.dateAdded).toISOString().slice(0, 10)})` : '';
|
|
1336
|
-
return `${icon} [${s.state}] ${s.name ?? s.key}${date}${s.description ? `\n ${s.description}` : ''}${s.url ? `\n ${s.url}` : ''}`;
|
|
1337
|
-
});
|
|
1338
|
-
return text(`${data.values.length} build status(es) for ${args.commitSha.slice(0, 8)}:\n${lines.join('\n')}`);
|
|
1339
|
-
}
|
|
1340
|
-
}
|