@stubbedev/atlassian-mcp 0.2.6 → 0.2.7
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 +83 -65
- 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;
|
|
@@ -190,6 +192,10 @@ export class BitbucketClient {
|
|
|
190
192
|
Accept: 'application/json',
|
|
191
193
|
};
|
|
192
194
|
}
|
|
195
|
+
/** Returns a URL-safe `/projects/.../repos/...` prefix for REST paths. */
|
|
196
|
+
rp(projectKey, repoSlug) {
|
|
197
|
+
return `/projects/${encodeURIComponent(projectKey)}/repos/${encodeURIComponent(repoSlug)}`;
|
|
198
|
+
}
|
|
193
199
|
pullRequestUrl(projectKey, repoSlug, prId, pr) {
|
|
194
200
|
const apiUrl = pr?.links?.self?.[0]?.href?.trim();
|
|
195
201
|
if (apiUrl) {
|
|
@@ -232,7 +238,7 @@ export class BitbucketClient {
|
|
|
232
238
|
}
|
|
233
239
|
async request(method, path, body) {
|
|
234
240
|
const url = `${this.baseUrl}/rest/api/1.0${path}`;
|
|
235
|
-
const opts = { method, headers: this.headers };
|
|
241
|
+
const opts = { method, headers: this.headers, signal: AbortSignal.timeout(30_000) };
|
|
236
242
|
if (body !== undefined)
|
|
237
243
|
opts.body = JSON.stringify(body);
|
|
238
244
|
const res = await fetch(url, opts);
|
|
@@ -248,6 +254,7 @@ export class BitbucketClient {
|
|
|
248
254
|
const res = await fetch(url, {
|
|
249
255
|
method: 'GET',
|
|
250
256
|
headers: { Authorization: this.headers.Authorization },
|
|
257
|
+
signal: AbortSignal.timeout(30_000),
|
|
251
258
|
});
|
|
252
259
|
if (!res.ok) {
|
|
253
260
|
const errText = await res.text();
|
|
@@ -258,7 +265,7 @@ export class BitbucketClient {
|
|
|
258
265
|
}
|
|
259
266
|
async requestBuildStatus(method, path, body) {
|
|
260
267
|
const url = `${this.baseUrl}/rest/build-status/1.0${path}`;
|
|
261
|
-
const opts = { method, headers: this.headers };
|
|
268
|
+
const opts = { method, headers: this.headers, signal: AbortSignal.timeout(30_000) };
|
|
262
269
|
if (body !== undefined)
|
|
263
270
|
opts.body = JSON.stringify(body);
|
|
264
271
|
const res = await fetch(url, opts);
|
|
@@ -307,24 +314,19 @@ export class BitbucketClient {
|
|
|
307
314
|
isRemoteForThisInstance(remoteUrl) {
|
|
308
315
|
return this.remoteMatchesInstance(remoteUrl);
|
|
309
316
|
}
|
|
310
|
-
// Used internally by context tools — finds the open PR for a given source branch
|
|
317
|
+
// Used internally by context tools — finds the open PR for a given source branch.
|
|
318
|
+
// Uses the `at` filter to avoid paginating all open PRs.
|
|
311
319
|
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
|
-
}
|
|
320
|
+
const atRef = encodeURIComponent(toBranchRef(branch));
|
|
321
|
+
const encodedProject = encodeURIComponent(projectKey);
|
|
322
|
+
const encodedRepo = encodeURIComponent(repoSlug);
|
|
323
|
+
const data = await this.request('GET', `/projects/${encodedProject}/repos/${encodedRepo}/pull-requests?state=OPEN&direction=OUTGOING&at=${atRef}&limit=1`);
|
|
324
|
+
return data?.values[0] ?? null;
|
|
323
325
|
}
|
|
324
326
|
// Fallback: search branches matching filterText and check each for an open PR.
|
|
325
327
|
// Used when exact branch name lookup yields no result (e.g. LLM provides a partial branch name).
|
|
326
328
|
async findOpenPrByBranchFilter(projectKey, repoSlug, filterText) {
|
|
327
|
-
const branches = await this.request('GET',
|
|
329
|
+
const branches = await this.request('GET', `${this.rp(projectKey, repoSlug)}/branches?limit=25&filterText=${encodeURIComponent(filterText)}`);
|
|
328
330
|
if (!branches?.values?.length)
|
|
329
331
|
return null;
|
|
330
332
|
for (const b of branches.values) {
|
|
@@ -338,7 +340,7 @@ export class BitbucketClient {
|
|
|
338
340
|
const { limit = 50, start = 0 } = args;
|
|
339
341
|
const qs = `?limit=${limit}&start=${start}`;
|
|
340
342
|
const path = args.projectKey
|
|
341
|
-
? `/projects/${args.projectKey}/repos${qs}`
|
|
343
|
+
? `/projects/${encodeURIComponent(args.projectKey)}/repos${qs}`
|
|
342
344
|
: `/repos${qs}`;
|
|
343
345
|
const data = await this.request('GET', path);
|
|
344
346
|
if (!data || data.values.length === 0)
|
|
@@ -356,7 +358,7 @@ export class BitbucketClient {
|
|
|
356
358
|
}
|
|
357
359
|
if (searchText)
|
|
358
360
|
qs.set('filterText', searchText);
|
|
359
|
-
const path = `/projects/${projectKey}/repos/${repoSlug}/pull-requests?${qs}`;
|
|
361
|
+
const path = `/projects/${encodeURIComponent(projectKey)}/repos/${encodeURIComponent(repoSlug)}/pull-requests?${qs}`;
|
|
360
362
|
const data = await this.request('GET', path);
|
|
361
363
|
if (!data || data.values.length === 0)
|
|
362
364
|
return text(`No ${state} pull requests found.`);
|
|
@@ -365,21 +367,25 @@ export class BitbucketClient {
|
|
|
365
367
|
}
|
|
366
368
|
async myPrs(args) {
|
|
367
369
|
const { limit = 25, start = 0, role } = args;
|
|
368
|
-
const
|
|
370
|
+
const me = await this.getCurrentUser();
|
|
371
|
+
const userSlug = me.slug ?? me.name;
|
|
372
|
+
if (!userSlug)
|
|
373
|
+
throw new Error('Could not determine your Bitbucket user slug. Check token permissions.');
|
|
374
|
+
const qs = new URLSearchParams({ limit: String(limit), start: String(start), state: 'OPEN' });
|
|
369
375
|
if (role)
|
|
370
|
-
qs.set('role', role);
|
|
371
|
-
const data = await this.request('GET', `/
|
|
376
|
+
qs.set('role', role.toUpperCase());
|
|
377
|
+
const data = await this.request('GET', `/users/${encodeURIComponent(userSlug)}/pull-requests?${qs}`);
|
|
372
378
|
if (!data || data.values.length === 0)
|
|
373
|
-
return text('No pull requests
|
|
379
|
+
return text('No pull requests found.');
|
|
374
380
|
const lines = data.values.map((pr) => {
|
|
375
381
|
const repo = `${pr.toRef.repository.project.key}/${pr.toRef.repository.slug}`;
|
|
376
382
|
return `#${pr.id} [${pr.state}] ${pr.title} | ${repo} | ${pr.fromRef.displayId} → ${pr.toRef.displayId}`;
|
|
377
383
|
});
|
|
378
|
-
return text(`${data.values.length} PR(s)
|
|
384
|
+
return text(`${data.values.length} PR(s)${pageHint(data)}:\n${lines.join('\n')}`);
|
|
379
385
|
}
|
|
380
386
|
async getPullRequest(args) {
|
|
381
387
|
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
382
|
-
const data = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}`);
|
|
388
|
+
const data = await this.request('GET', `/projects/${encodeURIComponent(projectKey)}/repos/${encodeURIComponent(repoSlug)}/pull-requests/${args.prId}`);
|
|
383
389
|
if (!data)
|
|
384
390
|
return text('Pull request not found.');
|
|
385
391
|
const reviewers = data.reviewers
|
|
@@ -419,7 +425,7 @@ export class BitbucketClient {
|
|
|
419
425
|
prId = found.id;
|
|
420
426
|
}
|
|
421
427
|
const [pr, me] = await Promise.all([
|
|
422
|
-
this.request('GET',
|
|
428
|
+
this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${prId}`),
|
|
423
429
|
this.getCurrentUser().catch(() => null),
|
|
424
430
|
]);
|
|
425
431
|
if (!pr)
|
|
@@ -470,7 +476,7 @@ export class BitbucketClient {
|
|
|
470
476
|
if (includeCommits) {
|
|
471
477
|
const commitsLimit = args.commitsLimit ?? 25;
|
|
472
478
|
const commitsStart = args.commitsStart ?? 0;
|
|
473
|
-
const data = await this.request('GET',
|
|
479
|
+
const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${prId}/commits?limit=${commitsLimit}&start=${commitsStart}`);
|
|
474
480
|
if (!data || data.values.length === 0) {
|
|
475
481
|
sections.push('Commits:\n(no commits found)');
|
|
476
482
|
}
|
|
@@ -489,7 +495,7 @@ export class BitbucketClient {
|
|
|
489
495
|
}
|
|
490
496
|
if (commentsSeverity === 'BLOCKER') {
|
|
491
497
|
const qs = new URLSearchParams({ limit: String(commentsLimit), start: String(commentsStart), state: commentsState });
|
|
492
|
-
const data = await this.request('GET',
|
|
498
|
+
const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${prId}/blocker-comments?${qs}`);
|
|
493
499
|
if (!data || data.values.length === 0) {
|
|
494
500
|
sections.push(`Comments:\n(no ${commentsState} BLOCKER comments)`);
|
|
495
501
|
}
|
|
@@ -499,7 +505,7 @@ export class BitbucketClient {
|
|
|
499
505
|
}
|
|
500
506
|
}
|
|
501
507
|
else {
|
|
502
|
-
const activityData = await this.request('GET',
|
|
508
|
+
const activityData = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${prId}/activities?limit=${commentsLimit}&start=${commentsStart}`);
|
|
503
509
|
const comments = uniqueCommentsFromActivities(activityData?.values ?? []).filter((comment) => {
|
|
504
510
|
const matchesState = commentMatchesState(comment, commentsState);
|
|
505
511
|
return matchesState && commentMatchesSeverity(comment, commentsSeverity);
|
|
@@ -515,14 +521,14 @@ export class BitbucketClient {
|
|
|
515
521
|
}
|
|
516
522
|
}
|
|
517
523
|
if (includeDiff) {
|
|
518
|
-
const data = await this.request('GET',
|
|
524
|
+
const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${prId}/diff`);
|
|
519
525
|
sections.push(`Diff:\n${data ? formatDiff(data, args.diffMaxChars ?? 8000) : '(no diff found)'}`);
|
|
520
526
|
}
|
|
521
527
|
return text(sections.join('\n\n'));
|
|
522
528
|
}
|
|
523
529
|
async getPrDiff(args) {
|
|
524
530
|
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
525
|
-
const data = await this.request('GET',
|
|
531
|
+
const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/diff`);
|
|
526
532
|
if (!data)
|
|
527
533
|
return text('No diff found.');
|
|
528
534
|
return text(formatDiff(data));
|
|
@@ -531,7 +537,7 @@ export class BitbucketClient {
|
|
|
531
537
|
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
532
538
|
const limit = args.limit ?? 25;
|
|
533
539
|
const start = args.start ?? 0;
|
|
534
|
-
const data = await this.request('GET',
|
|
540
|
+
const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/commits?limit=${limit}&start=${start}`);
|
|
535
541
|
if (!data || data.values.length === 0)
|
|
536
542
|
return text('No commits found.');
|
|
537
543
|
const lines = data.values.map((c) => `${c.displayId} ${formatDate(c.authorTimestamp)} ${c.author.name}: ${c.message.split('\n')[0]}`);
|
|
@@ -549,15 +555,18 @@ export class BitbucketClient {
|
|
|
549
555
|
const url = this.pullRequestUrl(projectKey, repoSlug, existing.id, existing);
|
|
550
556
|
return text(`Open PR already exists for branch "${sourceBranchName}": #${existing.id} "${existing.title}"\n${url}`);
|
|
551
557
|
}
|
|
552
|
-
const { title, description,
|
|
558
|
+
const { title, description, reviewers = [] } = args;
|
|
559
|
+
const toRef = args.toBranch
|
|
560
|
+
? toBranchRef(args.toBranch)
|
|
561
|
+
: await this.getDefaultBranchRef(projectKey, repoSlug);
|
|
553
562
|
const body = {
|
|
554
563
|
title,
|
|
555
564
|
description: description ?? '',
|
|
556
565
|
fromRef: { id: toBranchRef(sourceBranch), repository: { slug: repoSlug, project: { key: projectKey } } },
|
|
557
|
-
toRef: { id:
|
|
566
|
+
toRef: { id: toRef, repository: { slug: repoSlug, project: { key: projectKey } } },
|
|
558
567
|
reviewers: reviewers.map((name) => ({ user: { name } })),
|
|
559
568
|
};
|
|
560
|
-
const data = await this.request('POST',
|
|
569
|
+
const data = await this.request('POST', `${this.rp(projectKey, repoSlug)}/pull-requests`, body);
|
|
561
570
|
if (!data)
|
|
562
571
|
return text('Pull request created.');
|
|
563
572
|
const url = this.pullRequestUrl(projectKey, repoSlug, data.id, data);
|
|
@@ -571,7 +580,7 @@ export class BitbucketClient {
|
|
|
571
580
|
&& args.reviewers === undefined) {
|
|
572
581
|
throw new Error('At least one field is required: title, description, toBranch, or reviewers');
|
|
573
582
|
}
|
|
574
|
-
const existing = await this.request('GET',
|
|
583
|
+
const existing = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}`);
|
|
575
584
|
if (!existing)
|
|
576
585
|
throw new Error(`PR #${args.prId} not found.`);
|
|
577
586
|
const buildBody = (pr) => {
|
|
@@ -595,16 +604,16 @@ export class BitbucketClient {
|
|
|
595
604
|
};
|
|
596
605
|
let updated;
|
|
597
606
|
try {
|
|
598
|
-
updated = await this.request('PUT',
|
|
607
|
+
updated = await this.request('PUT', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}`, buildBody(existing));
|
|
599
608
|
}
|
|
600
609
|
catch (error) {
|
|
601
610
|
const message = error instanceof Error ? error.message : String(error);
|
|
602
611
|
if (!message.includes('Bitbucket 409'))
|
|
603
612
|
throw error;
|
|
604
|
-
const latest = await this.request('GET',
|
|
613
|
+
const latest = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}`);
|
|
605
614
|
if (!latest)
|
|
606
615
|
throw error;
|
|
607
|
-
updated = await this.request('PUT',
|
|
616
|
+
updated = await this.request('PUT', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}`, buildBody(latest));
|
|
608
617
|
}
|
|
609
618
|
if (!updated)
|
|
610
619
|
return text(`Updated PR #${args.prId}.`);
|
|
@@ -670,7 +679,7 @@ export class BitbucketClient {
|
|
|
670
679
|
}
|
|
671
680
|
async approvePr(args) {
|
|
672
681
|
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
673
|
-
const data = await this.request('POST',
|
|
682
|
+
const data = await this.request('POST', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/approve`);
|
|
674
683
|
const url = this.pullRequestUrl(projectKey, repoSlug, args.prId);
|
|
675
684
|
if (!data)
|
|
676
685
|
return text(`Approved PR #${args.prId}.\n${url}`);
|
|
@@ -678,25 +687,25 @@ export class BitbucketClient {
|
|
|
678
687
|
}
|
|
679
688
|
async unapprovePr(args) {
|
|
680
689
|
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
681
|
-
await this.request('DELETE',
|
|
690
|
+
await this.request('DELETE', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/approve`);
|
|
682
691
|
return text(`Approval removed from PR #${args.prId}.\n${this.pullRequestUrl(projectKey, repoSlug, args.prId)}`);
|
|
683
692
|
}
|
|
684
693
|
async declinePr(args) {
|
|
685
694
|
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
686
|
-
const pr = await this.request('GET',
|
|
695
|
+
const pr = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}`);
|
|
687
696
|
if (!pr)
|
|
688
697
|
throw new Error(`PR #${args.prId} not found.`);
|
|
689
698
|
const body = { version: pr.version };
|
|
690
699
|
if (args.message)
|
|
691
700
|
body.message = args.message;
|
|
692
|
-
const data = await this.request('POST',
|
|
701
|
+
const data = await this.request('POST', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/decline`, body);
|
|
693
702
|
if (!data)
|
|
694
703
|
return text(`Declined PR #${args.prId}.\n${this.pullRequestUrl(projectKey, repoSlug, args.prId)}`);
|
|
695
704
|
return text(`Declined PR #${data.id}: "${data.title}".\n${this.pullRequestUrl(projectKey, repoSlug, data.id, data)}`);
|
|
696
705
|
}
|
|
697
706
|
async mergePr(args) {
|
|
698
707
|
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
699
|
-
const pr = await this.request('GET',
|
|
708
|
+
const pr = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}`);
|
|
700
709
|
if (!pr)
|
|
701
710
|
throw new Error(`PR #${args.prId} not found.`);
|
|
702
711
|
const body = { version: pr.version };
|
|
@@ -704,13 +713,13 @@ export class BitbucketClient {
|
|
|
704
713
|
body.strategyId = args.mergeStrategy;
|
|
705
714
|
if (args.message)
|
|
706
715
|
body.message = args.message;
|
|
707
|
-
const data = await this.request('POST',
|
|
716
|
+
const data = await this.request('POST', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/merge`, body);
|
|
708
717
|
if (!data)
|
|
709
718
|
return text(`Merged PR #${args.prId}.\n${this.pullRequestUrl(projectKey, repoSlug, args.prId)}`);
|
|
710
719
|
return text(`Merged PR #${data.id}: "${data.title}" (${data.fromRef.displayId} → ${data.toRef.displayId}).\n${this.pullRequestUrl(projectKey, repoSlug, data.id, data)}`);
|
|
711
720
|
}
|
|
712
721
|
async getDefaultBranchRef(projectKey, repoSlug) {
|
|
713
|
-
const data = await this.request('GET',
|
|
722
|
+
const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/default-branch`);
|
|
714
723
|
if (data?.displayId)
|
|
715
724
|
return `refs/heads/${data.displayId}`;
|
|
716
725
|
// Fallback: detect from local git
|
|
@@ -722,7 +731,7 @@ export class BitbucketClient {
|
|
|
722
731
|
async createBranch(args) {
|
|
723
732
|
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
724
733
|
const startPoint = args.startPoint ?? await this.getDefaultBranchRef(projectKey, repoSlug);
|
|
725
|
-
const data = await this.request('POST',
|
|
734
|
+
const data = await this.request('POST', `${this.rp(projectKey, repoSlug)}/branches`, { name: args.branchName, startPoint });
|
|
726
735
|
if (!data)
|
|
727
736
|
return text(`Branch "${args.branchName}" created.`);
|
|
728
737
|
return text(`Created branch "${data.displayId}" at ${data.latestCommit.slice(0, 8)} in ${projectKey}/${repoSlug}.`);
|
|
@@ -732,7 +741,7 @@ export class BitbucketClient {
|
|
|
732
741
|
const qs = new URLSearchParams({ limit: String(args.limit ?? 25), start: String(args.start ?? 0) });
|
|
733
742
|
if (args.filter)
|
|
734
743
|
qs.set('filterText', args.filter);
|
|
735
|
-
const data = await this.request('GET',
|
|
744
|
+
const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/branches?${qs}`);
|
|
736
745
|
if (!data || data.values.length === 0)
|
|
737
746
|
return text('No branches found.');
|
|
738
747
|
const lines = data.values.map((b) => `${b.displayId}${b.isDefault ? ' (default)' : ''} — ${b.latestCommit.slice(0, 8)}`);
|
|
@@ -742,7 +751,7 @@ export class BitbucketClient {
|
|
|
742
751
|
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
743
752
|
const qs = args.ref ? `?at=${encodeURIComponent(args.ref)}` : '';
|
|
744
753
|
const encodedPath = args.path.split('/').map(encodeURIComponent).join('/');
|
|
745
|
-
const content = await this.requestText(
|
|
754
|
+
const content = await this.requestText(`${this.rp(projectKey, repoSlug)}/raw/${encodedPath}${qs}`);
|
|
746
755
|
const MAX_CHARS = 10000;
|
|
747
756
|
if (content.length > MAX_CHARS) {
|
|
748
757
|
return text(content.slice(0, MAX_CHARS) + `\n\n... (truncated, ${content.length - MAX_CHARS} more chars)`);
|
|
@@ -765,7 +774,7 @@ export class BitbucketClient {
|
|
|
765
774
|
const qs = new URLSearchParams({ count: 'true' });
|
|
766
775
|
if (state)
|
|
767
776
|
qs.set('state', state);
|
|
768
|
-
const data = await this.request('GET',
|
|
777
|
+
const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/blocker-comments?${qs}`);
|
|
769
778
|
let open = data?.open ?? 0;
|
|
770
779
|
let resolved = data?.resolved ?? 0;
|
|
771
780
|
if ((open === 0 && resolved === 0) && data?.values && data.values.length > 0) {
|
|
@@ -789,7 +798,7 @@ export class BitbucketClient {
|
|
|
789
798
|
const qs = new URLSearchParams({ limit: String(limit), start: String(start) });
|
|
790
799
|
if (state)
|
|
791
800
|
qs.set('state', state);
|
|
792
|
-
const data = await this.request('GET',
|
|
801
|
+
const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/blocker-comments?${qs}`);
|
|
793
802
|
if (!data || data.values.length === 0) {
|
|
794
803
|
return text(`No ${state ?? 'OPEN/RESOLVED'} BLOCKER comments on PR #${args.prId}.`);
|
|
795
804
|
}
|
|
@@ -804,7 +813,7 @@ export class BitbucketClient {
|
|
|
804
813
|
});
|
|
805
814
|
if (state)
|
|
806
815
|
qs.set('state', state);
|
|
807
|
-
const data = await this.request('GET',
|
|
816
|
+
const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/comments?${qs}`);
|
|
808
817
|
const filtered = (data?.values ?? []).filter((comment) => {
|
|
809
818
|
const matchesState = state ? commentMatchesState(comment, state) : true;
|
|
810
819
|
return matchesState && commentMatchesSeverity(comment, severity);
|
|
@@ -816,7 +825,7 @@ export class BitbucketClient {
|
|
|
816
825
|
const paging = data ? pageHint(data) : '';
|
|
817
826
|
return text(`${filtered.length} comment thread(s) on PR #${args.prId} for ${args.path}${paging}:\n\n${blocks.join('\n\n')}`);
|
|
818
827
|
}
|
|
819
|
-
const activityData = await this.request('GET',
|
|
828
|
+
const activityData = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/activities?limit=${limit}&start=${start}`);
|
|
820
829
|
const comments = uniqueCommentsFromActivities(activityData?.values ?? []).filter((comment) => {
|
|
821
830
|
const matchesState = state ? commentMatchesState(comment, state) : true;
|
|
822
831
|
return matchesState && commentMatchesSeverity(comment, severity);
|
|
@@ -847,6 +856,8 @@ export class BitbucketClient {
|
|
|
847
856
|
commentText = args.text ? `${args.text}\n\n${suggestionBlock}` : suggestionBlock;
|
|
848
857
|
}
|
|
849
858
|
const body = { text: validateCommentText(commentText) };
|
|
859
|
+
if (args.severity)
|
|
860
|
+
body.severity = args.severity;
|
|
850
861
|
if (replyToCommentId !== undefined)
|
|
851
862
|
body.parent = { id: replyToCommentId };
|
|
852
863
|
let inlineAnchor;
|
|
@@ -854,7 +865,7 @@ export class BitbucketClient {
|
|
|
854
865
|
if (args.filePath === undefined || args.line === undefined) {
|
|
855
866
|
throw new Error('filePath and line must be provided together for inline comments.');
|
|
856
867
|
}
|
|
857
|
-
const pr = await this.request('GET',
|
|
868
|
+
const pr = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}`);
|
|
858
869
|
inlineAnchor = {
|
|
859
870
|
diffType: 'EFFECTIVE',
|
|
860
871
|
fileType: args.fileType ?? 'TO',
|
|
@@ -879,7 +890,7 @@ export class BitbucketClient {
|
|
|
879
890
|
}
|
|
880
891
|
let created;
|
|
881
892
|
try {
|
|
882
|
-
created = await this.request('POST',
|
|
893
|
+
created = await this.request('POST', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/comments`, body);
|
|
883
894
|
}
|
|
884
895
|
catch (error) {
|
|
885
896
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -888,7 +899,7 @@ export class BitbucketClient {
|
|
|
888
899
|
}
|
|
889
900
|
const { fromHash: _fromHash, toHash: _toHash, ...anchorWithoutHashes } = inlineAnchor;
|
|
890
901
|
body.anchor = anchorWithoutHashes;
|
|
891
|
-
created = await this.request('POST',
|
|
902
|
+
created = await this.request('POST', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/comments`, body);
|
|
892
903
|
}
|
|
893
904
|
if (!created)
|
|
894
905
|
return text(`Comment added to PR #${args.prId}.`);
|
|
@@ -903,7 +914,7 @@ export class BitbucketClient {
|
|
|
903
914
|
if (!args.text && !args.state && !args.severity && args.threadResolved === undefined) {
|
|
904
915
|
throw new Error('At least one field is required: text, state, severity, or threadResolved');
|
|
905
916
|
}
|
|
906
|
-
const current = await this.request('GET',
|
|
917
|
+
const current = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/comments/${args.commentId}`);
|
|
907
918
|
if (!current)
|
|
908
919
|
throw new Error(`Comment #${args.commentId} not found.`);
|
|
909
920
|
const currentSeverity = current.severity ?? 'NORMAL';
|
|
@@ -922,8 +933,8 @@ export class BitbucketClient {
|
|
|
922
933
|
throw new Error('threadResolved is only supported for normal comments. Use state for BLOCKER comment tasks.');
|
|
923
934
|
}
|
|
924
935
|
const commentPath = (targetSeverity === 'BLOCKER' || current.severity === 'BLOCKER')
|
|
925
|
-
?
|
|
926
|
-
:
|
|
936
|
+
? `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/blocker-comments/${args.commentId}`
|
|
937
|
+
: `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/comments/${args.commentId}`;
|
|
927
938
|
const buildBody = (version) => {
|
|
928
939
|
const body = { version };
|
|
929
940
|
if (args.text !== undefined)
|
|
@@ -960,20 +971,20 @@ export class BitbucketClient {
|
|
|
960
971
|
}
|
|
961
972
|
async deletePrComment(args) {
|
|
962
973
|
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
963
|
-
const current = await this.request('GET',
|
|
974
|
+
const current = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/comments/${args.commentId}`);
|
|
964
975
|
if (!current)
|
|
965
976
|
throw new Error(`Comment #${args.commentId} not found.`);
|
|
966
977
|
await this.assertOwnComment(current);
|
|
967
978
|
const commentPath = current.severity === 'BLOCKER'
|
|
968
|
-
?
|
|
969
|
-
:
|
|
979
|
+
? `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/blocker-comments/${args.commentId}`
|
|
980
|
+
: `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/comments/${args.commentId}`;
|
|
970
981
|
const path = `${commentPath}?version=${current.version}`;
|
|
971
982
|
await this.request('DELETE', path);
|
|
972
983
|
return text(`Comment #${args.commentId} deleted from PR #${args.prId}.`);
|
|
973
984
|
}
|
|
974
985
|
async getPrTasks(args) {
|
|
975
986
|
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
976
|
-
const data = await this.request('GET',
|
|
987
|
+
const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/tasks`);
|
|
977
988
|
if (!data || data.values.length === 0)
|
|
978
989
|
return text(`No tasks on PR #${args.prId}.`);
|
|
979
990
|
const lines = data.values.map((t) => {
|
|
@@ -1011,15 +1022,22 @@ export class BitbucketClient {
|
|
|
1011
1022
|
const task = await this.request('GET', `/tasks/${args.taskId}`);
|
|
1012
1023
|
if (!task)
|
|
1013
1024
|
throw new Error(`Task #${args.taskId} not found.`);
|
|
1014
|
-
|
|
1025
|
+
// Verify the task belongs to the given PR (when anchor is a direct PR anchor)
|
|
1026
|
+
if (args.prId !== undefined && task.anchor?.type === 'PULL_REQUEST' && task.anchor.id !== args.prId) {
|
|
1027
|
+
throw new Error(`Task #${args.taskId} does not belong to PR #${args.prId}.`);
|
|
1028
|
+
}
|
|
1029
|
+
await this.request('DELETE', `/tasks/${args.taskId}?version=${task.version}`);
|
|
1015
1030
|
return text(`Task #${args.taskId} deleted.`);
|
|
1016
1031
|
}
|
|
1017
1032
|
// resolve or reopen
|
|
1018
1033
|
const task = await this.request('GET', `/tasks/${args.taskId}`);
|
|
1019
1034
|
if (!task)
|
|
1020
1035
|
throw new Error(`Task #${args.taskId} not found.`);
|
|
1036
|
+
if (args.prId !== undefined && task.anchor?.type === 'PULL_REQUEST' && task.anchor.id !== args.prId) {
|
|
1037
|
+
throw new Error(`Task #${args.taskId} does not belong to PR #${args.prId}.`);
|
|
1038
|
+
}
|
|
1021
1039
|
const newState = args.action === 'resolve' ? 'RESOLVED' : 'OPEN';
|
|
1022
|
-
const updated = await this.request('PUT', `/tasks/${args.taskId}`, {
|
|
1040
|
+
const updated = await this.request('PUT', `/tasks/${args.taskId}?version=${task.version}`, {
|
|
1023
1041
|
id: task.id,
|
|
1024
1042
|
state: newState,
|
|
1025
1043
|
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}.`);
|