@stubbedev/atlassian-mcp 0.2.6 → 0.2.8
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/atlassian-mcp.schema.json +0 -1
- package/dist/bitbucket.js +93 -123
- package/dist/config.js +3 -2
- package/dist/context.js +22 -5
- package/dist/git.js +80 -42
- package/dist/index.js +31 -18
- package/dist/jira.js +31 -24
- package/package.json +1 -1
package/dist/bitbucket.js
CHANGED
|
@@ -37,7 +37,9 @@ function branchDisplayId(branch) {
|
|
|
37
37
|
function formatDate(ms) {
|
|
38
38
|
return new Date(ms).toISOString().slice(0, 10);
|
|
39
39
|
}
|
|
40
|
-
function formatCommentThread(comment, indent = '') {
|
|
40
|
+
function formatCommentThread(comment, indent = '', depth = 0) {
|
|
41
|
+
if (depth > 20)
|
|
42
|
+
return [`${indent}... (deeply nested replies omitted)`];
|
|
41
43
|
const author = comment.author?.displayName ?? comment.author?.name ?? 'Unknown';
|
|
42
44
|
const date = comment.createdDate ? ` (${formatDate(comment.createdDate)})` : '';
|
|
43
45
|
const state = comment.state ?? 'OPEN';
|
|
@@ -51,7 +53,7 @@ function formatCommentThread(comment, indent = '') {
|
|
|
51
53
|
];
|
|
52
54
|
if (comment.comments && comment.comments.length > 0) {
|
|
53
55
|
for (const reply of comment.comments) {
|
|
54
|
-
lines.push(...formatCommentThread(reply, `${indent}
|
|
56
|
+
lines.push(...formatCommentThread(reply, `${indent} `, depth + 1));
|
|
55
57
|
}
|
|
56
58
|
}
|
|
57
59
|
return lines;
|
|
@@ -181,7 +183,7 @@ function validateCommentText(textValue) {
|
|
|
181
183
|
export class BitbucketClient {
|
|
182
184
|
baseUrl;
|
|
183
185
|
headers;
|
|
184
|
-
|
|
186
|
+
currentUsernameCache;
|
|
185
187
|
constructor(baseUrl, token) {
|
|
186
188
|
this.baseUrl = baseUrl.replace(/\/$/, '');
|
|
187
189
|
this.headers = {
|
|
@@ -190,6 +192,22 @@ export class BitbucketClient {
|
|
|
190
192
|
Accept: 'application/json',
|
|
191
193
|
};
|
|
192
194
|
}
|
|
195
|
+
/** Returns the slug/username of the authenticated user via the X-AUSERNAME response header. */
|
|
196
|
+
async getCurrentUsername() {
|
|
197
|
+
if (this.currentUsernameCache)
|
|
198
|
+
return this.currentUsernameCache;
|
|
199
|
+
const url = `${this.baseUrl}/rest/api/1.0/application-properties`;
|
|
200
|
+
const res = await fetch(url, { method: 'GET', headers: this.headers });
|
|
201
|
+
const username = res.headers.get('X-AUSERNAME');
|
|
202
|
+
if (!username)
|
|
203
|
+
throw new Error('Could not determine current Bitbucket user. Check token permissions.');
|
|
204
|
+
this.currentUsernameCache = username;
|
|
205
|
+
return username;
|
|
206
|
+
}
|
|
207
|
+
/** Returns a URL-safe `/projects/.../repos/...` prefix for REST paths. */
|
|
208
|
+
rp(projectKey, repoSlug) {
|
|
209
|
+
return `/projects/${encodeURIComponent(projectKey)}/repos/${encodeURIComponent(repoSlug)}`;
|
|
210
|
+
}
|
|
193
211
|
pullRequestUrl(projectKey, repoSlug, prId, pr) {
|
|
194
212
|
const apiUrl = pr?.links?.self?.[0]?.href?.trim();
|
|
195
213
|
if (apiUrl) {
|
|
@@ -232,7 +250,7 @@ export class BitbucketClient {
|
|
|
232
250
|
}
|
|
233
251
|
async request(method, path, body) {
|
|
234
252
|
const url = `${this.baseUrl}/rest/api/1.0${path}`;
|
|
235
|
-
const opts = { method, headers: this.headers };
|
|
253
|
+
const opts = { method, headers: this.headers, signal: AbortSignal.timeout(30_000) };
|
|
236
254
|
if (body !== undefined)
|
|
237
255
|
opts.body = JSON.stringify(body);
|
|
238
256
|
const res = await fetch(url, opts);
|
|
@@ -248,6 +266,7 @@ export class BitbucketClient {
|
|
|
248
266
|
const res = await fetch(url, {
|
|
249
267
|
method: 'GET',
|
|
250
268
|
headers: { Authorization: this.headers.Authorization },
|
|
269
|
+
signal: AbortSignal.timeout(30_000),
|
|
251
270
|
});
|
|
252
271
|
if (!res.ok) {
|
|
253
272
|
const errText = await res.text();
|
|
@@ -258,7 +277,7 @@ export class BitbucketClient {
|
|
|
258
277
|
}
|
|
259
278
|
async requestBuildStatus(method, path, body) {
|
|
260
279
|
const url = `${this.baseUrl}/rest/build-status/1.0${path}`;
|
|
261
|
-
const opts = { method, headers: this.headers };
|
|
280
|
+
const opts = { method, headers: this.headers, signal: AbortSignal.timeout(30_000) };
|
|
262
281
|
if (body !== undefined)
|
|
263
282
|
opts.body = JSON.stringify(body);
|
|
264
283
|
const res = await fetch(url, opts);
|
|
@@ -271,60 +290,23 @@ export class BitbucketClient {
|
|
|
271
290
|
}
|
|
272
291
|
return res.status === 204 ? null : res.json();
|
|
273
292
|
}
|
|
274
|
-
normalizeIdentity(value) {
|
|
275
|
-
return (value ?? '').trim().toLowerCase();
|
|
276
|
-
}
|
|
277
|
-
async getCurrentUser() {
|
|
278
|
-
if (this.currentUserCache)
|
|
279
|
-
return this.currentUserCache;
|
|
280
|
-
const me = await this.request('GET', '/users/~self');
|
|
281
|
-
if (!me) {
|
|
282
|
-
throw new Error('Could not determine current Bitbucket user identity.');
|
|
283
|
-
}
|
|
284
|
-
this.currentUserCache = me;
|
|
285
|
-
return me;
|
|
286
|
-
}
|
|
287
|
-
async assertOwnComment(comment) {
|
|
288
|
-
const me = await this.getCurrentUser();
|
|
289
|
-
const commentAuthorName = this.normalizeIdentity(comment.author?.name);
|
|
290
|
-
const commentAuthorDisplayName = this.normalizeIdentity(comment.author?.displayName);
|
|
291
|
-
const meName = this.normalizeIdentity(me.name);
|
|
292
|
-
const meSlug = this.normalizeIdentity(me.slug);
|
|
293
|
-
const meDisplayName = this.normalizeIdentity(me.displayName);
|
|
294
|
-
const hasStrongCommentIdentity = commentAuthorName.length > 0;
|
|
295
|
-
const hasStrongUserIdentity = meName.length > 0 || meSlug.length > 0;
|
|
296
|
-
const matchesByName = commentAuthorName.length > 0 && (commentAuthorName === meName || commentAuthorName === meSlug);
|
|
297
|
-
const matchesByDisplayNameFallback = !hasStrongCommentIdentity
|
|
298
|
-
&& !hasStrongUserIdentity
|
|
299
|
-
&& commentAuthorDisplayName.length > 0
|
|
300
|
-
&& commentAuthorDisplayName === meDisplayName;
|
|
301
|
-
if (matchesByName || matchesByDisplayNameFallback) {
|
|
302
|
-
return;
|
|
303
|
-
}
|
|
304
|
-
throw new Error(`You can only edit your own Bitbucket comments. Comment #${comment.id} is authored by ${comment.author?.displayName ?? comment.author?.name ?? 'another user'}.`);
|
|
305
|
-
}
|
|
306
293
|
/** Returns true if the given remote URL belongs to this Bitbucket instance. */
|
|
307
294
|
isRemoteForThisInstance(remoteUrl) {
|
|
308
295
|
return this.remoteMatchesInstance(remoteUrl);
|
|
309
296
|
}
|
|
310
|
-
// Used internally by context tools — finds the open PR for a given source branch
|
|
297
|
+
// Used internally by context tools — finds the open PR for a given source branch.
|
|
298
|
+
// Uses the `at` filter to avoid paginating all open PRs.
|
|
311
299
|
async findOpenPrForBranch(projectKey, repoSlug, branch) {
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
if (match)
|
|
318
|
-
return match;
|
|
319
|
-
if (!data || data.isLastPage || data.nextPageStart === undefined)
|
|
320
|
-
return null;
|
|
321
|
-
start = data.nextPageStart;
|
|
322
|
-
}
|
|
300
|
+
const atRef = encodeURIComponent(toBranchRef(branch));
|
|
301
|
+
const encodedProject = encodeURIComponent(projectKey);
|
|
302
|
+
const encodedRepo = encodeURIComponent(repoSlug);
|
|
303
|
+
const data = await this.request('GET', `/projects/${encodedProject}/repos/${encodedRepo}/pull-requests?state=OPEN&direction=OUTGOING&at=${atRef}&limit=1`);
|
|
304
|
+
return data?.values[0] ?? null;
|
|
323
305
|
}
|
|
324
306
|
// Fallback: search branches matching filterText and check each for an open PR.
|
|
325
307
|
// Used when exact branch name lookup yields no result (e.g. LLM provides a partial branch name).
|
|
326
308
|
async findOpenPrByBranchFilter(projectKey, repoSlug, filterText) {
|
|
327
|
-
const branches = await this.request('GET',
|
|
309
|
+
const branches = await this.request('GET', `${this.rp(projectKey, repoSlug)}/branches?limit=25&filterText=${encodeURIComponent(filterText)}`);
|
|
328
310
|
if (!branches?.values?.length)
|
|
329
311
|
return null;
|
|
330
312
|
for (const b of branches.values) {
|
|
@@ -338,7 +320,7 @@ export class BitbucketClient {
|
|
|
338
320
|
const { limit = 50, start = 0 } = args;
|
|
339
321
|
const qs = `?limit=${limit}&start=${start}`;
|
|
340
322
|
const path = args.projectKey
|
|
341
|
-
? `/projects/${args.projectKey}/repos${qs}`
|
|
323
|
+
? `/projects/${encodeURIComponent(args.projectKey)}/repos${qs}`
|
|
342
324
|
: `/repos${qs}`;
|
|
343
325
|
const data = await this.request('GET', path);
|
|
344
326
|
if (!data || data.values.length === 0)
|
|
@@ -356,7 +338,7 @@ export class BitbucketClient {
|
|
|
356
338
|
}
|
|
357
339
|
if (searchText)
|
|
358
340
|
qs.set('filterText', searchText);
|
|
359
|
-
const path = `/projects/${projectKey}/repos/${repoSlug}/pull-requests?${qs}`;
|
|
341
|
+
const path = `/projects/${encodeURIComponent(projectKey)}/repos/${encodeURIComponent(repoSlug)}/pull-requests?${qs}`;
|
|
360
342
|
const data = await this.request('GET', path);
|
|
361
343
|
if (!data || data.values.length === 0)
|
|
362
344
|
return text(`No ${state} pull requests found.`);
|
|
@@ -365,21 +347,22 @@ export class BitbucketClient {
|
|
|
365
347
|
}
|
|
366
348
|
async myPrs(args) {
|
|
367
349
|
const { limit = 25, start = 0, role } = args;
|
|
368
|
-
const
|
|
350
|
+
const userSlug = await this.getCurrentUsername();
|
|
351
|
+
const qs = new URLSearchParams({ limit: String(limit), start: String(start), state: 'OPEN' });
|
|
369
352
|
if (role)
|
|
370
|
-
qs.set('role', role);
|
|
371
|
-
const data = await this.request('GET', `/
|
|
353
|
+
qs.set('role', role.toUpperCase());
|
|
354
|
+
const data = await this.request('GET', `/users/${encodeURIComponent(userSlug)}/pull-requests?${qs}`);
|
|
372
355
|
if (!data || data.values.length === 0)
|
|
373
|
-
return text('No pull requests
|
|
356
|
+
return text('No pull requests found.');
|
|
374
357
|
const lines = data.values.map((pr) => {
|
|
375
358
|
const repo = `${pr.toRef.repository.project.key}/${pr.toRef.repository.slug}`;
|
|
376
359
|
return `#${pr.id} [${pr.state}] ${pr.title} | ${repo} | ${pr.fromRef.displayId} → ${pr.toRef.displayId}`;
|
|
377
360
|
});
|
|
378
|
-
return text(`${data.values.length} PR(s)
|
|
361
|
+
return text(`${data.values.length} PR(s)${pageHint(data)}:\n${lines.join('\n')}`);
|
|
379
362
|
}
|
|
380
363
|
async getPullRequest(args) {
|
|
381
364
|
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
382
|
-
const data = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}`);
|
|
365
|
+
const data = await this.request('GET', `/projects/${encodeURIComponent(projectKey)}/repos/${encodeURIComponent(repoSlug)}/pull-requests/${args.prId}`);
|
|
383
366
|
if (!data)
|
|
384
367
|
return text('Pull request not found.');
|
|
385
368
|
const reviewers = data.reviewers
|
|
@@ -418,28 +401,12 @@ export class BitbucketClient {
|
|
|
418
401
|
throw new Error(`No open PR found for branch "${branchDisplayId(branch)}".`);
|
|
419
402
|
prId = found.id;
|
|
420
403
|
}
|
|
421
|
-
const
|
|
422
|
-
this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${prId}`),
|
|
423
|
-
this.getCurrentUser().catch(() => null),
|
|
424
|
-
]);
|
|
404
|
+
const pr = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${prId}`);
|
|
425
405
|
if (!pr)
|
|
426
406
|
return text('Pull request not found.');
|
|
427
407
|
const sections = [];
|
|
428
408
|
const reviewers = pr.reviewers.map((r) => `${r.user.displayName}${r.approved ? ' ✓' : ''}`).join(', ');
|
|
429
409
|
const url = pr.links?.self?.[0]?.href;
|
|
430
|
-
// Prefer slug (canonical unique identifier), fall back to username (name field).
|
|
431
|
-
// Display name is deliberately excluded — it is not unique.
|
|
432
|
-
const meSlug = this.normalizeIdentity(me?.slug);
|
|
433
|
-
const meName = this.normalizeIdentity(me?.name);
|
|
434
|
-
const authorSlug = this.normalizeIdentity(pr.author.user.slug);
|
|
435
|
-
const authorName = this.normalizeIdentity(pr.author.user.name);
|
|
436
|
-
const isAuthor = me
|
|
437
|
-
? (meSlug && authorSlug ? meSlug === authorSlug : false) ||
|
|
438
|
-
(meName && authorName ? meName === authorName : false)
|
|
439
|
-
: false;
|
|
440
|
-
const viewingAs = me
|
|
441
|
-
? `Viewing as: ${me.displayName} (${isAuthor ? 'you are the author' : 'you are a reviewer'})`
|
|
442
|
-
: '';
|
|
443
410
|
const header = [
|
|
444
411
|
`PR #${pr.id}: ${pr.title}`,
|
|
445
412
|
`State: ${pr.state}`,
|
|
@@ -447,7 +414,6 @@ export class BitbucketClient {
|
|
|
447
414
|
`Branch: ${pr.fromRef.displayId} → ${pr.toRef.displayId}`,
|
|
448
415
|
`Reviewers: ${reviewers || 'None'}`,
|
|
449
416
|
url ? `URL: ${url}` : '',
|
|
450
|
-
viewingAs,
|
|
451
417
|
'',
|
|
452
418
|
'Description:',
|
|
453
419
|
pr.description ?? '(no description)',
|
|
@@ -470,7 +436,7 @@ export class BitbucketClient {
|
|
|
470
436
|
if (includeCommits) {
|
|
471
437
|
const commitsLimit = args.commitsLimit ?? 25;
|
|
472
438
|
const commitsStart = args.commitsStart ?? 0;
|
|
473
|
-
const data = await this.request('GET',
|
|
439
|
+
const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${prId}/commits?limit=${commitsLimit}&start=${commitsStart}`);
|
|
474
440
|
if (!data || data.values.length === 0) {
|
|
475
441
|
sections.push('Commits:\n(no commits found)');
|
|
476
442
|
}
|
|
@@ -489,7 +455,7 @@ export class BitbucketClient {
|
|
|
489
455
|
}
|
|
490
456
|
if (commentsSeverity === 'BLOCKER') {
|
|
491
457
|
const qs = new URLSearchParams({ limit: String(commentsLimit), start: String(commentsStart), state: commentsState });
|
|
492
|
-
const data = await this.request('GET',
|
|
458
|
+
const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${prId}/blocker-comments?${qs}`);
|
|
493
459
|
if (!data || data.values.length === 0) {
|
|
494
460
|
sections.push(`Comments:\n(no ${commentsState} BLOCKER comments)`);
|
|
495
461
|
}
|
|
@@ -499,7 +465,7 @@ export class BitbucketClient {
|
|
|
499
465
|
}
|
|
500
466
|
}
|
|
501
467
|
else {
|
|
502
|
-
const activityData = await this.request('GET',
|
|
468
|
+
const activityData = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${prId}/activities?limit=${commentsLimit}&start=${commentsStart}`);
|
|
503
469
|
const comments = uniqueCommentsFromActivities(activityData?.values ?? []).filter((comment) => {
|
|
504
470
|
const matchesState = commentMatchesState(comment, commentsState);
|
|
505
471
|
return matchesState && commentMatchesSeverity(comment, commentsSeverity);
|
|
@@ -515,14 +481,14 @@ export class BitbucketClient {
|
|
|
515
481
|
}
|
|
516
482
|
}
|
|
517
483
|
if (includeDiff) {
|
|
518
|
-
const data = await this.request('GET',
|
|
484
|
+
const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${prId}/diff`);
|
|
519
485
|
sections.push(`Diff:\n${data ? formatDiff(data, args.diffMaxChars ?? 8000) : '(no diff found)'}`);
|
|
520
486
|
}
|
|
521
487
|
return text(sections.join('\n\n'));
|
|
522
488
|
}
|
|
523
489
|
async getPrDiff(args) {
|
|
524
490
|
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
525
|
-
const data = await this.request('GET',
|
|
491
|
+
const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/diff`);
|
|
526
492
|
if (!data)
|
|
527
493
|
return text('No diff found.');
|
|
528
494
|
return text(formatDiff(data));
|
|
@@ -531,7 +497,7 @@ export class BitbucketClient {
|
|
|
531
497
|
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
532
498
|
const limit = args.limit ?? 25;
|
|
533
499
|
const start = args.start ?? 0;
|
|
534
|
-
const data = await this.request('GET',
|
|
500
|
+
const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/commits?limit=${limit}&start=${start}`);
|
|
535
501
|
if (!data || data.values.length === 0)
|
|
536
502
|
return text('No commits found.');
|
|
537
503
|
const lines = data.values.map((c) => `${c.displayId} ${formatDate(c.authorTimestamp)} ${c.author.name}: ${c.message.split('\n')[0]}`);
|
|
@@ -549,15 +515,18 @@ export class BitbucketClient {
|
|
|
549
515
|
const url = this.pullRequestUrl(projectKey, repoSlug, existing.id, existing);
|
|
550
516
|
return text(`Open PR already exists for branch "${sourceBranchName}": #${existing.id} "${existing.title}"\n${url}`);
|
|
551
517
|
}
|
|
552
|
-
const { title, description,
|
|
518
|
+
const { title, description, reviewers = [] } = args;
|
|
519
|
+
const toRef = args.toBranch
|
|
520
|
+
? toBranchRef(args.toBranch)
|
|
521
|
+
: await this.getDefaultBranchRef(projectKey, repoSlug);
|
|
553
522
|
const body = {
|
|
554
523
|
title,
|
|
555
524
|
description: description ?? '',
|
|
556
525
|
fromRef: { id: toBranchRef(sourceBranch), repository: { slug: repoSlug, project: { key: projectKey } } },
|
|
557
|
-
toRef: { id:
|
|
526
|
+
toRef: { id: toRef, repository: { slug: repoSlug, project: { key: projectKey } } },
|
|
558
527
|
reviewers: reviewers.map((name) => ({ user: { name } })),
|
|
559
528
|
};
|
|
560
|
-
const data = await this.request('POST',
|
|
529
|
+
const data = await this.request('POST', `${this.rp(projectKey, repoSlug)}/pull-requests`, body);
|
|
561
530
|
if (!data)
|
|
562
531
|
return text('Pull request created.');
|
|
563
532
|
const url = this.pullRequestUrl(projectKey, repoSlug, data.id, data);
|
|
@@ -571,7 +540,7 @@ export class BitbucketClient {
|
|
|
571
540
|
&& args.reviewers === undefined) {
|
|
572
541
|
throw new Error('At least one field is required: title, description, toBranch, or reviewers');
|
|
573
542
|
}
|
|
574
|
-
const existing = await this.request('GET',
|
|
543
|
+
const existing = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}`);
|
|
575
544
|
if (!existing)
|
|
576
545
|
throw new Error(`PR #${args.prId} not found.`);
|
|
577
546
|
const buildBody = (pr) => {
|
|
@@ -595,16 +564,16 @@ export class BitbucketClient {
|
|
|
595
564
|
};
|
|
596
565
|
let updated;
|
|
597
566
|
try {
|
|
598
|
-
updated = await this.request('PUT',
|
|
567
|
+
updated = await this.request('PUT', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}`, buildBody(existing));
|
|
599
568
|
}
|
|
600
569
|
catch (error) {
|
|
601
570
|
const message = error instanceof Error ? error.message : String(error);
|
|
602
571
|
if (!message.includes('Bitbucket 409'))
|
|
603
572
|
throw error;
|
|
604
|
-
const latest = await this.request('GET',
|
|
573
|
+
const latest = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}`);
|
|
605
574
|
if (!latest)
|
|
606
575
|
throw error;
|
|
607
|
-
updated = await this.request('PUT',
|
|
576
|
+
updated = await this.request('PUT', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}`, buildBody(latest));
|
|
608
577
|
}
|
|
609
578
|
if (!updated)
|
|
610
579
|
return text(`Updated PR #${args.prId}.`);
|
|
@@ -670,7 +639,7 @@ export class BitbucketClient {
|
|
|
670
639
|
}
|
|
671
640
|
async approvePr(args) {
|
|
672
641
|
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
673
|
-
const data = await this.request('POST',
|
|
642
|
+
const data = await this.request('POST', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/approve`);
|
|
674
643
|
const url = this.pullRequestUrl(projectKey, repoSlug, args.prId);
|
|
675
644
|
if (!data)
|
|
676
645
|
return text(`Approved PR #${args.prId}.\n${url}`);
|
|
@@ -678,25 +647,25 @@ export class BitbucketClient {
|
|
|
678
647
|
}
|
|
679
648
|
async unapprovePr(args) {
|
|
680
649
|
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
681
|
-
await this.request('DELETE',
|
|
650
|
+
await this.request('DELETE', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/approve`);
|
|
682
651
|
return text(`Approval removed from PR #${args.prId}.\n${this.pullRequestUrl(projectKey, repoSlug, args.prId)}`);
|
|
683
652
|
}
|
|
684
653
|
async declinePr(args) {
|
|
685
654
|
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
686
|
-
const pr = await this.request('GET',
|
|
655
|
+
const pr = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}`);
|
|
687
656
|
if (!pr)
|
|
688
657
|
throw new Error(`PR #${args.prId} not found.`);
|
|
689
658
|
const body = { version: pr.version };
|
|
690
659
|
if (args.message)
|
|
691
660
|
body.message = args.message;
|
|
692
|
-
const data = await this.request('POST',
|
|
661
|
+
const data = await this.request('POST', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/decline`, body);
|
|
693
662
|
if (!data)
|
|
694
663
|
return text(`Declined PR #${args.prId}.\n${this.pullRequestUrl(projectKey, repoSlug, args.prId)}`);
|
|
695
664
|
return text(`Declined PR #${data.id}: "${data.title}".\n${this.pullRequestUrl(projectKey, repoSlug, data.id, data)}`);
|
|
696
665
|
}
|
|
697
666
|
async mergePr(args) {
|
|
698
667
|
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
699
|
-
const pr = await this.request('GET',
|
|
668
|
+
const pr = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}`);
|
|
700
669
|
if (!pr)
|
|
701
670
|
throw new Error(`PR #${args.prId} not found.`);
|
|
702
671
|
const body = { version: pr.version };
|
|
@@ -704,13 +673,13 @@ export class BitbucketClient {
|
|
|
704
673
|
body.strategyId = args.mergeStrategy;
|
|
705
674
|
if (args.message)
|
|
706
675
|
body.message = args.message;
|
|
707
|
-
const data = await this.request('POST',
|
|
676
|
+
const data = await this.request('POST', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/merge`, body);
|
|
708
677
|
if (!data)
|
|
709
678
|
return text(`Merged PR #${args.prId}.\n${this.pullRequestUrl(projectKey, repoSlug, args.prId)}`);
|
|
710
679
|
return text(`Merged PR #${data.id}: "${data.title}" (${data.fromRef.displayId} → ${data.toRef.displayId}).\n${this.pullRequestUrl(projectKey, repoSlug, data.id, data)}`);
|
|
711
680
|
}
|
|
712
681
|
async getDefaultBranchRef(projectKey, repoSlug) {
|
|
713
|
-
const data = await this.request('GET',
|
|
682
|
+
const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/default-branch`);
|
|
714
683
|
if (data?.displayId)
|
|
715
684
|
return `refs/heads/${data.displayId}`;
|
|
716
685
|
// Fallback: detect from local git
|
|
@@ -722,7 +691,7 @@ export class BitbucketClient {
|
|
|
722
691
|
async createBranch(args) {
|
|
723
692
|
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
724
693
|
const startPoint = args.startPoint ?? await this.getDefaultBranchRef(projectKey, repoSlug);
|
|
725
|
-
const data = await this.request('POST',
|
|
694
|
+
const data = await this.request('POST', `${this.rp(projectKey, repoSlug)}/branches`, { name: args.branchName, startPoint });
|
|
726
695
|
if (!data)
|
|
727
696
|
return text(`Branch "${args.branchName}" created.`);
|
|
728
697
|
return text(`Created branch "${data.displayId}" at ${data.latestCommit.slice(0, 8)} in ${projectKey}/${repoSlug}.`);
|
|
@@ -732,7 +701,7 @@ export class BitbucketClient {
|
|
|
732
701
|
const qs = new URLSearchParams({ limit: String(args.limit ?? 25), start: String(args.start ?? 0) });
|
|
733
702
|
if (args.filter)
|
|
734
703
|
qs.set('filterText', args.filter);
|
|
735
|
-
const data = await this.request('GET',
|
|
704
|
+
const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/branches?${qs}`);
|
|
736
705
|
if (!data || data.values.length === 0)
|
|
737
706
|
return text('No branches found.');
|
|
738
707
|
const lines = data.values.map((b) => `${b.displayId}${b.isDefault ? ' (default)' : ''} — ${b.latestCommit.slice(0, 8)}`);
|
|
@@ -742,7 +711,7 @@ export class BitbucketClient {
|
|
|
742
711
|
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
743
712
|
const qs = args.ref ? `?at=${encodeURIComponent(args.ref)}` : '';
|
|
744
713
|
const encodedPath = args.path.split('/').map(encodeURIComponent).join('/');
|
|
745
|
-
const content = await this.requestText(
|
|
714
|
+
const content = await this.requestText(`${this.rp(projectKey, repoSlug)}/raw/${encodedPath}${qs}`);
|
|
746
715
|
const MAX_CHARS = 10000;
|
|
747
716
|
if (content.length > MAX_CHARS) {
|
|
748
717
|
return text(content.slice(0, MAX_CHARS) + `\n\n... (truncated, ${content.length - MAX_CHARS} more chars)`);
|
|
@@ -765,7 +734,7 @@ export class BitbucketClient {
|
|
|
765
734
|
const qs = new URLSearchParams({ count: 'true' });
|
|
766
735
|
if (state)
|
|
767
736
|
qs.set('state', state);
|
|
768
|
-
const data = await this.request('GET',
|
|
737
|
+
const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/blocker-comments?${qs}`);
|
|
769
738
|
let open = data?.open ?? 0;
|
|
770
739
|
let resolved = data?.resolved ?? 0;
|
|
771
740
|
if ((open === 0 && resolved === 0) && data?.values && data.values.length > 0) {
|
|
@@ -789,7 +758,7 @@ export class BitbucketClient {
|
|
|
789
758
|
const qs = new URLSearchParams({ limit: String(limit), start: String(start) });
|
|
790
759
|
if (state)
|
|
791
760
|
qs.set('state', state);
|
|
792
|
-
const data = await this.request('GET',
|
|
761
|
+
const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/blocker-comments?${qs}`);
|
|
793
762
|
if (!data || data.values.length === 0) {
|
|
794
763
|
return text(`No ${state ?? 'OPEN/RESOLVED'} BLOCKER comments on PR #${args.prId}.`);
|
|
795
764
|
}
|
|
@@ -804,7 +773,7 @@ export class BitbucketClient {
|
|
|
804
773
|
});
|
|
805
774
|
if (state)
|
|
806
775
|
qs.set('state', state);
|
|
807
|
-
const data = await this.request('GET',
|
|
776
|
+
const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/comments?${qs}`);
|
|
808
777
|
const filtered = (data?.values ?? []).filter((comment) => {
|
|
809
778
|
const matchesState = state ? commentMatchesState(comment, state) : true;
|
|
810
779
|
return matchesState && commentMatchesSeverity(comment, severity);
|
|
@@ -816,7 +785,7 @@ export class BitbucketClient {
|
|
|
816
785
|
const paging = data ? pageHint(data) : '';
|
|
817
786
|
return text(`${filtered.length} comment thread(s) on PR #${args.prId} for ${args.path}${paging}:\n\n${blocks.join('\n\n')}`);
|
|
818
787
|
}
|
|
819
|
-
const activityData = await this.request('GET',
|
|
788
|
+
const activityData = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/activities?limit=${limit}&start=${start}`);
|
|
820
789
|
const comments = uniqueCommentsFromActivities(activityData?.values ?? []).filter((comment) => {
|
|
821
790
|
const matchesState = state ? commentMatchesState(comment, state) : true;
|
|
822
791
|
return matchesState && commentMatchesSeverity(comment, severity);
|
|
@@ -847,6 +816,8 @@ export class BitbucketClient {
|
|
|
847
816
|
commentText = args.text ? `${args.text}\n\n${suggestionBlock}` : suggestionBlock;
|
|
848
817
|
}
|
|
849
818
|
const body = { text: validateCommentText(commentText) };
|
|
819
|
+
if (args.severity)
|
|
820
|
+
body.severity = args.severity;
|
|
850
821
|
if (replyToCommentId !== undefined)
|
|
851
822
|
body.parent = { id: replyToCommentId };
|
|
852
823
|
let inlineAnchor;
|
|
@@ -854,7 +825,7 @@ export class BitbucketClient {
|
|
|
854
825
|
if (args.filePath === undefined || args.line === undefined) {
|
|
855
826
|
throw new Error('filePath and line must be provided together for inline comments.');
|
|
856
827
|
}
|
|
857
|
-
const pr = await this.request('GET',
|
|
828
|
+
const pr = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}`);
|
|
858
829
|
inlineAnchor = {
|
|
859
830
|
diffType: 'EFFECTIVE',
|
|
860
831
|
fileType: args.fileType ?? 'TO',
|
|
@@ -879,7 +850,7 @@ export class BitbucketClient {
|
|
|
879
850
|
}
|
|
880
851
|
let created;
|
|
881
852
|
try {
|
|
882
|
-
created = await this.request('POST',
|
|
853
|
+
created = await this.request('POST', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/comments`, body);
|
|
883
854
|
}
|
|
884
855
|
catch (error) {
|
|
885
856
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -888,7 +859,7 @@ export class BitbucketClient {
|
|
|
888
859
|
}
|
|
889
860
|
const { fromHash: _fromHash, toHash: _toHash, ...anchorWithoutHashes } = inlineAnchor;
|
|
890
861
|
body.anchor = anchorWithoutHashes;
|
|
891
|
-
created = await this.request('POST',
|
|
862
|
+
created = await this.request('POST', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/comments`, body);
|
|
892
863
|
}
|
|
893
864
|
if (!created)
|
|
894
865
|
return text(`Comment added to PR #${args.prId}.`);
|
|
@@ -903,17 +874,10 @@ export class BitbucketClient {
|
|
|
903
874
|
if (!args.text && !args.state && !args.severity && args.threadResolved === undefined) {
|
|
904
875
|
throw new Error('At least one field is required: text, state, severity, or threadResolved');
|
|
905
876
|
}
|
|
906
|
-
const current = await this.request('GET',
|
|
877
|
+
const current = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/comments/${args.commentId}`);
|
|
907
878
|
if (!current)
|
|
908
879
|
throw new Error(`Comment #${args.commentId} not found.`);
|
|
909
880
|
const currentSeverity = current.severity ?? 'NORMAL';
|
|
910
|
-
const severityIsChanging = args.severity !== undefined && args.severity !== currentSeverity;
|
|
911
|
-
const isResolutionOnlyUpdate = (args.state !== undefined || args.threadResolved !== undefined)
|
|
912
|
-
&& args.text === undefined
|
|
913
|
-
&& !severityIsChanging;
|
|
914
|
-
if (!isResolutionOnlyUpdate) {
|
|
915
|
-
await this.assertOwnComment(current);
|
|
916
|
-
}
|
|
917
881
|
const targetSeverity = args.severity ?? currentSeverity;
|
|
918
882
|
if (args.state && targetSeverity !== 'BLOCKER') {
|
|
919
883
|
throw new Error('state is only supported for BLOCKER comments (tasks). Use threadResolved for normal comment threads.');
|
|
@@ -922,8 +886,8 @@ export class BitbucketClient {
|
|
|
922
886
|
throw new Error('threadResolved is only supported for normal comments. Use state for BLOCKER comment tasks.');
|
|
923
887
|
}
|
|
924
888
|
const commentPath = (targetSeverity === 'BLOCKER' || current.severity === 'BLOCKER')
|
|
925
|
-
?
|
|
926
|
-
:
|
|
889
|
+
? `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/blocker-comments/${args.commentId}`
|
|
890
|
+
: `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/comments/${args.commentId}`;
|
|
927
891
|
const buildBody = (version) => {
|
|
928
892
|
const body = { version };
|
|
929
893
|
if (args.text !== undefined)
|
|
@@ -960,20 +924,19 @@ export class BitbucketClient {
|
|
|
960
924
|
}
|
|
961
925
|
async deletePrComment(args) {
|
|
962
926
|
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
963
|
-
const current = await this.request('GET',
|
|
927
|
+
const current = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/comments/${args.commentId}`);
|
|
964
928
|
if (!current)
|
|
965
929
|
throw new Error(`Comment #${args.commentId} not found.`);
|
|
966
|
-
await this.assertOwnComment(current);
|
|
967
930
|
const commentPath = current.severity === 'BLOCKER'
|
|
968
|
-
?
|
|
969
|
-
:
|
|
931
|
+
? `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/blocker-comments/${args.commentId}`
|
|
932
|
+
: `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/comments/${args.commentId}`;
|
|
970
933
|
const path = `${commentPath}?version=${current.version}`;
|
|
971
934
|
await this.request('DELETE', path);
|
|
972
935
|
return text(`Comment #${args.commentId} deleted from PR #${args.prId}.`);
|
|
973
936
|
}
|
|
974
937
|
async getPrTasks(args) {
|
|
975
938
|
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
976
|
-
const data = await this.request('GET',
|
|
939
|
+
const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/tasks`);
|
|
977
940
|
if (!data || data.values.length === 0)
|
|
978
941
|
return text(`No tasks on PR #${args.prId}.`);
|
|
979
942
|
const lines = data.values.map((t) => {
|
|
@@ -1011,15 +974,22 @@ export class BitbucketClient {
|
|
|
1011
974
|
const task = await this.request('GET', `/tasks/${args.taskId}`);
|
|
1012
975
|
if (!task)
|
|
1013
976
|
throw new Error(`Task #${args.taskId} not found.`);
|
|
1014
|
-
|
|
977
|
+
// Verify the task belongs to the given PR (when anchor is a direct PR anchor)
|
|
978
|
+
if (args.prId !== undefined && task.anchor?.type === 'PULL_REQUEST' && task.anchor.id !== args.prId) {
|
|
979
|
+
throw new Error(`Task #${args.taskId} does not belong to PR #${args.prId}.`);
|
|
980
|
+
}
|
|
981
|
+
await this.request('DELETE', `/tasks/${args.taskId}?version=${task.version}`);
|
|
1015
982
|
return text(`Task #${args.taskId} deleted.`);
|
|
1016
983
|
}
|
|
1017
984
|
// resolve or reopen
|
|
1018
985
|
const task = await this.request('GET', `/tasks/${args.taskId}`);
|
|
1019
986
|
if (!task)
|
|
1020
987
|
throw new Error(`Task #${args.taskId} not found.`);
|
|
988
|
+
if (args.prId !== undefined && task.anchor?.type === 'PULL_REQUEST' && task.anchor.id !== args.prId) {
|
|
989
|
+
throw new Error(`Task #${args.taskId} does not belong to PR #${args.prId}.`);
|
|
990
|
+
}
|
|
1021
991
|
const newState = args.action === 'resolve' ? 'RESOLVED' : 'OPEN';
|
|
1022
|
-
const updated = await this.request('PUT', `/tasks/${args.taskId}`, {
|
|
992
|
+
const updated = await this.request('PUT', `/tasks/${args.taskId}?version=${task.version}`, {
|
|
1023
993
|
id: task.id,
|
|
1024
994
|
state: newState,
|
|
1025
995
|
text: task.text,
|
package/dist/config.js
CHANGED
|
@@ -38,7 +38,8 @@ export function loadConfig() {
|
|
|
38
38
|
if (jiraUrl && jiraToken) {
|
|
39
39
|
config.jira = { url: jiraUrl, token: jiraToken };
|
|
40
40
|
}
|
|
41
|
-
else {
|
|
41
|
+
else if (jiraUrl || jiraToken) {
|
|
42
|
+
// Partially configured — log which piece is missing so the user can fix it
|
|
42
43
|
const missing = [];
|
|
43
44
|
if (!jiraUrl)
|
|
44
45
|
missing.push('jira.url (or JIRA_URL)');
|
|
@@ -49,7 +50,7 @@ export function loadConfig() {
|
|
|
49
50
|
if (bitbucketUrl && bitbucketToken) {
|
|
50
51
|
config.bitbucket = { url: bitbucketUrl, token: bitbucketToken };
|
|
51
52
|
}
|
|
52
|
-
else {
|
|
53
|
+
else if (bitbucketUrl || bitbucketToken) {
|
|
53
54
|
const missing = [];
|
|
54
55
|
if (!bitbucketUrl)
|
|
55
56
|
missing.push('bitbucket.url (or BITBUCKET_URL)');
|
package/dist/context.js
CHANGED
|
@@ -20,9 +20,25 @@ export async function getDevContext(args, jira, bitbucket) {
|
|
|
20
20
|
const remote = safeExec('git remote get-url origin', repoPath) || '(no remote)';
|
|
21
21
|
const recentCommits = safeExec('git log --oneline -5', repoPath) || '(none)';
|
|
22
22
|
const status = safeExec('git status --short', repoPath) || '(clean)';
|
|
23
|
+
// Upstream ahead/behind
|
|
24
|
+
const upstream = safeExec('git rev-parse --abbrev-ref @{u}', repoPath);
|
|
25
|
+
let upstreamLine = '';
|
|
26
|
+
if (upstream) {
|
|
27
|
+
const ab = safeExec(`git rev-list --left-right --count ${upstream}...HEAD`, repoPath);
|
|
28
|
+
if (ab.includes('\t')) {
|
|
29
|
+
const [behind, ahead] = ab.split('\t').map(Number);
|
|
30
|
+
const parts = [];
|
|
31
|
+
if (ahead)
|
|
32
|
+
parts.push(`${ahead} ahead`);
|
|
33
|
+
if (behind)
|
|
34
|
+
parts.push(`${behind} behind`);
|
|
35
|
+
upstreamLine = `${upstream}${parts.length ? ` (${parts.join(', ')})` : ' (up to date)'}`;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
23
38
|
sections.push([
|
|
24
39
|
`Repository: ${repoPath}`,
|
|
25
40
|
`Branch: ${branch}`,
|
|
41
|
+
...(upstreamLine ? [`Upstream: ${upstreamLine}`] : []),
|
|
26
42
|
`Remote: ${remote}`,
|
|
27
43
|
'',
|
|
28
44
|
'Recent commits:',
|
|
@@ -31,9 +47,9 @@ export async function getDevContext(args, jira, bitbucket) {
|
|
|
31
47
|
'Working tree:',
|
|
32
48
|
status,
|
|
33
49
|
].join('\n'));
|
|
34
|
-
// Jira — fetch overview for any tickets referenced in the branch name
|
|
50
|
+
// Jira — fetch overview for any tickets referenced in the branch name (parallel)
|
|
35
51
|
const jiraKeys = jira ? [...new Set(branch.match(JIRA_KEY_RE) ?? [])] : [];
|
|
36
|
-
|
|
52
|
+
const jiraResults = await Promise.all(jiraKeys.map(async (key) => {
|
|
37
53
|
try {
|
|
38
54
|
const result = await jira.issueOverview({
|
|
39
55
|
issueKey: key,
|
|
@@ -42,12 +58,13 @@ export async function getDevContext(args, jira, bitbucket) {
|
|
|
42
58
|
includeTransitions: true,
|
|
43
59
|
includeSprint: true,
|
|
44
60
|
});
|
|
45
|
-
|
|
61
|
+
return `── Jira ${key} ──\n${result.content[0].text}`;
|
|
46
62
|
}
|
|
47
63
|
catch {
|
|
48
|
-
|
|
64
|
+
return `── Jira ${key} ── (could not fetch)`;
|
|
49
65
|
}
|
|
50
|
-
}
|
|
66
|
+
}));
|
|
67
|
+
sections.push(...jiraResults);
|
|
51
68
|
// Bitbucket — find the open PR for this branch (only if remote points to this instance)
|
|
52
69
|
const parsed = bitbucket?.isRemoteForThisInstance(remote) ? parseBitbucketRemote(remote) : null;
|
|
53
70
|
if (parsed) {
|
package/dist/git.js
CHANGED
|
@@ -1,32 +1,55 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
2
|
const JIRA_KEY_RE = /\b[A-Z][A-Z0-9]+-\d+\b/g;
|
|
3
|
+
// Allowlist for git refs (commits, branches used as refs in diff commands)
|
|
4
|
+
const SAFE_REF_RE = /^[a-zA-Z0-9/_.\-@{}~^:]+(\.\.\.[a-zA-Z0-9/_.\-@{}~^:]+)?$/;
|
|
5
|
+
// Allowlist for branch names (stricter — no range syntax)
|
|
6
|
+
const SAFE_BRANCH_RE = /^[a-zA-Z0-9/_.\-]+$/;
|
|
3
7
|
function text(t) {
|
|
4
8
|
return { content: [{ type: 'text', text: t }] };
|
|
5
9
|
}
|
|
6
|
-
function git(
|
|
7
|
-
return
|
|
10
|
+
function git(args, cwd) {
|
|
11
|
+
return execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
8
12
|
}
|
|
9
|
-
function safeGit(
|
|
13
|
+
function safeGit(args, cwd, fallback = '') {
|
|
10
14
|
try {
|
|
11
|
-
return git(
|
|
15
|
+
return git(args, cwd);
|
|
12
16
|
}
|
|
13
17
|
catch {
|
|
14
18
|
return fallback;
|
|
15
19
|
}
|
|
16
20
|
}
|
|
21
|
+
function validateRepoPath(repoPath) {
|
|
22
|
+
try {
|
|
23
|
+
execFileSync('git', ['rev-parse', '--git-dir'], { cwd: repoPath, encoding: 'utf-8', stdio: 'pipe' });
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
throw new Error(`Not a git repository: ${repoPath}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function validateBranch(branch, label) {
|
|
30
|
+
if (!SAFE_BRANCH_RE.test(branch)) {
|
|
31
|
+
throw new Error(`Invalid ${label} "${branch}". Use only letters, numbers, /, _, ., -`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function validateRef(ref, label) {
|
|
35
|
+
if (!SAFE_REF_RE.test(ref)) {
|
|
36
|
+
throw new Error(`Invalid ${label} "${ref}". Use only safe git ref characters.`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
17
39
|
export function getContext(args) {
|
|
18
40
|
const repoPath = args.repoPath ?? process.cwd();
|
|
19
|
-
const limit = args.commitLimit ?? 10;
|
|
41
|
+
const limit = Math.max(1, Math.min(args.commitLimit ?? 10, 100));
|
|
20
42
|
try {
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
const
|
|
43
|
+
validateRepoPath(repoPath);
|
|
44
|
+
const branch = safeGit(['rev-parse', '--abbrev-ref', 'HEAD'], repoPath, '(unknown)');
|
|
45
|
+
const remote = safeGit(['remote', 'get-url', 'origin'], repoPath, '(no remote)');
|
|
46
|
+
const commits = safeGit(['log', '--oneline', `-${limit}`], repoPath, '(no commits)');
|
|
47
|
+
const status = safeGit(['status', '--short'], repoPath, '');
|
|
25
48
|
// Upstream tracking
|
|
26
|
-
const upstream = safeGit('rev-parse --abbrev-ref @{u}', repoPath, '');
|
|
49
|
+
const upstream = safeGit(['rev-parse', '--abbrev-ref', '@{u}'], repoPath, '');
|
|
27
50
|
let upstreamLine = '';
|
|
28
51
|
if (upstream) {
|
|
29
|
-
const ab = safeGit(
|
|
52
|
+
const ab = safeGit(['rev-list', '--left-right', '--count', `${upstream}...HEAD`], repoPath, '');
|
|
30
53
|
if (ab.includes('\t')) {
|
|
31
54
|
const [behind, ahead] = ab.split('\t').map(Number);
|
|
32
55
|
const parts = [];
|
|
@@ -38,7 +61,7 @@ export function getContext(args) {
|
|
|
38
61
|
}
|
|
39
62
|
}
|
|
40
63
|
// Diff stat summary
|
|
41
|
-
const diffStatLines = safeGit('diff HEAD --stat', repoPath, '').split('\n').filter(Boolean);
|
|
64
|
+
const diffStatLines = safeGit(['diff', 'HEAD', '--stat'], repoPath, '').split('\n').filter(Boolean);
|
|
42
65
|
const diffStat = diffStatLines[diffStatLines.length - 1]?.trim() ?? '';
|
|
43
66
|
const jiraKeys = [...new Set(branch.match(JIRA_KEY_RE) ?? [])];
|
|
44
67
|
const lines = [
|
|
@@ -62,7 +85,7 @@ export function getContext(args) {
|
|
|
62
85
|
lines.push('(clean)');
|
|
63
86
|
}
|
|
64
87
|
if (args.includeDiff && status) {
|
|
65
|
-
const diff = safeGit('diff HEAD', repoPath, '');
|
|
88
|
+
const diff = safeGit(['diff', 'HEAD'], repoPath, '');
|
|
66
89
|
if (diff) {
|
|
67
90
|
const MAX = 6000;
|
|
68
91
|
lines.push('', '── Uncommitted diff ──', diff.length > MAX ? diff.slice(0, MAX) + `\n\n... (truncated, ${diff.length - MAX} more chars)` : diff);
|
|
@@ -74,18 +97,22 @@ export function getContext(args) {
|
|
|
74
97
|
return text(`Error reading git context: ${err.message}`);
|
|
75
98
|
}
|
|
76
99
|
}
|
|
100
|
+
// Not exposed as an MCP tool — internal/experimental use only
|
|
77
101
|
export function getCommits(args) {
|
|
78
102
|
const repoPath = args.repoPath ?? process.cwd();
|
|
79
|
-
const limit = args.limit ?? 20;
|
|
103
|
+
const limit = Math.max(1, Math.min(args.limit ?? 20, 200));
|
|
80
104
|
const branch = args.branch ?? '';
|
|
81
105
|
try {
|
|
106
|
+
validateRepoPath(repoPath);
|
|
107
|
+
if (branch)
|
|
108
|
+
validateBranch(branch, 'branch');
|
|
82
109
|
const format = '%H|%an|%ad|%s';
|
|
83
|
-
const
|
|
84
|
-
const raw = safeGit(
|
|
110
|
+
const gitArgs = ['log', `--pretty=format:${format}`, '--date=short', `-${limit}`, ...(branch ? [branch] : [])];
|
|
111
|
+
const raw = safeGit(gitArgs, repoPath, '');
|
|
85
112
|
if (!raw)
|
|
86
113
|
return text('No commits found.');
|
|
87
114
|
const lines = raw.split('\n').map((line) => {
|
|
88
|
-
const [hash, author, date, ...msgParts] = line.
|
|
115
|
+
const [hash, author, date, ...msgParts] = line.split('|');
|
|
89
116
|
return `${hash?.slice(0, 8)} ${date} ${author}: ${msgParts.join('|')}`;
|
|
90
117
|
});
|
|
91
118
|
return text(`Last ${lines.length} commit(s)${branch ? ` on ${branch}` : ''}:\n${lines.join('\n')}`);
|
|
@@ -96,24 +123,24 @@ export function getCommits(args) {
|
|
|
96
123
|
}
|
|
97
124
|
export function getDiff(args) {
|
|
98
125
|
const repoPath = args.repoPath ?? process.cwd();
|
|
99
|
-
const MAX_CHARS = 8000;
|
|
100
126
|
try {
|
|
101
|
-
|
|
127
|
+
validateRepoPath(repoPath);
|
|
128
|
+
let gitArgs;
|
|
102
129
|
if (args.fromRef && args.toRef) {
|
|
103
|
-
|
|
130
|
+
validateRef(args.fromRef, 'fromRef');
|
|
131
|
+
validateRef(args.toRef, 'toRef');
|
|
132
|
+
gitArgs = ['diff', args.fromRef, args.toRef];
|
|
104
133
|
}
|
|
105
134
|
else if (args.fromRef) {
|
|
106
|
-
|
|
135
|
+
validateRef(args.fromRef, 'fromRef');
|
|
136
|
+
gitArgs = ['diff', args.fromRef];
|
|
107
137
|
}
|
|
108
138
|
else {
|
|
109
|
-
|
|
139
|
+
gitArgs = ['diff', 'HEAD'];
|
|
110
140
|
}
|
|
111
|
-
const diff = safeGit(
|
|
141
|
+
const diff = safeGit(gitArgs, repoPath, '');
|
|
112
142
|
if (!diff)
|
|
113
143
|
return text('No differences found.');
|
|
114
|
-
if (diff.length > MAX_CHARS) {
|
|
115
|
-
return text(diff.slice(0, MAX_CHARS) + `\n\n... (truncated, ${diff.length - MAX_CHARS} more chars)`);
|
|
116
|
-
}
|
|
117
144
|
return text(diff);
|
|
118
145
|
}
|
|
119
146
|
catch (err) {
|
|
@@ -121,13 +148,19 @@ export function getDiff(args) {
|
|
|
121
148
|
}
|
|
122
149
|
}
|
|
123
150
|
export function checkRemoteBranch(branchName, repoPath) {
|
|
124
|
-
|
|
151
|
+
validateBranch(branchName, 'branchName');
|
|
152
|
+
const lsRemote = safeGit(['ls-remote', '--heads', 'origin', `refs/heads/${branchName}`], repoPath);
|
|
125
153
|
if (!lsRemote)
|
|
126
154
|
return { exists: false };
|
|
127
155
|
const sha = lsRemote.split(/\s+/)[0]?.trim();
|
|
128
|
-
// Fetch so we can read the log
|
|
129
|
-
|
|
130
|
-
|
|
156
|
+
// Fetch so we can read the log — failure is non-fatal (network/credentials issue)
|
|
157
|
+
try {
|
|
158
|
+
git(['fetch', 'origin', branchName], repoPath);
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return { exists: true, sha: sha?.slice(0, 8) };
|
|
162
|
+
}
|
|
163
|
+
const log = safeGit(['log', `origin/${branchName}`, '-1', '--format=%an%x09%ae%x09%ad%x09%s'], repoPath);
|
|
131
164
|
if (!log)
|
|
132
165
|
return { exists: true, sha: sha?.slice(0, 8) };
|
|
133
166
|
const [author, email, date, ...msgParts] = log.split('\t');
|
|
@@ -140,22 +173,23 @@ export function checkRemoteBranch(branchName, repoPath) {
|
|
|
140
173
|
};
|
|
141
174
|
}
|
|
142
175
|
function getDefaultBranch(repoPath) {
|
|
143
|
-
const head = safeGit('rev-parse --abbrev-ref origin/HEAD', repoPath);
|
|
176
|
+
const head = safeGit(['rev-parse', '--abbrev-ref', 'origin/HEAD'], repoPath);
|
|
144
177
|
if (head && head.startsWith('origin/'))
|
|
145
178
|
return head.slice('origin/'.length);
|
|
146
179
|
// origin/HEAD not set — probe common defaults
|
|
147
|
-
if (safeGit('rev-parse --verify origin/main', repoPath))
|
|
180
|
+
if (safeGit(['rev-parse', '--verify', 'origin/main'], repoPath))
|
|
148
181
|
return 'main';
|
|
149
182
|
return 'master';
|
|
150
183
|
}
|
|
151
184
|
export function checkoutRemoteBranch(branchName, repoPath) {
|
|
152
185
|
try {
|
|
153
|
-
|
|
186
|
+
validateBranch(branchName, 'branchName');
|
|
187
|
+
const existing = safeGit(['branch', '--list', branchName], repoPath);
|
|
154
188
|
if (existing.trim()) {
|
|
155
|
-
git(
|
|
189
|
+
git(['checkout', branchName], repoPath);
|
|
156
190
|
return text(`Switched to existing local branch "${branchName}".`);
|
|
157
191
|
}
|
|
158
|
-
git(
|
|
192
|
+
git(['checkout', '--track', `origin/${branchName}`], repoPath);
|
|
159
193
|
return text(`Checked out "${branchName}" tracking origin/${branchName}.`);
|
|
160
194
|
}
|
|
161
195
|
catch (err) {
|
|
@@ -165,20 +199,24 @@ export function checkoutRemoteBranch(branchName, repoPath) {
|
|
|
165
199
|
export function createBranch(args) {
|
|
166
200
|
const repoPath = args.repoPath ?? process.cwd();
|
|
167
201
|
const { branchName, push = false } = args;
|
|
168
|
-
const baseBranch = args.baseBranch ?? getDefaultBranch(repoPath);
|
|
169
202
|
try {
|
|
170
|
-
|
|
203
|
+
validateRepoPath(repoPath);
|
|
204
|
+
if (!SAFE_BRANCH_RE.test(branchName)) {
|
|
171
205
|
return text(`Invalid branch name "${branchName}". Use only letters, numbers, /, _, ., -`);
|
|
172
206
|
}
|
|
173
|
-
const
|
|
207
|
+
const baseBranch = args.baseBranch ?? getDefaultBranch(repoPath);
|
|
208
|
+
if (!SAFE_BRANCH_RE.test(baseBranch)) {
|
|
209
|
+
return text(`Invalid base branch name "${baseBranch}". Use only letters, numbers, /, _, ., -`);
|
|
210
|
+
}
|
|
211
|
+
const existing = safeGit(['branch', '--list', branchName], repoPath);
|
|
174
212
|
if (existing.trim()) {
|
|
175
213
|
return text(`Branch "${branchName}" already exists locally. Switch with: git checkout ${branchName}`);
|
|
176
214
|
}
|
|
177
|
-
safeGit(
|
|
178
|
-
git(
|
|
215
|
+
safeGit(['fetch', 'origin', baseBranch], repoPath);
|
|
216
|
+
git(['checkout', '-b', branchName, `origin/${baseBranch}`], repoPath);
|
|
179
217
|
const lines = [`Created and switched to branch "${branchName}" from origin/${baseBranch}.`];
|
|
180
218
|
if (push) {
|
|
181
|
-
git(
|
|
219
|
+
git(['push', '-u', 'origin', branchName], repoPath);
|
|
182
220
|
lines.push(`Pushed to origin/${branchName} and set upstream.`);
|
|
183
221
|
}
|
|
184
222
|
return text(lines.join('\n'));
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import 'dotenv/config';
|
|
3
|
-
import {
|
|
3
|
+
import { execFileSync } from 'child_process';
|
|
4
|
+
import { readFileSync } from 'fs';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
import { dirname, join } from 'path';
|
|
4
7
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
8
|
+
const _pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '../package.json'), 'utf-8'));
|
|
5
9
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
6
10
|
import { CallToolRequestSchema, ListToolsRequestSchema, McpError, ErrorCode, } from '@modelcontextprotocol/sdk/types.js';
|
|
7
11
|
import { loadConfig } from './config.js';
|
|
@@ -11,7 +15,7 @@ import { getContext, getDiff, createBranch, checkRemoteBranch, checkoutRemoteBra
|
|
|
11
15
|
import { getDevContext } from './context.js';
|
|
12
16
|
function currentGitRemote() {
|
|
13
17
|
try {
|
|
14
|
-
return
|
|
18
|
+
return execFileSync('git', ['remote', 'get-url', 'origin'], { encoding: 'utf-8' }).trim();
|
|
15
19
|
}
|
|
16
20
|
catch {
|
|
17
21
|
return '';
|
|
@@ -35,7 +39,7 @@ const bitbucket = (config.bitbucket && remoteMatchesBitbucketInstance(_remote, c
|
|
|
35
39
|
if (config.bitbucket && !bitbucket) {
|
|
36
40
|
console.error(`[atlassian-mcp] Bitbucket configured but remote "${_remote || '(none)'}" does not match ${config.bitbucket.url} — Bitbucket tools disabled for this repo.`);
|
|
37
41
|
}
|
|
38
|
-
const server = new Server({ name: 'atlassian-mcp', version:
|
|
42
|
+
const server = new Server({ name: 'atlassian-mcp', version: _pkg.version }, { capabilities: { tools: {} } });
|
|
39
43
|
server.onerror = (error) => console.error('[MCP Error]', error);
|
|
40
44
|
function normalizeBitbucketArgs(args) {
|
|
41
45
|
const src = (args && typeof args === 'object') ? args : {};
|
|
@@ -495,17 +499,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
495
499
|
if (result.action === 'cancel' || result.action === 'decline') {
|
|
496
500
|
return { content: [{ type: 'text', text: 'Cancelled.' }] };
|
|
497
501
|
}
|
|
498
|
-
if (result.action === 'accept'
|
|
499
|
-
const
|
|
500
|
-
|
|
502
|
+
if (result.action === 'accept') {
|
|
503
|
+
const chosen = result.content?.action;
|
|
504
|
+
if (chosen === 'checkout') {
|
|
505
|
+
const checkout = checkoutRemoteBranch(branchName, repoPath);
|
|
506
|
+
return { content: [{ type: 'text', text: `${message}\n\n${checkout.content[0].text}` }] };
|
|
507
|
+
}
|
|
508
|
+
if (chosen === 'cancel') {
|
|
509
|
+
return { content: [{ type: 'text', text: 'Cancelled.' }] };
|
|
510
|
+
}
|
|
511
|
+
// new_name — instruct the model to re-run with a custom name
|
|
512
|
+
return {
|
|
513
|
+
content: [{
|
|
514
|
+
type: 'text',
|
|
515
|
+
text: `${message}\n\nRe-run start_work with a custom branchName to proceed.`,
|
|
516
|
+
}],
|
|
517
|
+
};
|
|
501
518
|
}
|
|
502
|
-
//
|
|
503
|
-
return {
|
|
504
|
-
content: [{
|
|
505
|
-
type: 'text',
|
|
506
|
-
text: `${message}\n\nRe-run start_work with a custom branchName to proceed.`,
|
|
507
|
-
}],
|
|
508
|
-
};
|
|
519
|
+
// Fallback: unknown action
|
|
520
|
+
return { content: [{ type: 'text', text: 'Cancelled.' }] };
|
|
509
521
|
}
|
|
510
522
|
catch {
|
|
511
523
|
// Client doesn't support elicitation — fall back to informational text
|
|
@@ -656,9 +668,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
656
668
|
// Resolve PR — by prId or current branch
|
|
657
669
|
let resolvedPrId = a.prId;
|
|
658
670
|
if (resolvedPrId === undefined) {
|
|
659
|
-
const { execSync } = await import('child_process');
|
|
660
671
|
const branch = (() => { try {
|
|
661
|
-
return
|
|
672
|
+
return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: repoPath, encoding: 'utf-8' }).trim();
|
|
662
673
|
}
|
|
663
674
|
catch {
|
|
664
675
|
return '';
|
|
@@ -667,7 +678,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
667
678
|
throw new Error('Could not determine current branch. Provide prId or run from a checked-out branch.');
|
|
668
679
|
}
|
|
669
680
|
const remote = (() => { try {
|
|
670
|
-
return
|
|
681
|
+
return execFileSync('git', ['remote', 'get-url', 'origin'], { cwd: repoPath, encoding: 'utf-8' }).trim();
|
|
671
682
|
}
|
|
672
683
|
catch {
|
|
673
684
|
return '';
|
|
@@ -730,9 +741,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
730
741
|
};
|
|
731
742
|
}
|
|
732
743
|
});
|
|
733
|
-
|
|
744
|
+
async function shutdown() {
|
|
734
745
|
await server.close();
|
|
735
746
|
process.exit(0);
|
|
736
|
-
}
|
|
747
|
+
}
|
|
748
|
+
process.on('SIGINT', shutdown);
|
|
749
|
+
process.on('SIGTERM', shutdown);
|
|
737
750
|
const transport = new StdioServerTransport();
|
|
738
751
|
await server.connect(transport);
|
package/dist/jira.js
CHANGED
|
@@ -9,8 +9,11 @@ function pagination(total, startAt, count) {
|
|
|
9
9
|
return total > end ? ` (showing ${startAt + 1}–${end} of ${total}, use startAt=${end} for next page)` : '';
|
|
10
10
|
}
|
|
11
11
|
function buildJQL(args) {
|
|
12
|
-
if (args.jql)
|
|
12
|
+
if (args.jql) {
|
|
13
|
+
if (args.jql.length > 2000)
|
|
14
|
+
throw new Error('JQL query too long (max 2000 characters).');
|
|
13
15
|
return args.jql;
|
|
16
|
+
}
|
|
14
17
|
const clauses = [];
|
|
15
18
|
if (args.query)
|
|
16
19
|
clauses.push(`text ~ ${JSON.stringify(args.query)}`);
|
|
@@ -91,6 +94,7 @@ export class JiraClient {
|
|
|
91
94
|
baseUrl;
|
|
92
95
|
headers;
|
|
93
96
|
currentUserCache;
|
|
97
|
+
projectsCache;
|
|
94
98
|
constructor(baseUrl, token) {
|
|
95
99
|
this.baseUrl = baseUrl.replace(/\/$/, '');
|
|
96
100
|
this.headers = {
|
|
@@ -113,7 +117,7 @@ export class JiraClient {
|
|
|
113
117
|
}
|
|
114
118
|
async requestWithBase(apiBase, method, path, body) {
|
|
115
119
|
const url = `${this.baseUrl}${apiBase}${path}`;
|
|
116
|
-
const opts = { method, headers: this.headers };
|
|
120
|
+
const opts = { method, headers: this.headers, signal: AbortSignal.timeout(30_000) };
|
|
117
121
|
if (body !== undefined)
|
|
118
122
|
opts.body = JSON.stringify(body);
|
|
119
123
|
const res = await fetch(url, opts);
|
|
@@ -195,7 +199,7 @@ export class JiraClient {
|
|
|
195
199
|
if (args.description !== undefined)
|
|
196
200
|
fields.description = args.description;
|
|
197
201
|
if (args.assignee !== undefined)
|
|
198
|
-
fields.assignee = { name: args.assignee };
|
|
202
|
+
fields.assignee = args.assignee ? { name: args.assignee } : null;
|
|
199
203
|
if (args.priority !== undefined)
|
|
200
204
|
fields.priority = { name: args.priority };
|
|
201
205
|
if (args.labels !== undefined)
|
|
@@ -204,7 +208,7 @@ export class JiraClient {
|
|
|
204
208
|
fields.fixVersions = args.fixVersion ? [{ name: args.fixVersion }] : [];
|
|
205
209
|
if (Object.keys(fields).length === 0)
|
|
206
210
|
return false;
|
|
207
|
-
await this.request('PUT', `/issue/${args.issueKey}`, { fields });
|
|
211
|
+
await this.request('PUT', `/issue/${encodeURIComponent(args.issueKey)}`, { fields });
|
|
208
212
|
return true;
|
|
209
213
|
}
|
|
210
214
|
async resolveTransitionId(issueKey, transitionId, transitionName) {
|
|
@@ -214,7 +218,7 @@ export class JiraClient {
|
|
|
214
218
|
if (!requestedName) {
|
|
215
219
|
throw new Error('Provide transitionId or transitionName');
|
|
216
220
|
}
|
|
217
|
-
const data = await this.request('GET', `/issue/${issueKey}/transitions`);
|
|
221
|
+
const data = await this.request('GET', `/issue/${encodeURIComponent(issueKey)}/transitions`);
|
|
218
222
|
const transitions = data?.transitions ?? [];
|
|
219
223
|
const lowered = requestedName.toLowerCase();
|
|
220
224
|
const match = transitions.find((t) => t.name.toLowerCase() === lowered);
|
|
@@ -225,14 +229,17 @@ export class JiraClient {
|
|
|
225
229
|
return match.id;
|
|
226
230
|
}
|
|
227
231
|
async transitionIssueInternal(issueKey, transitionId) {
|
|
228
|
-
await this.request('POST', `/issue/${issueKey}/transitions`, {
|
|
232
|
+
await this.request('POST', `/issue/${encodeURIComponent(issueKey)}/transitions`, {
|
|
229
233
|
transition: { id: transitionId },
|
|
230
234
|
});
|
|
231
235
|
}
|
|
232
236
|
async resolveProjectKey(projectKey) {
|
|
233
237
|
if (projectKey)
|
|
234
238
|
return projectKey;
|
|
235
|
-
|
|
239
|
+
if (!this.projectsCache) {
|
|
240
|
+
this.projectsCache = (await this.request('GET', '/project?maxResults=100')) ?? [];
|
|
241
|
+
}
|
|
242
|
+
const projects = this.projectsCache;
|
|
236
243
|
if (projects.length === 0) {
|
|
237
244
|
throw new Error('No Jira projects found for your account.');
|
|
238
245
|
}
|
|
@@ -287,7 +294,7 @@ export class JiraClient {
|
|
|
287
294
|
}
|
|
288
295
|
async getIssueTypes(args) {
|
|
289
296
|
const projectKey = await this.resolveProjectKey(args.projectKey);
|
|
290
|
-
const data = await this.request('GET', `/project/${projectKey}/statuses`);
|
|
297
|
+
const data = await this.request('GET', `/project/${encodeURIComponent(projectKey)}/statuses`);
|
|
291
298
|
if (!data || data.length === 0)
|
|
292
299
|
return text('No issue types found.');
|
|
293
300
|
const lines = data.map((t) => {
|
|
@@ -330,7 +337,7 @@ export class JiraClient {
|
|
|
330
337
|
return text(`${lines.length} user(s) found:\n${lines.join('\n')}`);
|
|
331
338
|
}
|
|
332
339
|
async getIssueFields(issueKey) {
|
|
333
|
-
const data = await this.request('GET', `/issue/${issueKey}?fields=summary,status,issuetype`);
|
|
340
|
+
const data = await this.request('GET', `/issue/${encodeURIComponent(issueKey)}?fields=summary,status,issuetype`);
|
|
334
341
|
if (!data)
|
|
335
342
|
throw new Error(`Issue ${issueKey} not found`);
|
|
336
343
|
return {
|
|
@@ -341,7 +348,7 @@ export class JiraClient {
|
|
|
341
348
|
}
|
|
342
349
|
async getIssue(args) {
|
|
343
350
|
const fields = 'summary,description,status,assignee,priority,issuetype,labels,components';
|
|
344
|
-
const data = await this.request('GET', `/issue/${args.issueKey}?fields=${fields}`);
|
|
351
|
+
const data = await this.request('GET', `/issue/${encodeURIComponent(args.issueKey)}?fields=${fields}`);
|
|
345
352
|
if (!data)
|
|
346
353
|
return text('Issue not found.');
|
|
347
354
|
const f = data.fields;
|
|
@@ -367,7 +374,7 @@ export class JiraClient {
|
|
|
367
374
|
const commentsMaxResults = args.commentsMaxResults ?? 10;
|
|
368
375
|
const commentsStartAt = args.commentsStartAt ?? 0;
|
|
369
376
|
const fields = 'summary,description,status,assignee,priority,issuetype,labels,components,parent,fixVersions,issuelinks,subtasks';
|
|
370
|
-
const issue = await this.request('GET', `/issue/${args.issueKey}?fields=${fields}`);
|
|
377
|
+
const issue = await this.request('GET', `/issue/${encodeURIComponent(args.issueKey)}?fields=${fields}`);
|
|
371
378
|
if (!issue)
|
|
372
379
|
return text('Issue not found.');
|
|
373
380
|
const f = issue.fields;
|
|
@@ -395,7 +402,7 @@ export class JiraClient {
|
|
|
395
402
|
];
|
|
396
403
|
if (includeSprint) {
|
|
397
404
|
try {
|
|
398
|
-
const agileIssue = await this.requestAgile('GET', `/issue/${args.issueKey}?fields=sprint,closedSprints`);
|
|
405
|
+
const agileIssue = await this.requestAgile('GET', `/issue/${encodeURIComponent(args.issueKey)}?fields=sprint,closedSprints`);
|
|
399
406
|
const activeSprint = agileIssue?.fields?.sprint;
|
|
400
407
|
const closedSprints = agileIssue?.fields?.closedSprints ?? [];
|
|
401
408
|
if (activeSprint) {
|
|
@@ -415,13 +422,13 @@ export class JiraClient {
|
|
|
415
422
|
}
|
|
416
423
|
}
|
|
417
424
|
if (includeTransitions) {
|
|
418
|
-
const transitions = await this.request('GET', `/issue/${args.issueKey}/transitions`);
|
|
425
|
+
const transitions = await this.request('GET', `/issue/${encodeURIComponent(args.issueKey)}/transitions`);
|
|
419
426
|
const names = (transitions?.transitions ?? []).map((t) => `${t.name} -> ${t.to.name}`);
|
|
420
427
|
lines.push(`Transitions: ${names.length > 0 ? names.join(', ') : '(none)'}`);
|
|
421
428
|
}
|
|
422
429
|
lines.push('', 'Description:', f.description ?? '(no description)');
|
|
423
430
|
if (includeComments) {
|
|
424
|
-
const comments = await this.request('GET', `/issue/${args.issueKey}/comment?startAt=${commentsStartAt}&maxResults=${commentsMaxResults}`);
|
|
431
|
+
const comments = await this.request('GET', `/issue/${encodeURIComponent(args.issueKey)}/comment?startAt=${commentsStartAt}&maxResults=${commentsMaxResults}`);
|
|
425
432
|
const items = comments?.comments ?? [];
|
|
426
433
|
const total = comments?.total ?? 0;
|
|
427
434
|
const page = comments ? pagination(total, commentsStartAt, items.length) : '';
|
|
@@ -592,7 +599,7 @@ export class JiraClient {
|
|
|
592
599
|
actions.push(`transitioned via ${transitionId}`);
|
|
593
600
|
}
|
|
594
601
|
if (args.comment !== undefined) {
|
|
595
|
-
await this.request('POST', `/issue/${issueKey}/comment`, { body: validateCommentBody(args.comment) });
|
|
602
|
+
await this.request('POST', `/issue/${encodeURIComponent(issueKey)}/comment`, { body: validateCommentBody(args.comment) });
|
|
596
603
|
actions.push('added comment');
|
|
597
604
|
}
|
|
598
605
|
if (args.link) {
|
|
@@ -610,7 +617,7 @@ export class JiraClient {
|
|
|
610
617
|
wBody.comment = args.worklog.comment;
|
|
611
618
|
if (args.worklog.started)
|
|
612
619
|
wBody.started = args.worklog.started;
|
|
613
|
-
await this.request('POST', `/issue/${issueKey}/worklog`, wBody);
|
|
620
|
+
await this.request('POST', `/issue/${encodeURIComponent(issueKey)}/worklog`, wBody);
|
|
614
621
|
actions.push(`logged ${args.worklog.timeSpent}`);
|
|
615
622
|
}
|
|
616
623
|
if (actions.length === 0) {
|
|
@@ -620,7 +627,7 @@ export class JiraClient {
|
|
|
620
627
|
}
|
|
621
628
|
async getComments(args) {
|
|
622
629
|
const { issueKey, maxResults = 50, startAt = 0 } = args;
|
|
623
|
-
const data = await this.request('GET', `/issue/${issueKey}/comment?startAt=${startAt}&maxResults=${maxResults}`);
|
|
630
|
+
const data = await this.request('GET', `/issue/${encodeURIComponent(issueKey)}/comment?startAt=${startAt}&maxResults=${maxResults}`);
|
|
624
631
|
if (!data || data.comments.length === 0)
|
|
625
632
|
return text('No comments found.');
|
|
626
633
|
const blocks = data.comments.map((c) => {
|
|
@@ -631,15 +638,15 @@ export class JiraClient {
|
|
|
631
638
|
return text(`${data.total} comment(s) on ${issueKey}${page}:\n\n${blocks.join('\n\n')}`);
|
|
632
639
|
}
|
|
633
640
|
async addComment(args) {
|
|
634
|
-
await this.request('POST', `/issue/${args.issueKey}/comment`, { body: validateCommentBody(args.body) });
|
|
641
|
+
await this.request('POST', `/issue/${encodeURIComponent(args.issueKey)}/comment`, { body: validateCommentBody(args.body) });
|
|
635
642
|
return text(`Comment added to ${args.issueKey}.`);
|
|
636
643
|
}
|
|
637
644
|
async editComment(args) {
|
|
638
|
-
const commentId = String(args.commentId).trim();
|
|
639
|
-
if (!commentId) {
|
|
645
|
+
const commentId = String(args.commentId ?? '').trim();
|
|
646
|
+
if (!commentId || commentId === 'undefined' || commentId === 'null') {
|
|
640
647
|
throw new Error('commentId is required.');
|
|
641
648
|
}
|
|
642
|
-
const path = `/issue/${args.issueKey}/comment/${commentId}`;
|
|
649
|
+
const path = `/issue/${encodeURIComponent(args.issueKey)}/comment/${encodeURIComponent(commentId)}`;
|
|
643
650
|
const current = await this.request('GET', path);
|
|
644
651
|
if (!current)
|
|
645
652
|
throw new Error(`Comment ${commentId} not found on ${args.issueKey}.`);
|
|
@@ -648,11 +655,11 @@ export class JiraClient {
|
|
|
648
655
|
return text(`Comment ${commentId} updated on ${args.issueKey}.`);
|
|
649
656
|
}
|
|
650
657
|
async deleteComment(args) {
|
|
651
|
-
const commentId = String(args.commentId).trim();
|
|
652
|
-
if (!commentId) {
|
|
658
|
+
const commentId = String(args.commentId ?? '').trim();
|
|
659
|
+
if (!commentId || commentId === 'undefined' || commentId === 'null') {
|
|
653
660
|
throw new Error('commentId is required.');
|
|
654
661
|
}
|
|
655
|
-
const path = `/issue/${args.issueKey}/comment/${commentId}`;
|
|
662
|
+
const path = `/issue/${encodeURIComponent(args.issueKey)}/comment/${encodeURIComponent(commentId)}`;
|
|
656
663
|
const current = await this.request('GET', path);
|
|
657
664
|
if (!current)
|
|
658
665
|
throw new Error(`Comment ${commentId} not found on ${args.issueKey}.`);
|