@stubbedev/atlassian-mcp 0.2.11 → 0.2.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bitbucket.js +28 -0
- package/dist/index.js +139 -13
- package/dist/jira.js +16 -0
- package/package.json +1 -1
package/dist/bitbucket.js
CHANGED
|
@@ -371,6 +371,24 @@ export class BitbucketClient {
|
|
|
371
371
|
});
|
|
372
372
|
return text(`${data.values.length} user(s)${pageHint(data)}:\n${lines.join('\n')}`);
|
|
373
373
|
}
|
|
374
|
+
async searchUsersRaw(args) {
|
|
375
|
+
const params = new URLSearchParams();
|
|
376
|
+
if (args.query)
|
|
377
|
+
params.set('filter', args.query);
|
|
378
|
+
params.set('limit', String(args.limit ?? 50));
|
|
379
|
+
let path;
|
|
380
|
+
if (args.projectKey && args.repoSlug) {
|
|
381
|
+
path = `${this.rp(args.projectKey, args.repoSlug)}/permissions/users?${params}`;
|
|
382
|
+
}
|
|
383
|
+
else if (args.projectKey) {
|
|
384
|
+
path = `/projects/${encodeURIComponent(args.projectKey)}/permissions/users?${params}`;
|
|
385
|
+
}
|
|
386
|
+
else {
|
|
387
|
+
path = `/users?${params}`;
|
|
388
|
+
}
|
|
389
|
+
const data = await this.request('GET', path);
|
|
390
|
+
return (data?.values ?? []).map((entry) => entry.user ?? entry);
|
|
391
|
+
}
|
|
374
392
|
async listPullRequests(args) {
|
|
375
393
|
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
376
394
|
const { state = 'OPEN', fromBranch, text: searchText, limit = 25, start = 0 } = args;
|
|
@@ -761,6 +779,16 @@ export class BitbucketClient {
|
|
|
761
779
|
}
|
|
762
780
|
return text(content);
|
|
763
781
|
}
|
|
782
|
+
async fetchFileText(projectKey, repoSlug, filePath) {
|
|
783
|
+
try {
|
|
784
|
+
const encoded = filePath.split('/').map(encodeURIComponent).join('/');
|
|
785
|
+
const content = await this.requestText(`${this.rp(projectKey, repoSlug)}/raw/${encoded}`);
|
|
786
|
+
return content;
|
|
787
|
+
}
|
|
788
|
+
catch {
|
|
789
|
+
return null;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
764
792
|
async getPrComments(args) {
|
|
765
793
|
const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
|
|
766
794
|
const limit = args.limit ?? 50;
|
package/dist/index.js
CHANGED
|
@@ -132,18 +132,18 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
132
132
|
...(jira ? [
|
|
133
133
|
{
|
|
134
134
|
name: 'start_work',
|
|
135
|
-
description: 'Start working on a Jira ticket:
|
|
135
|
+
description: 'Start working on a Jira ticket end-to-end: resolves the ticket (by key or free-text search with a picker when multiple match), creates a local branch with an auto-generated name, fetches the project README from Bitbucket so you have commit/PR conventions in context, and prints a ready-to-use next-steps summary. Use when told "make a branch for FOO-123", "start working on this ticket", "I want to work on the login bug", or "begin work on the payment gateway story". If issueKey is omitted, provide query for free-text search.',
|
|
136
136
|
inputSchema: {
|
|
137
137
|
type: 'object',
|
|
138
138
|
properties: {
|
|
139
|
-
issueKey: { type: 'string', description: 'Jira issue key, e.g. FOO-123' },
|
|
139
|
+
issueKey: { type: 'string', description: 'Jira issue key, e.g. FOO-123 (provide this OR query)' },
|
|
140
|
+
query: { type: 'string', description: 'Free-text search when issueKey is unknown — shows a picker if multiple tickets match' },
|
|
140
141
|
repoPath: { type: 'string', description: 'Local repo path (defaults to cwd)' },
|
|
141
142
|
baseBranch: { type: 'string', description: 'Branch to base off (default: master)' },
|
|
142
143
|
branchName: { type: 'string', description: 'Override the generated branch name' },
|
|
143
144
|
transitionName: { type: 'string', description: 'Jira transition to apply, e.g. "In Progress" (optional)' },
|
|
144
145
|
push: { type: 'boolean', description: 'Push branch to remote after creation (default false)', default: false },
|
|
145
146
|
},
|
|
146
|
-
required: ['issueKey'],
|
|
147
147
|
},
|
|
148
148
|
},
|
|
149
149
|
{
|
|
@@ -332,6 +332,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
332
332
|
fromBranch: { type: 'string', description: 'Source branch (defaults to current branch)' },
|
|
333
333
|
toBranch: { type: 'string', description: 'Target branch (default: master)' },
|
|
334
334
|
reviewers: { type: 'array', items: { type: 'string' }, description: 'Reviewer usernames. Use bitbucket_search resource=users to look up valid usernames before setting this.' },
|
|
335
|
+
pickReviewers: { type: 'boolean', description: 'Show an interactive reviewer picker before creating the PR (lists users with repo access)' },
|
|
335
336
|
},
|
|
336
337
|
required: ['title'],
|
|
337
338
|
},
|
|
@@ -462,8 +463,69 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
462
463
|
if (!jira)
|
|
463
464
|
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
|
|
464
465
|
const a = args;
|
|
465
|
-
|
|
466
|
-
|
|
466
|
+
if (!a.issueKey && !a.query) {
|
|
467
|
+
throw new Error('Provide issueKey (e.g. FOO-123) or query (free-text search).');
|
|
468
|
+
}
|
|
469
|
+
// Resolve issueKey from free-text query if not provided directly
|
|
470
|
+
let issueKey = a.issueKey;
|
|
471
|
+
if (!issueKey && a.query) {
|
|
472
|
+
const candidates = await jira.findIssues(a.query, 10);
|
|
473
|
+
if (candidates.length === 0) {
|
|
474
|
+
return { content: [{ type: 'text', text: `No Jira tickets found for: "${a.query}"` }] };
|
|
475
|
+
}
|
|
476
|
+
if (candidates.length === 1) {
|
|
477
|
+
issueKey = candidates[0].key;
|
|
478
|
+
}
|
|
479
|
+
else {
|
|
480
|
+
// Multiple matches — present a picker
|
|
481
|
+
const pickerMessage = [
|
|
482
|
+
`Found ${candidates.length} tickets matching "${a.query}". Which one do you want to work on?`,
|
|
483
|
+
...candidates.map((c, i) => `${i + 1}. [${c.key}] ${c.summary} (${c.status})`),
|
|
484
|
+
].join('\n');
|
|
485
|
+
try {
|
|
486
|
+
const pickerResult = await server.elicitInput({
|
|
487
|
+
message: pickerMessage,
|
|
488
|
+
requestedSchema: {
|
|
489
|
+
type: 'object',
|
|
490
|
+
properties: {
|
|
491
|
+
ticket: {
|
|
492
|
+
type: 'string',
|
|
493
|
+
title: 'Select a ticket',
|
|
494
|
+
oneOf: [
|
|
495
|
+
...candidates.map((c) => ({ const: c.key, title: `[${c.key}] ${c.summary}` })),
|
|
496
|
+
{ const: '__cancel__', title: 'Cancel' },
|
|
497
|
+
],
|
|
498
|
+
},
|
|
499
|
+
},
|
|
500
|
+
required: ['ticket'],
|
|
501
|
+
},
|
|
502
|
+
});
|
|
503
|
+
if (pickerResult.action === 'cancel' ||
|
|
504
|
+
pickerResult.action === 'decline' ||
|
|
505
|
+
pickerResult.content?.ticket === '__cancel__') {
|
|
506
|
+
return { content: [{ type: 'text', text: 'Cancelled.' }] };
|
|
507
|
+
}
|
|
508
|
+
issueKey = pickerResult.content?.ticket;
|
|
509
|
+
if (!issueKey || issueKey === '__cancel__') {
|
|
510
|
+
return { content: [{ type: 'text', text: 'Cancelled.' }] };
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
catch {
|
|
514
|
+
// Client doesn't support elicitation — list options and ask caller to retry with issueKey
|
|
515
|
+
const list = candidates.map((c) => ` • ${c.key} — ${c.summary} (${c.status})`).join('\n');
|
|
516
|
+
return {
|
|
517
|
+
content: [{
|
|
518
|
+
type: 'text',
|
|
519
|
+
text: `Found ${candidates.length} tickets matching "${a.query}":\n${list}\n\nRe-run start_work with the desired issueKey.`,
|
|
520
|
+
}],
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
if (!issueKey)
|
|
526
|
+
throw new Error('Could not resolve issue key.');
|
|
527
|
+
const fields = await jira.getIssueFields(issueKey);
|
|
528
|
+
const branchName = a.branchName ?? slugifyBranchName(issueKey, fields.summary, fields.type);
|
|
467
529
|
const repoPath = a.repoPath ?? process.cwd();
|
|
468
530
|
// Check if branch already exists on remote before creating
|
|
469
531
|
const remote = checkRemoteBranch(branchName, repoPath);
|
|
@@ -475,7 +537,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
475
537
|
const contextLines = [authorLine, commitLine].filter(Boolean).join('\n');
|
|
476
538
|
const message = [
|
|
477
539
|
`Branch "${branchName}" already exists on remote.`,
|
|
478
|
-
`Ticket: ${
|
|
540
|
+
`Ticket: ${issueKey} — ${fields.summary}`,
|
|
479
541
|
contextLines,
|
|
480
542
|
].filter(Boolean).join('\n');
|
|
481
543
|
try {
|
|
@@ -509,7 +571,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
509
571
|
if (chosen === 'cancel') {
|
|
510
572
|
return { content: [{ type: 'text', text: 'Cancelled.' }] };
|
|
511
573
|
}
|
|
512
|
-
// new_name — instruct the model to re-run with a custom name
|
|
513
574
|
return {
|
|
514
575
|
content: [{
|
|
515
576
|
type: 'text',
|
|
@@ -517,11 +578,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
517
578
|
}],
|
|
518
579
|
};
|
|
519
580
|
}
|
|
520
|
-
// Fallback: unknown action
|
|
521
581
|
return { content: [{ type: 'text', text: 'Cancelled.' }] };
|
|
522
582
|
}
|
|
523
583
|
catch {
|
|
524
|
-
// Client doesn't support elicitation — fall back to informational text
|
|
525
584
|
return {
|
|
526
585
|
content: [{
|
|
527
586
|
type: 'text',
|
|
@@ -543,21 +602,52 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
543
602
|
push: a.push ?? false,
|
|
544
603
|
});
|
|
545
604
|
const lines = [
|
|
546
|
-
`Ticket: ${
|
|
605
|
+
`Ticket: ${issueKey} — ${fields.summary}`,
|
|
547
606
|
`Status: ${fields.status}`,
|
|
548
607
|
branchResult.content[0].text,
|
|
549
608
|
];
|
|
550
609
|
if (a.transitionName) {
|
|
551
610
|
try {
|
|
552
|
-
await jira.mutateIssue({ issueKey
|
|
611
|
+
await jira.mutateIssue({ issueKey, transitionName: a.transitionName });
|
|
553
612
|
lines.push(`Jira: transitioned → ${a.transitionName}`);
|
|
554
613
|
}
|
|
555
614
|
catch (err) {
|
|
556
615
|
lines.push(`Jira: could not transition — ${err.message}`);
|
|
557
616
|
}
|
|
558
617
|
}
|
|
559
|
-
|
|
560
|
-
|
|
618
|
+
// Fetch README from Bitbucket for project conventions
|
|
619
|
+
if (bitbucket) {
|
|
620
|
+
try {
|
|
621
|
+
const remoteUrl = (() => {
|
|
622
|
+
try {
|
|
623
|
+
return execFileSync('git', ['remote', 'get-url', 'origin'], { cwd: repoPath, encoding: 'utf-8' }).trim();
|
|
624
|
+
}
|
|
625
|
+
catch {
|
|
626
|
+
return '';
|
|
627
|
+
}
|
|
628
|
+
})();
|
|
629
|
+
const parsed = parseBitbucketRemote(remoteUrl);
|
|
630
|
+
if (parsed) {
|
|
631
|
+
const readme = await bitbucket.fetchFileText(parsed.projectKey, parsed.repoSlug, 'README.md');
|
|
632
|
+
if (readme) {
|
|
633
|
+
const maxLen = 4000;
|
|
634
|
+
const truncated = readme.length > maxLen ? readme.slice(0, maxLen) + '\n... (truncated)' : readme;
|
|
635
|
+
lines.push('');
|
|
636
|
+
lines.push('Project conventions (from README.md):');
|
|
637
|
+
lines.push('────────────────────────────────────');
|
|
638
|
+
lines.push(truncated);
|
|
639
|
+
lines.push('────────────────────────────────────');
|
|
640
|
+
lines.push('Follow the conventions above when writing commit messages and the PR description.');
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
catch { /* README fetch is best-effort */ }
|
|
645
|
+
lines.push('');
|
|
646
|
+
lines.push('Next steps:');
|
|
647
|
+
lines.push(' 1. Make your changes and commit following the project conventions.');
|
|
648
|
+
lines.push(' 2. Use bitbucket_mutate (create) to open a PR — the Jira summary and ticket key make a good title/description starting point.');
|
|
649
|
+
lines.push(' 3. Add reviewers: bitbucket_search resource=users to find colleagues, or pass pickReviewers=true in create to get an interactive picker.');
|
|
650
|
+
}
|
|
561
651
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
562
652
|
}
|
|
563
653
|
// Jira
|
|
@@ -636,6 +726,42 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
636
726
|
return await bitbucket.declinePr({ ...a, message: a.declineMessage });
|
|
637
727
|
if (action === 'merge')
|
|
638
728
|
return await bitbucket.mergePr({ ...a, message: a.mergeMessage, mergeStrategy: a.mergeStrategy });
|
|
729
|
+
// Handle interactive reviewer picker for PR creation
|
|
730
|
+
const createArgs = a.create;
|
|
731
|
+
if (createArgs?.pickReviewers && !a.prId) {
|
|
732
|
+
const projectKey = a.projectKey;
|
|
733
|
+
const repoSlug = a.repoSlug;
|
|
734
|
+
const users = await bitbucket.searchUsersRaw({ projectKey, repoSlug, limit: 30 });
|
|
735
|
+
if (users.length > 0) {
|
|
736
|
+
// Build boolean checkbox schema — one field per user
|
|
737
|
+
// key must be safe for JSON schema property names
|
|
738
|
+
const toSchemaKey = (uname) => uname.replace(/[^a-zA-Z0-9]/g, '_');
|
|
739
|
+
const userMap = new Map(); // schemaKey -> username
|
|
740
|
+
const properties = {};
|
|
741
|
+
for (const u of users) {
|
|
742
|
+
const key = toSchemaKey(u.name);
|
|
743
|
+
userMap.set(key, u.name);
|
|
744
|
+
properties[key] = { type: 'boolean', title: `${u.displayName} (${u.name})` };
|
|
745
|
+
}
|
|
746
|
+
try {
|
|
747
|
+
const pickerResult = await server.elicitInput({
|
|
748
|
+
message: 'Select reviewers to add to this PR:',
|
|
749
|
+
requestedSchema: { type: 'object', properties },
|
|
750
|
+
});
|
|
751
|
+
if (pickerResult.action === 'accept' && pickerResult.content) {
|
|
752
|
+
const selected = [];
|
|
753
|
+
for (const [key, username] of userMap) {
|
|
754
|
+
if (pickerResult.content[key] === true)
|
|
755
|
+
selected.push(username);
|
|
756
|
+
}
|
|
757
|
+
createArgs.reviewers = selected;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
catch {
|
|
761
|
+
// elicitation not supported — proceed without reviewer picker
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
}
|
|
639
765
|
return await bitbucket.mutatePullRequest(a);
|
|
640
766
|
}
|
|
641
767
|
case 'bitbucket_comment': {
|
package/dist/jira.js
CHANGED
|
@@ -285,6 +285,22 @@ export class JiraClient {
|
|
|
285
285
|
const page = pagination(data.total, startAt, data.issues.length);
|
|
286
286
|
return text(`Found ${data.total} issues${page}:\n${lines.join('\n')}`);
|
|
287
287
|
}
|
|
288
|
+
async findIssues(query, maxResults = 10) {
|
|
289
|
+
const jql = buildJQL({ query });
|
|
290
|
+
const params = new URLSearchParams({
|
|
291
|
+
jql,
|
|
292
|
+
maxResults: String(maxResults),
|
|
293
|
+
startAt: '0',
|
|
294
|
+
fields: 'summary,status,issuetype',
|
|
295
|
+
});
|
|
296
|
+
const data = await this.request('GET', `/search?${params}`);
|
|
297
|
+
return (data?.issues ?? []).map((i) => ({
|
|
298
|
+
key: i.key,
|
|
299
|
+
summary: i.fields.summary,
|
|
300
|
+
status: i.fields.status.name,
|
|
301
|
+
type: i.fields.issuetype.name,
|
|
302
|
+
}));
|
|
303
|
+
}
|
|
288
304
|
async myIssues(args) {
|
|
289
305
|
return this.searchIssues({
|
|
290
306
|
jql: 'assignee = currentUser() ORDER BY updated DESC',
|