@rui.branco/jira-mcp 1.6.7 → 1.6.9
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/index.js +594 -66
- package/package.json +1 -1
- package/setup.js +186 -17
package/index.js
CHANGED
|
@@ -19,33 +19,92 @@ const path = require("path");
|
|
|
19
19
|
const fetch = require("node-fetch");
|
|
20
20
|
const { spawn, execSync } = require("child_process");
|
|
21
21
|
|
|
22
|
-
// Auto-update:
|
|
23
|
-
const
|
|
24
|
-
const
|
|
22
|
+
// Auto-update: check GitHub for new commits, install in background
|
|
23
|
+
const GITHUB_REPO = "rui-branco/jira-mcp";
|
|
24
|
+
const INSTALLED_SHA_FILE = path.join(__dirname, ".installed-sha");
|
|
25
25
|
try {
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
const localSha = fs.existsSync(INSTALLED_SHA_FILE)
|
|
27
|
+
? fs.readFileSync(INSTALLED_SHA_FILE, "utf-8").trim()
|
|
28
|
+
: "";
|
|
29
|
+
const remoteSha = execSync(
|
|
30
|
+
`git ls-remote https://github.com/${GITHUB_REPO}.git HEAD`,
|
|
31
|
+
{ stdio: "pipe", timeout: 5000 },
|
|
32
|
+
)
|
|
30
33
|
.toString()
|
|
34
|
+
.split("\t")[0]
|
|
31
35
|
.trim();
|
|
32
|
-
if (
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
36
|
+
if (remoteSha && remoteSha !== localSha) {
|
|
37
|
+
const child = spawn(
|
|
38
|
+
"sh",
|
|
39
|
+
[
|
|
40
|
+
"-c",
|
|
41
|
+
`npm install -g git+ssh://git@github.com/${GITHUB_REPO}.git && echo "${remoteSha}" > "${INSTALLED_SHA_FILE}"`,
|
|
42
|
+
],
|
|
43
|
+
{ stdio: "ignore", detached: true },
|
|
44
|
+
);
|
|
45
|
+
child.unref();
|
|
37
46
|
}
|
|
38
47
|
} catch {}
|
|
39
48
|
|
|
40
|
-
// Load Jira config
|
|
49
|
+
// Load Jira config (supports single-instance and multi-instance formats)
|
|
41
50
|
const jiraConfigPath = path.join(
|
|
42
51
|
process.env.HOME,
|
|
43
52
|
".config/jira-mcp/config.json",
|
|
44
53
|
);
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
54
|
+
const rawConfig = JSON.parse(fs.readFileSync(jiraConfigPath, "utf8"));
|
|
55
|
+
|
|
56
|
+
// Normalize config to multi-instance format
|
|
57
|
+
let instances;
|
|
58
|
+
if (rawConfig.instances) {
|
|
59
|
+
// New multi-instance format
|
|
60
|
+
instances = rawConfig.instances.map((inst) => ({
|
|
61
|
+
...inst,
|
|
62
|
+
projects: (inst.projects || []).map((p) => p.toUpperCase()),
|
|
63
|
+
auth: Buffer.from(`${inst.email}:${inst.token}`).toString("base64"),
|
|
64
|
+
}));
|
|
65
|
+
} else {
|
|
66
|
+
// Old single-instance format — wrap as array
|
|
67
|
+
instances = [
|
|
68
|
+
{
|
|
69
|
+
name: "default",
|
|
70
|
+
email: rawConfig.email,
|
|
71
|
+
token: rawConfig.token,
|
|
72
|
+
baseUrl: rawConfig.baseUrl,
|
|
73
|
+
projects: [],
|
|
74
|
+
auth: Buffer.from(`${rawConfig.email}:${rawConfig.token}`).toString("base64"),
|
|
75
|
+
},
|
|
76
|
+
];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Resolve default instance
|
|
80
|
+
const defaultInstance =
|
|
81
|
+
(rawConfig.defaultInstance && instances.find((i) => i.name === rawConfig.defaultInstance)) ||
|
|
82
|
+
instances[0];
|
|
83
|
+
|
|
84
|
+
// Re-read config file (for persisting changes)
|
|
85
|
+
function loadConfigFile() {
|
|
86
|
+
try {
|
|
87
|
+
return JSON.parse(fs.readFileSync(jiraConfigPath, "utf8"));
|
|
88
|
+
} catch {
|
|
89
|
+
return {};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Instance resolution helpers
|
|
94
|
+
function getInstanceForProject(projectPrefix) {
|
|
95
|
+
const upper = projectPrefix.toUpperCase();
|
|
96
|
+
return instances.find((i) => i.projects.includes(upper)) || defaultInstance;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function getInstanceForKey(issueKey) {
|
|
100
|
+
const prefix = issueKey.split("-")[0];
|
|
101
|
+
return getInstanceForProject(prefix);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function getInstanceByName(name) {
|
|
105
|
+
if (!name) return defaultInstance;
|
|
106
|
+
return instances.find((i) => i.name === name) || defaultInstance;
|
|
107
|
+
}
|
|
49
108
|
|
|
50
109
|
// Load Figma config (optional)
|
|
51
110
|
let figmaConfig = null;
|
|
@@ -77,16 +136,16 @@ if (!fs.existsSync(attachmentDir)) {
|
|
|
77
136
|
|
|
78
137
|
// ============ JIRA FUNCTIONS ============
|
|
79
138
|
|
|
80
|
-
async function fetchJira(endpoint, options = {}) {
|
|
139
|
+
async function fetchJira(endpoint, options = {}, instance = defaultInstance) {
|
|
81
140
|
const { method = "GET", body } = options;
|
|
82
141
|
const headers = {
|
|
83
|
-
Authorization: `Basic ${auth}`,
|
|
142
|
+
Authorization: `Basic ${instance.auth}`,
|
|
84
143
|
Accept: "application/json",
|
|
85
144
|
};
|
|
86
145
|
if (body) {
|
|
87
146
|
headers["Content-Type"] = "application/json";
|
|
88
147
|
}
|
|
89
|
-
const response = await fetch(`${
|
|
148
|
+
const response = await fetch(`${instance.baseUrl}/rest/api/3${endpoint}`, {
|
|
90
149
|
method,
|
|
91
150
|
headers,
|
|
92
151
|
body: body ? JSON.stringify(body) : undefined,
|
|
@@ -101,7 +160,7 @@ async function fetchJira(endpoint, options = {}) {
|
|
|
101
160
|
return text ? JSON.parse(text) : {};
|
|
102
161
|
}
|
|
103
162
|
|
|
104
|
-
async function downloadAttachment(url, filename, issueKey) {
|
|
163
|
+
async function downloadAttachment(url, filename, issueKey, instance) {
|
|
105
164
|
const issueDir = path.join(attachmentDir, issueKey);
|
|
106
165
|
if (!fs.existsSync(issueDir)) {
|
|
107
166
|
fs.mkdirSync(issueDir, { recursive: true });
|
|
@@ -113,8 +172,9 @@ async function downloadAttachment(url, filename, issueKey) {
|
|
|
113
172
|
return localPath;
|
|
114
173
|
}
|
|
115
174
|
|
|
175
|
+
const inst = instance || getInstanceForKey(issueKey);
|
|
116
176
|
const response = await fetch(url, {
|
|
117
|
-
headers: { Authorization: `Basic ${auth}` },
|
|
177
|
+
headers: { Authorization: `Basic ${inst.auth}` },
|
|
118
178
|
});
|
|
119
179
|
|
|
120
180
|
if (!response.ok) {
|
|
@@ -186,9 +246,9 @@ function extractTextSimple(content) {
|
|
|
186
246
|
// Cache for user lookups to avoid repeated API calls
|
|
187
247
|
const userCache = new Map();
|
|
188
248
|
|
|
189
|
-
async function searchUser(query) {
|
|
190
|
-
// Check cache first
|
|
191
|
-
const cacheKey = query.toLowerCase()
|
|
249
|
+
async function searchUser(query, instance = defaultInstance) {
|
|
250
|
+
// Check cache first (instance-aware)
|
|
251
|
+
const cacheKey = `${instance.name}:${query.toLowerCase()}`;
|
|
192
252
|
if (userCache.has(cacheKey)) {
|
|
193
253
|
return userCache.get(cacheKey);
|
|
194
254
|
}
|
|
@@ -197,6 +257,8 @@ async function searchUser(query) {
|
|
|
197
257
|
// Search for users by display name
|
|
198
258
|
const users = await fetchJira(
|
|
199
259
|
`/user/search?query=${encodeURIComponent(query)}&maxResults=5`,
|
|
260
|
+
{},
|
|
261
|
+
instance,
|
|
200
262
|
);
|
|
201
263
|
if (users && users.length > 0) {
|
|
202
264
|
// Find best match - prefer exact match, then starts with, then contains
|
|
@@ -223,7 +285,7 @@ async function searchUser(query) {
|
|
|
223
285
|
|
|
224
286
|
// Parse text with @mentions and build ADF content
|
|
225
287
|
// Parse inline formatting: **bold**, *italic*, @mentions
|
|
226
|
-
async function parseInlineFormatting(text) {
|
|
288
|
+
async function parseInlineFormatting(text, instance = defaultInstance) {
|
|
227
289
|
const nodes = [];
|
|
228
290
|
// Bold (**) must come before italic (*) in alternation, backticks for inline code
|
|
229
291
|
const regex = /(`(.+?)`|\*\*(.+?)\*\*|\*(.+?)\*|@([A-Z][a-zA-Zà-ÿ]*(?:\s[A-Z][a-zA-Zà-ÿ]*)*))/g;
|
|
@@ -247,7 +309,7 @@ async function parseInlineFormatting(text) {
|
|
|
247
309
|
nodes.push({ type: "text", text: match[4], marks: [{ type: "em" }] });
|
|
248
310
|
} else if (match[5] !== undefined) {
|
|
249
311
|
// @Mention
|
|
250
|
-
const user = await searchUser(match[5].trim());
|
|
312
|
+
const user = await searchUser(match[5].trim(), instance);
|
|
251
313
|
if (user) {
|
|
252
314
|
nodes.push({
|
|
253
315
|
type: "mention",
|
|
@@ -269,7 +331,9 @@ async function parseInlineFormatting(text) {
|
|
|
269
331
|
}
|
|
270
332
|
|
|
271
333
|
// Parse text with markdown formatting and @mentions, build ADF content
|
|
272
|
-
async function buildCommentADF(text) {
|
|
334
|
+
async function buildCommentADF(text, instance = defaultInstance) {
|
|
335
|
+
// Sanitize: replace em dashes and en dashes with hyphen
|
|
336
|
+
text = text.replace(/[—–]/g, "-");
|
|
273
337
|
// Split into blocks by double newlines (paragraphs)
|
|
274
338
|
const blocks = text.split(/\n\n+/);
|
|
275
339
|
const content = [];
|
|
@@ -286,7 +350,7 @@ async function buildCommentADF(text) {
|
|
|
286
350
|
const listItems = [];
|
|
287
351
|
for (const line of lines) {
|
|
288
352
|
const itemText = line.trimStart().substring(2);
|
|
289
|
-
const inlineContent = await parseInlineFormatting(itemText);
|
|
353
|
+
const inlineContent = await parseInlineFormatting(itemText, instance);
|
|
290
354
|
listItems.push({
|
|
291
355
|
type: "listItem",
|
|
292
356
|
content: [{ type: "paragraph", content: inlineContent }],
|
|
@@ -298,7 +362,7 @@ async function buildCommentADF(text) {
|
|
|
298
362
|
const paragraphContent = [];
|
|
299
363
|
for (let i = 0; i < lines.length; i++) {
|
|
300
364
|
if (i > 0) paragraphContent.push({ type: "hardBreak" });
|
|
301
|
-
const inlineNodes = await parseInlineFormatting(lines[i]);
|
|
365
|
+
const inlineNodes = await parseInlineFormatting(lines[i], instance);
|
|
302
366
|
paragraphContent.push(...inlineNodes);
|
|
303
367
|
}
|
|
304
368
|
content.push({ type: "paragraph", content: paragraphContent });
|
|
@@ -581,9 +645,11 @@ async function fetchFigmaDesign(url) {
|
|
|
581
645
|
|
|
582
646
|
// ============ MAIN TICKET FUNCTION ============
|
|
583
647
|
|
|
584
|
-
async function getTicket(issueKey, downloadImages = true, fetchFigma = true) {
|
|
585
|
-
|
|
648
|
+
async function getTicket(issueKey, downloadImages = true, fetchFigma = true, instance = null) {
|
|
649
|
+
instance = instance || getInstanceForKey(issueKey);
|
|
650
|
+
const issue = await fetchJira(`/issue/${issueKey}?expand=renderedFields`, {}, instance);
|
|
586
651
|
const fields = issue.fields;
|
|
652
|
+
const storyPoints = fields.customfield_10016 ?? fields.story_points ?? null;
|
|
587
653
|
|
|
588
654
|
let output = `# ${issueKey}: ${fields.summary}\n\n`;
|
|
589
655
|
output += `**Status:** ${fields.status?.name || "Unknown"}\n`;
|
|
@@ -592,6 +658,15 @@ async function getTicket(issueKey, downloadImages = true, fetchFigma = true) {
|
|
|
592
658
|
output += `**Assignee:** ${fields.assignee?.displayName || "Unassigned"}\n`;
|
|
593
659
|
output += `**Reporter:** ${fields.reporter?.displayName || "Unknown"}\n`;
|
|
594
660
|
|
|
661
|
+
// Date fields
|
|
662
|
+
if (fields.created) output += `**Created:** ${fields.created}\n`;
|
|
663
|
+
if (fields.updated) output += `**Updated:** ${fields.updated}\n`;
|
|
664
|
+
if (fields.resolutiondate) output += `**Resolved:** ${fields.resolutiondate}\n`;
|
|
665
|
+
if (fields.resolution) output += `**Resolution:** ${fields.resolution.name}\n`;
|
|
666
|
+
|
|
667
|
+
// Story points
|
|
668
|
+
if (storyPoints != null) output += `**Story Points:** ${storyPoints}\n`;
|
|
669
|
+
|
|
595
670
|
if (fields.sprint) {
|
|
596
671
|
output += `**Sprint:** ${fields.sprint.name}\n`;
|
|
597
672
|
}
|
|
@@ -600,6 +675,16 @@ async function getTicket(issueKey, downloadImages = true, fetchFigma = true) {
|
|
|
600
675
|
output += `**Parent:** ${fields.parent.key} - ${fields.parent.fields?.summary || ""}\n`;
|
|
601
676
|
}
|
|
602
677
|
|
|
678
|
+
// Labels, Components, Fix Versions
|
|
679
|
+
if (fields.labels?.length > 0) output += `**Labels:** ${fields.labels.join(", ")}\n`;
|
|
680
|
+
if (fields.components?.length > 0) output += `**Components:** ${fields.components.map(c => c.name).join(", ")}\n`;
|
|
681
|
+
if (fields.fixVersions?.length > 0) output += `**Fix Versions:** ${fields.fixVersions.map(v => v.name).join(", ")}\n`;
|
|
682
|
+
|
|
683
|
+
// Time tracking
|
|
684
|
+
if (fields.timetracking && (fields.timetracking.originalEstimate || fields.timetracking.timeSpent)) {
|
|
685
|
+
output += `**Time Tracking:** estimate=${fields.timetracking.originalEstimate || "none"}, spent=${fields.timetracking.timeSpent || "none"}, remaining=${fields.timetracking.remainingEstimate || "none"}\n`;
|
|
686
|
+
}
|
|
687
|
+
|
|
603
688
|
// Subtasks
|
|
604
689
|
if (fields.subtasks?.length > 0) {
|
|
605
690
|
output += `**Subtasks:** ${fields.subtasks.length}\n`;
|
|
@@ -620,7 +705,7 @@ async function getTicket(issueKey, downloadImages = true, fetchFigma = true) {
|
|
|
620
705
|
output += `\n## Parent Ticket: ${fields.parent.key}\n\n`;
|
|
621
706
|
try {
|
|
622
707
|
const parentIssue = await fetchJira(
|
|
623
|
-
`/issue/${fields.parent.key}?expand=renderedFields`,
|
|
708
|
+
`/issue/${fields.parent.key}?expand=renderedFields`, {}, instance,
|
|
624
709
|
);
|
|
625
710
|
const pf = parentIssue.fields;
|
|
626
711
|
|
|
@@ -685,6 +770,7 @@ async function getTicket(issueKey, downloadImages = true, fetchFigma = true) {
|
|
|
685
770
|
att.content,
|
|
686
771
|
att.filename,
|
|
687
772
|
issueKey,
|
|
773
|
+
instance,
|
|
688
774
|
);
|
|
689
775
|
output += ` Local: ${localPath}\n`;
|
|
690
776
|
downloadedImages.push(localPath);
|
|
@@ -707,7 +793,7 @@ async function getTicket(issueKey, downloadImages = true, fetchFigma = true) {
|
|
|
707
793
|
output += `Type: ${subtask.fields?.issuetype?.name || "Subtask"}\n`;
|
|
708
794
|
|
|
709
795
|
try {
|
|
710
|
-
const subtaskDetails = await fetchJira(`/issue/${subtask.key}
|
|
796
|
+
const subtaskDetails = await fetchJira(`/issue/${subtask.key}`, {}, instance);
|
|
711
797
|
const sf = subtaskDetails.fields;
|
|
712
798
|
|
|
713
799
|
if (sf.assignee) {
|
|
@@ -761,7 +847,7 @@ async function getTicket(issueKey, downloadImages = true, fetchFigma = true) {
|
|
|
761
847
|
|
|
762
848
|
try {
|
|
763
849
|
const linkedIssue = await fetchJira(
|
|
764
|
-
`/issue/${linked.key}?expand=renderedFields`,
|
|
850
|
+
`/issue/${linked.key}?expand=renderedFields`, {}, instance,
|
|
765
851
|
);
|
|
766
852
|
const lf = linkedIssue.fields;
|
|
767
853
|
|
|
@@ -833,7 +919,7 @@ async function getTicket(issueKey, downloadImages = true, fetchFigma = true) {
|
|
|
833
919
|
|
|
834
920
|
try {
|
|
835
921
|
const refIssue = await fetchJira(
|
|
836
|
-
`/issue/${refKey}?expand=renderedFields`,
|
|
922
|
+
`/issue/${refKey}?expand=renderedFields`, {}, instance,
|
|
837
923
|
);
|
|
838
924
|
const rf = refIssue.fields;
|
|
839
925
|
|
|
@@ -919,17 +1005,207 @@ async function getTicket(issueKey, downloadImages = true, fetchFigma = true) {
|
|
|
919
1005
|
return { text: output, jiraImages: downloadedImages, figmaDesigns };
|
|
920
1006
|
}
|
|
921
1007
|
|
|
922
|
-
async function searchTickets(jql, maxResults = 10) {
|
|
1008
|
+
async function searchTickets(jql, maxResults = 10, fields = null, instance = defaultInstance) {
|
|
1009
|
+
const defaultFields = [
|
|
1010
|
+
"summary", "status", "assignee", "reporter", "issuetype", "priority",
|
|
1011
|
+
"created", "resolutiondate", "updated", "statuscategorychangedate",
|
|
1012
|
+
"resolution", "timetracking", "aggregatetimeoriginalestimate",
|
|
1013
|
+
"aggregatetimespent", "parent", "labels", "components", "fixVersions",
|
|
1014
|
+
"customfield_10016", // story points (Jira Software)
|
|
1015
|
+
];
|
|
1016
|
+
const requestFields = fields || defaultFields;
|
|
923
1017
|
const data = await fetchJira(
|
|
924
|
-
`/search/jql?jql=${encodeURIComponent(jql)}&maxResults=${maxResults}`,
|
|
1018
|
+
`/search/jql?jql=${encodeURIComponent(jql)}&maxResults=${maxResults}&fields=${requestFields.join(",")}`,
|
|
1019
|
+
{},
|
|
1020
|
+
instance,
|
|
925
1021
|
);
|
|
926
1022
|
|
|
927
|
-
|
|
1023
|
+
const issues = data.issues || [];
|
|
1024
|
+
let output = `# Search Results (${data.total || 0} total, showing ${issues.length})\n\n`;
|
|
1025
|
+
|
|
1026
|
+
for (const issue of issues) {
|
|
1027
|
+
const f = issue.fields || {};
|
|
1028
|
+
const storyPoints = f.customfield_10016 ?? f.story_points ?? null;
|
|
1029
|
+
|
|
1030
|
+
output += `- **${issue.key}**: ${f.summary || "No summary"}\n`;
|
|
1031
|
+
output += ` Status: ${f.status?.name || "Unknown"} | Type: ${f.issuetype?.name || "Unknown"} | Priority: ${f.priority?.name || "None"}\n`;
|
|
1032
|
+
output += ` Assignee: ${f.assignee?.displayName || "Unassigned"} | Reporter: ${f.reporter?.displayName || "Unknown"}\n`;
|
|
1033
|
+
|
|
1034
|
+
if (f.created) output += ` Created: ${f.created}\n`;
|
|
1035
|
+
if (f.updated) output += ` Updated: ${f.updated}\n`;
|
|
1036
|
+
if (f.resolutiondate) output += ` Resolved: ${f.resolutiondate}\n`;
|
|
1037
|
+
if (f.resolution) output += ` Resolution: ${f.resolution.name}\n`;
|
|
1038
|
+
if (storyPoints != null) output += ` Story Points: ${storyPoints}\n`;
|
|
1039
|
+
if (f.parent) output += ` Parent: ${f.parent.key}${f.parent.fields?.summary ? ` - ${f.parent.fields.summary}` : ""}\n`;
|
|
1040
|
+
if (f.labels?.length > 0) output += ` Labels: ${f.labels.join(", ")}\n`;
|
|
1041
|
+
if (f.components?.length > 0) output += ` Components: ${f.components.map(c => c.name).join(", ")}\n`;
|
|
1042
|
+
if (f.fixVersions?.length > 0) output += ` Fix Versions: ${f.fixVersions.map(v => v.name).join(", ")}\n`;
|
|
1043
|
+
if (f.timetracking && (f.timetracking.originalEstimate || f.timetracking.timeSpent)) {
|
|
1044
|
+
output += ` Time Tracking: estimate=${f.timetracking.originalEstimate || "none"}, spent=${f.timetracking.timeSpent || "none"}, remaining=${f.timetracking.remainingEstimate || "none"}\n`;
|
|
1045
|
+
}
|
|
928
1046
|
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
1047
|
+
output += "\n";
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
return output;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// ============ CHANGELOG FUNCTIONS ============
|
|
1054
|
+
|
|
1055
|
+
function parseStatusHistory(changelog) {
|
|
1056
|
+
const statusChanges = [];
|
|
1057
|
+
if (!changelog?.histories) return statusChanges;
|
|
1058
|
+
|
|
1059
|
+
for (const history of changelog.histories) {
|
|
1060
|
+
for (const item of history.items || []) {
|
|
1061
|
+
if (item.field === "status") {
|
|
1062
|
+
statusChanges.push({
|
|
1063
|
+
from: item.fromString,
|
|
1064
|
+
to: item.toString,
|
|
1065
|
+
date: history.created,
|
|
1066
|
+
author: history.author?.displayName || "Unknown",
|
|
1067
|
+
});
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
// Sort chronologically
|
|
1073
|
+
statusChanges.sort((a, b) => new Date(a.date) - new Date(b.date));
|
|
1074
|
+
return statusChanges;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
function computeMetrics(statusHistory, created) {
|
|
1078
|
+
if (!statusHistory.length) return null;
|
|
1079
|
+
|
|
1080
|
+
const metrics = {
|
|
1081
|
+
cycleTime: null,
|
|
1082
|
+
leadTime: null,
|
|
1083
|
+
timesInProgress: 0,
|
|
1084
|
+
timeInStatus: {},
|
|
1085
|
+
};
|
|
1086
|
+
|
|
1087
|
+
// Build timeline: start with created date in the initial status
|
|
1088
|
+
const timeline = [];
|
|
1089
|
+
if (created && statusHistory.length > 0) {
|
|
1090
|
+
timeline.push({ status: statusHistory[0].from, date: new Date(created) });
|
|
1091
|
+
}
|
|
1092
|
+
for (const change of statusHistory) {
|
|
1093
|
+
timeline.push({ status: change.to, date: new Date(change.date) });
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
// Calculate time in each status
|
|
1097
|
+
for (let i = 0; i < timeline.length - 1; i++) {
|
|
1098
|
+
const status = timeline[i].status;
|
|
1099
|
+
const duration = timeline[i + 1].date - timeline[i].date;
|
|
1100
|
+
metrics.timeInStatus[status] = (metrics.timeInStatus[status] || 0) + duration;
|
|
1101
|
+
}
|
|
1102
|
+
// Add current status (time since last transition)
|
|
1103
|
+
const last = timeline[timeline.length - 1];
|
|
1104
|
+
const sinceLastTransition = Date.now() - last.date;
|
|
1105
|
+
metrics.timeInStatus[last.status] = (metrics.timeInStatus[last.status] || 0) + sinceLastTransition;
|
|
1106
|
+
|
|
1107
|
+
// Count times in progress-like statuses
|
|
1108
|
+
metrics.timesInProgress = statusHistory.filter(
|
|
1109
|
+
(c) => c.to.toLowerCase().includes("progress"),
|
|
1110
|
+
).length;
|
|
1111
|
+
|
|
1112
|
+
// Cycle time: first "In Progress" to last "Done"
|
|
1113
|
+
const firstInProgress = statusHistory.find(
|
|
1114
|
+
(c) => c.to.toLowerCase().includes("progress"),
|
|
1115
|
+
);
|
|
1116
|
+
const lastDone = [...statusHistory].reverse().find(
|
|
1117
|
+
(c) => c.to.toLowerCase() === "done" || c.to.toLowerCase() === "closed",
|
|
1118
|
+
);
|
|
1119
|
+
if (firstInProgress && lastDone) {
|
|
1120
|
+
metrics.cycleTime = new Date(lastDone.date) - new Date(firstInProgress.date);
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// Lead time: created to done
|
|
1124
|
+
if (created && lastDone) {
|
|
1125
|
+
metrics.leadTime = new Date(lastDone.date) - new Date(created);
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
// Format durations
|
|
1129
|
+
const fmt = (ms) => {
|
|
1130
|
+
if (ms == null) return null;
|
|
1131
|
+
const totalMinutes = Math.floor(ms / 60000);
|
|
1132
|
+
const days = Math.floor(totalMinutes / (60 * 24));
|
|
1133
|
+
const hours = Math.floor((totalMinutes % (60 * 24)) / 60);
|
|
1134
|
+
const minutes = totalMinutes % 60;
|
|
1135
|
+
const parts = [];
|
|
1136
|
+
if (days > 0) parts.push(`${days}d`);
|
|
1137
|
+
if (hours > 0) parts.push(`${hours}h`);
|
|
1138
|
+
parts.push(`${minutes}m`);
|
|
1139
|
+
return parts.join(" ");
|
|
1140
|
+
};
|
|
1141
|
+
|
|
1142
|
+
return {
|
|
1143
|
+
cycleTime: fmt(metrics.cycleTime),
|
|
1144
|
+
leadTime: fmt(metrics.leadTime),
|
|
1145
|
+
timesInProgress: metrics.timesInProgress,
|
|
1146
|
+
timeInStatus: Object.fromEntries(
|
|
1147
|
+
Object.entries(metrics.timeInStatus).map(([k, v]) => [k, fmt(v)]),
|
|
1148
|
+
),
|
|
1149
|
+
};
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
function formatChangelog(issueKey, statusHistory, created) {
|
|
1153
|
+
let output = `## Status History for ${issueKey}\n\n`;
|
|
1154
|
+
|
|
1155
|
+
if (statusHistory.length === 0) {
|
|
1156
|
+
output += "_No status transitions found._\n";
|
|
1157
|
+
return output;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
for (const change of statusHistory) {
|
|
1161
|
+
const date = new Date(change.date).toLocaleString();
|
|
1162
|
+
output += `- **${change.from}** → **${change.to}** (${date}, by ${change.author})\n`;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
const computed = computeMetrics(statusHistory, created);
|
|
1166
|
+
if (computed) {
|
|
1167
|
+
output += `\n### Computed Metrics\n\n`;
|
|
1168
|
+
if (computed.cycleTime) output += `- **Cycle Time:** ${computed.cycleTime}\n`;
|
|
1169
|
+
if (computed.leadTime) output += `- **Lead Time:** ${computed.leadTime}\n`;
|
|
1170
|
+
output += `- **Times In Progress:** ${computed.timesInProgress}\n`;
|
|
1171
|
+
if (Object.keys(computed.timeInStatus).length > 0) {
|
|
1172
|
+
output += `- **Time in Status:**\n`;
|
|
1173
|
+
for (const [status, time] of Object.entries(computed.timeInStatus)) {
|
|
1174
|
+
output += ` - ${status}: ${time}\n`;
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
return output;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
async function getChangelog(issueKey, instance = null) {
|
|
1183
|
+
instance = instance || getInstanceForKey(issueKey);
|
|
1184
|
+
const issue = await fetchJira(`/issue/${issueKey}?expand=changelog&fields=created`, {}, instance);
|
|
1185
|
+
const statusHistory = parseStatusHistory(issue.changelog);
|
|
1186
|
+
const created = issue.fields?.created;
|
|
1187
|
+
return { statusHistory, created, formatted: formatChangelog(issueKey, statusHistory, created) };
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
async function getChangelogsBulk(jql, maxResults = 50, instance = defaultInstance) {
|
|
1191
|
+
// First get the issue keys matching the JQL
|
|
1192
|
+
const data = await fetchJira(
|
|
1193
|
+
`/search/jql?jql=${encodeURIComponent(jql)}&maxResults=${maxResults}&fields=key,created`,
|
|
1194
|
+
{},
|
|
1195
|
+
instance,
|
|
1196
|
+
);
|
|
1197
|
+
const issues = data.issues || [];
|
|
1198
|
+
|
|
1199
|
+
let output = `# Changelogs (${issues.length} of ${data.total || 0} issues)\n\n`;
|
|
1200
|
+
|
|
1201
|
+
// Fetch changelog for each issue (JIRA search API doesn't support expand=changelog on /search/jql)
|
|
1202
|
+
for (const issue of issues) {
|
|
1203
|
+
try {
|
|
1204
|
+
const result = await getChangelog(issue.key, instance);
|
|
1205
|
+
output += result.formatted + "\n";
|
|
1206
|
+
} catch (e) {
|
|
1207
|
+
output += `## ${issue.key}\n\n_Error fetching changelog: ${e.message}_\n\n`;
|
|
1208
|
+
}
|
|
933
1209
|
}
|
|
934
1210
|
|
|
935
1211
|
return output;
|
|
@@ -948,10 +1224,15 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
948
1224
|
{
|
|
949
1225
|
name: "jira_get_myself",
|
|
950
1226
|
description:
|
|
951
|
-
"Get the current authenticated user's info including accountId. Use this to get your account ID for assigning tickets.",
|
|
1227
|
+
"Get the current authenticated user's info including accountId. Use this to get your account ID for assigning tickets. When multiple Jira instances are configured, use the 'instance' parameter to specify which one.",
|
|
952
1228
|
inputSchema: {
|
|
953
1229
|
type: "object",
|
|
954
|
-
properties: {
|
|
1230
|
+
properties: {
|
|
1231
|
+
instance: {
|
|
1232
|
+
type: "string",
|
|
1233
|
+
description: "Instance name (for multi-instance setups). Uses default instance if omitted.",
|
|
1234
|
+
},
|
|
1235
|
+
},
|
|
955
1236
|
required: [],
|
|
956
1237
|
},
|
|
957
1238
|
},
|
|
@@ -982,7 +1263,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
982
1263
|
{
|
|
983
1264
|
name: "jira_search",
|
|
984
1265
|
description:
|
|
985
|
-
"Search Jira tickets using JQL. Examples: 'project = MODS AND status = Open'",
|
|
1266
|
+
"Search Jira tickets using JQL. Returns detailed fields including dates, story points, labels, components, fix versions, time tracking, and parent. Examples: 'project = MODS AND status = Open'. When multiple Jira instances are configured, use the 'instance' parameter to specify which one.",
|
|
986
1267
|
inputSchema: {
|
|
987
1268
|
type: "object",
|
|
988
1269
|
properties: {
|
|
@@ -991,6 +1272,16 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
991
1272
|
type: "number",
|
|
992
1273
|
description: "Max results (default 10)",
|
|
993
1274
|
},
|
|
1275
|
+
fields: {
|
|
1276
|
+
type: "array",
|
|
1277
|
+
items: { type: "string" },
|
|
1278
|
+
description:
|
|
1279
|
+
"Custom list of fields to return. Default includes: summary, status, assignee, reporter, issuetype, priority, created, resolutiondate, updated, resolution, timetracking, parent, labels, components, fixVersions, customfield_10016 (story points).",
|
|
1280
|
+
},
|
|
1281
|
+
instance: {
|
|
1282
|
+
type: "string",
|
|
1283
|
+
description: "Instance name (for multi-instance setups). Uses default instance if omitted.",
|
|
1284
|
+
},
|
|
994
1285
|
},
|
|
995
1286
|
required: ["jql"],
|
|
996
1287
|
},
|
|
@@ -998,7 +1289,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
998
1289
|
{
|
|
999
1290
|
name: "jira_add_comment",
|
|
1000
1291
|
description:
|
|
1001
|
-
"Add a comment to a Jira ticket. IMPORTANT: Use @DisplayName (e.g. @Julia Pereszta) for mentions — NOT [~accountId:...] syntax. Keep comments non-technical and user-facing. Never mention git details like 'pushed to main', branch names, or technical implementation details — stakeholders don't care about that.",
|
|
1292
|
+
"Add a comment to a Jira ticket. IMPORTANT: Use @DisplayName (e.g. @Julia Pereszta) for mentions — NOT [~accountId:...] syntax. Keep comments non-technical and user-facing. Never mention git details like 'pushed to main', branch names, or technical implementation details — stakeholders don't care about that. NEVER use em dashes (—) or en dashes (–) in comments — use commas, periods, or rewrite the sentence instead.",
|
|
1002
1293
|
inputSchema: {
|
|
1003
1294
|
type: "object",
|
|
1004
1295
|
properties: {
|
|
@@ -1101,7 +1392,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
1101
1392
|
{
|
|
1102
1393
|
name: "jira_update_ticket",
|
|
1103
1394
|
description:
|
|
1104
|
-
"Update fields on a Jira ticket. IMPORTANT: Only pass the fields you want to change. Omitted fields are left untouched.",
|
|
1395
|
+
"Update fields on a Jira ticket. IMPORTANT: Only pass the fields you want to change. Omitted fields are left untouched. NEVER use em dashes (—) or en dashes (–) in text — use commas, periods, or rewrite the sentence instead.",
|
|
1105
1396
|
inputSchema: {
|
|
1106
1397
|
type: "object",
|
|
1107
1398
|
properties: {
|
|
@@ -1154,7 +1445,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
1154
1445
|
{
|
|
1155
1446
|
name: "jira_search_users",
|
|
1156
1447
|
description:
|
|
1157
|
-
"Search for Jira users by name or email. Returns account IDs and display names. Use this to find users for mentions or assignments.",
|
|
1448
|
+
"Search for Jira users by name or email. Returns account IDs and display names. Use this to find users for mentions or assignments. When multiple Jira instances are configured, use the 'instance' parameter to specify which one.",
|
|
1158
1449
|
inputSchema: {
|
|
1159
1450
|
type: "object",
|
|
1160
1451
|
properties: {
|
|
@@ -1167,10 +1458,105 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
1167
1458
|
type: "number",
|
|
1168
1459
|
description: "Max results (default 5)",
|
|
1169
1460
|
},
|
|
1461
|
+
instance: {
|
|
1462
|
+
type: "string",
|
|
1463
|
+
description: "Instance name (for multi-instance setups). Uses default instance if omitted.",
|
|
1464
|
+
},
|
|
1170
1465
|
},
|
|
1171
1466
|
required: ["query"],
|
|
1172
1467
|
},
|
|
1173
1468
|
},
|
|
1469
|
+
{
|
|
1470
|
+
name: "jira_get_changelog",
|
|
1471
|
+
description:
|
|
1472
|
+
"Get status change history for a Jira ticket or multiple tickets via JQL. Returns all status transitions with timestamps and authors, plus computed metrics (cycle time, lead time, time in each status). Use issueKey for a single ticket, or jql for bulk retrieval. Instance is auto-detected from issueKey, or use the 'instance' parameter with jql.",
|
|
1473
|
+
inputSchema: {
|
|
1474
|
+
type: "object",
|
|
1475
|
+
properties: {
|
|
1476
|
+
issueKey: {
|
|
1477
|
+
type: "string",
|
|
1478
|
+
description:
|
|
1479
|
+
"Single issue key (e.g., MODS-13996). Use this OR jql, not both.",
|
|
1480
|
+
},
|
|
1481
|
+
jql: {
|
|
1482
|
+
type: "string",
|
|
1483
|
+
description:
|
|
1484
|
+
"JQL query to get changelogs for multiple tickets (e.g., 'project = MODS AND sprint = 123'). Use this OR issueKey, not both.",
|
|
1485
|
+
},
|
|
1486
|
+
maxResults: {
|
|
1487
|
+
type: "number",
|
|
1488
|
+
description:
|
|
1489
|
+
"Max results when using jql (default 50). Each ticket requires a separate API call, so keep this reasonable.",
|
|
1490
|
+
},
|
|
1491
|
+
instance: {
|
|
1492
|
+
type: "string",
|
|
1493
|
+
description: "Instance name (for multi-instance setups with jql). Auto-detected from issueKey if provided.",
|
|
1494
|
+
},
|
|
1495
|
+
},
|
|
1496
|
+
required: [],
|
|
1497
|
+
},
|
|
1498
|
+
},
|
|
1499
|
+
{
|
|
1500
|
+
name: "jira_add_instance",
|
|
1501
|
+
description:
|
|
1502
|
+
"Add or update a Jira instance configuration. Saves to config and makes it available immediately without restart. Use this to connect to a new Jira instance during a session.",
|
|
1503
|
+
inputSchema: {
|
|
1504
|
+
type: "object",
|
|
1505
|
+
properties: {
|
|
1506
|
+
name: {
|
|
1507
|
+
type: "string",
|
|
1508
|
+
description: "Unique name for this instance (e.g., 'work', 'personal')",
|
|
1509
|
+
},
|
|
1510
|
+
email: {
|
|
1511
|
+
type: "string",
|
|
1512
|
+
description: "Jira account email",
|
|
1513
|
+
},
|
|
1514
|
+
token: {
|
|
1515
|
+
type: "string",
|
|
1516
|
+
description: "Jira API token (from https://id.atlassian.com/manage-profile/security/api-tokens)",
|
|
1517
|
+
},
|
|
1518
|
+
baseUrl: {
|
|
1519
|
+
type: "string",
|
|
1520
|
+
description: "Jira base URL (e.g., https://company.atlassian.net)",
|
|
1521
|
+
},
|
|
1522
|
+
projects: {
|
|
1523
|
+
type: "array",
|
|
1524
|
+
items: { type: "string" },
|
|
1525
|
+
description: "Project key prefixes to auto-route to this instance (e.g., ['PROJ', 'ENG'])",
|
|
1526
|
+
},
|
|
1527
|
+
setDefault: {
|
|
1528
|
+
type: "boolean",
|
|
1529
|
+
description: "Set this instance as the default (default: false)",
|
|
1530
|
+
},
|
|
1531
|
+
},
|
|
1532
|
+
required: ["name", "email", "token", "baseUrl"],
|
|
1533
|
+
},
|
|
1534
|
+
},
|
|
1535
|
+
{
|
|
1536
|
+
name: "jira_remove_instance",
|
|
1537
|
+
description:
|
|
1538
|
+
"Remove a Jira instance configuration by name. Cannot remove the last remaining instance.",
|
|
1539
|
+
inputSchema: {
|
|
1540
|
+
type: "object",
|
|
1541
|
+
properties: {
|
|
1542
|
+
name: {
|
|
1543
|
+
type: "string",
|
|
1544
|
+
description: "Name of the instance to remove",
|
|
1545
|
+
},
|
|
1546
|
+
},
|
|
1547
|
+
required: ["name"],
|
|
1548
|
+
},
|
|
1549
|
+
},
|
|
1550
|
+
{
|
|
1551
|
+
name: "jira_list_instances",
|
|
1552
|
+
description:
|
|
1553
|
+
"List all configured Jira instances with their names, URLs, project prefixes, and which is the default.",
|
|
1554
|
+
inputSchema: {
|
|
1555
|
+
type: "object",
|
|
1556
|
+
properties: {},
|
|
1557
|
+
required: [],
|
|
1558
|
+
},
|
|
1559
|
+
},
|
|
1174
1560
|
],
|
|
1175
1561
|
};
|
|
1176
1562
|
});
|
|
@@ -1180,7 +1566,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1180
1566
|
|
|
1181
1567
|
try {
|
|
1182
1568
|
if (name === "jira_get_myself") {
|
|
1183
|
-
const
|
|
1569
|
+
const inst = getInstanceByName(args.instance);
|
|
1570
|
+
const result = await fetchJira("/myself", {}, inst);
|
|
1184
1571
|
return {
|
|
1185
1572
|
content: [
|
|
1186
1573
|
{
|
|
@@ -1190,9 +1577,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1190
1577
|
],
|
|
1191
1578
|
};
|
|
1192
1579
|
} else if (name === "jira_search_users") {
|
|
1580
|
+
const inst = getInstanceByName(args.instance);
|
|
1193
1581
|
const maxResults = args.maxResults || 5;
|
|
1194
1582
|
const users = await fetchJira(
|
|
1195
1583
|
`/user/search?query=${encodeURIComponent(args.query)}&maxResults=${maxResults}`,
|
|
1584
|
+
{},
|
|
1585
|
+
inst,
|
|
1196
1586
|
);
|
|
1197
1587
|
if (!users || users.length === 0) {
|
|
1198
1588
|
return {
|
|
@@ -1263,11 +1653,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1263
1653
|
|
|
1264
1654
|
return { content };
|
|
1265
1655
|
} else if (name === "jira_search") {
|
|
1266
|
-
const
|
|
1656
|
+
const inst = getInstanceByName(args.instance);
|
|
1657
|
+
const result = await searchTickets(args.jql, args.maxResults || 10, args.fields || null, inst);
|
|
1267
1658
|
return { content: [{ type: "text", text: result }] };
|
|
1268
1659
|
} else if (name === "jira_add_comment") {
|
|
1660
|
+
const inst = getInstanceForKey(args.issueKey);
|
|
1269
1661
|
// Build ADF content with mention support
|
|
1270
|
-
const adfContent = await buildCommentADF(args.comment);
|
|
1662
|
+
const adfContent = await buildCommentADF(args.comment, inst);
|
|
1271
1663
|
const body = {
|
|
1272
1664
|
body: {
|
|
1273
1665
|
version: 1,
|
|
@@ -1278,7 +1670,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1278
1670
|
const result = await fetchJira(`/issue/${args.issueKey}/comment`, {
|
|
1279
1671
|
method: "POST",
|
|
1280
1672
|
body,
|
|
1281
|
-
});
|
|
1673
|
+
}, inst);
|
|
1282
1674
|
const author = result.author?.displayName || "Unknown";
|
|
1283
1675
|
const created = new Date(result.created).toLocaleString();
|
|
1284
1676
|
return {
|
|
@@ -1290,9 +1682,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1290
1682
|
],
|
|
1291
1683
|
};
|
|
1292
1684
|
} else if (name === "jira_reply_comment") {
|
|
1685
|
+
const inst = getInstanceForKey(args.issueKey);
|
|
1293
1686
|
// Fetch the original comment
|
|
1294
1687
|
const original = await fetchJira(
|
|
1295
1688
|
`/issue/${args.issueKey}/comment/${args.commentId}`,
|
|
1689
|
+
{},
|
|
1690
|
+
inst,
|
|
1296
1691
|
);
|
|
1297
1692
|
const originalAuthor = original.author?.displayName || "Unknown";
|
|
1298
1693
|
const originalAccountId = original.author?.accountId;
|
|
@@ -1343,7 +1738,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1343
1738
|
const result = await fetchJira(`/issue/${args.issueKey}/comment`, {
|
|
1344
1739
|
method: "POST",
|
|
1345
1740
|
body,
|
|
1346
|
-
});
|
|
1741
|
+
}, inst);
|
|
1347
1742
|
const author = result.author?.displayName || "Unknown";
|
|
1348
1743
|
const created = new Date(result.created).toLocaleString();
|
|
1349
1744
|
return {
|
|
@@ -1355,8 +1750,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1355
1750
|
],
|
|
1356
1751
|
};
|
|
1357
1752
|
} else if (name === "jira_edit_comment") {
|
|
1753
|
+
const inst = getInstanceForKey(args.issueKey);
|
|
1358
1754
|
// Build ADF content with mention support
|
|
1359
|
-
const adfContent = await buildCommentADF(args.comment);
|
|
1755
|
+
const adfContent = await buildCommentADF(args.comment, inst);
|
|
1360
1756
|
const body = {
|
|
1361
1757
|
body: {
|
|
1362
1758
|
version: 1,
|
|
@@ -1367,6 +1763,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1367
1763
|
const result = await fetchJira(
|
|
1368
1764
|
`/issue/${args.issueKey}/comment/${args.commentId}`,
|
|
1369
1765
|
{ method: "PUT", body },
|
|
1766
|
+
inst,
|
|
1370
1767
|
);
|
|
1371
1768
|
return {
|
|
1372
1769
|
content: [
|
|
@@ -1377,9 +1774,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1377
1774
|
],
|
|
1378
1775
|
};
|
|
1379
1776
|
} else if (name === "jira_delete_comment") {
|
|
1777
|
+
const inst = getInstanceForKey(args.issueKey);
|
|
1380
1778
|
await fetchJira(`/issue/${args.issueKey}/comment/${args.commentId}`, {
|
|
1381
1779
|
method: "DELETE",
|
|
1382
|
-
});
|
|
1780
|
+
}, inst);
|
|
1383
1781
|
return {
|
|
1384
1782
|
content: [
|
|
1385
1783
|
{
|
|
@@ -1389,9 +1787,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1389
1787
|
],
|
|
1390
1788
|
};
|
|
1391
1789
|
} else if (name === "jira_transition") {
|
|
1790
|
+
const inst = getInstanceForKey(args.issueKey);
|
|
1392
1791
|
if (!args.transitionId && !args.targetStatus) {
|
|
1393
1792
|
// List available transitions
|
|
1394
|
-
const result = await fetchJira(`/issue/${args.issueKey}/transitions
|
|
1793
|
+
const result = await fetchJira(`/issue/${args.issueKey}/transitions`, {}, inst);
|
|
1395
1794
|
let output = `# Available transitions for ${args.issueKey}\n\n`;
|
|
1396
1795
|
for (const t of result.transitions || []) {
|
|
1397
1796
|
output += `- **${t.name}** (id: ${t.id}) → status: ${t.to?.name || "Unknown"}\n`;
|
|
@@ -1409,7 +1808,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1409
1808
|
|
|
1410
1809
|
// Try to reach target status, with up to 3 intermediate transitions
|
|
1411
1810
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
1412
|
-
const result = await fetchJira(`/issue/${args.issueKey}/transitions
|
|
1811
|
+
const result = await fetchJira(`/issue/${args.issueKey}/transitions`, {}, inst);
|
|
1413
1812
|
const available = result.transitions || [];
|
|
1414
1813
|
|
|
1415
1814
|
// Check if target status is directly available
|
|
@@ -1423,7 +1822,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1423
1822
|
await fetchJira(`/issue/${args.issueKey}/transitions`, {
|
|
1424
1823
|
method: "POST",
|
|
1425
1824
|
body: { transition: { id: directMatch.id } },
|
|
1426
|
-
});
|
|
1825
|
+
}, inst);
|
|
1427
1826
|
transitions.push(directMatch.to?.name || directMatch.name);
|
|
1428
1827
|
return {
|
|
1429
1828
|
content: [
|
|
@@ -1446,7 +1845,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1446
1845
|
await fetchJira(`/issue/${args.issueKey}/transitions`, {
|
|
1447
1846
|
method: "POST",
|
|
1448
1847
|
body: { transition: { id: inProgress.id } },
|
|
1449
|
-
});
|
|
1848
|
+
}, inst);
|
|
1450
1849
|
transitions.push(inProgress.to?.name || "In Progress");
|
|
1451
1850
|
continue; // Try again to find target
|
|
1452
1851
|
}
|
|
@@ -1456,7 +1855,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1456
1855
|
}
|
|
1457
1856
|
|
|
1458
1857
|
// Could not reach target status
|
|
1459
|
-
const result = await fetchJira(`/issue/${args.issueKey}/transitions
|
|
1858
|
+
const result = await fetchJira(`/issue/${args.issueKey}/transitions`, {}, inst);
|
|
1460
1859
|
const availableNames = (result.transitions || [])
|
|
1461
1860
|
.map((t) => t.to?.name || t.name)
|
|
1462
1861
|
.join(", ");
|
|
@@ -1474,7 +1873,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1474
1873
|
await fetchJira(`/issue/${args.issueKey}/transitions`, {
|
|
1475
1874
|
method: "POST",
|
|
1476
1875
|
body: { transition: { id: args.transitionId } },
|
|
1477
|
-
});
|
|
1876
|
+
}, inst);
|
|
1478
1877
|
return {
|
|
1479
1878
|
content: [
|
|
1480
1879
|
{
|
|
@@ -1484,6 +1883,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1484
1883
|
],
|
|
1485
1884
|
};
|
|
1486
1885
|
} else if (name === "jira_update_ticket") {
|
|
1886
|
+
const inst = getInstanceForKey(args.issueKey);
|
|
1487
1887
|
const fields = {};
|
|
1488
1888
|
if (args.summary) {
|
|
1489
1889
|
if (args.replaceSummary) {
|
|
@@ -1491,7 +1891,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1491
1891
|
} else {
|
|
1492
1892
|
// Append to existing title (default)
|
|
1493
1893
|
const issue = await fetchJira(
|
|
1494
|
-
`/issue/${args.issueKey}?fields=summary`,
|
|
1894
|
+
`/issue/${args.issueKey}?fields=summary`, {}, inst,
|
|
1495
1895
|
);
|
|
1496
1896
|
const existing = issue.fields?.summary || "";
|
|
1497
1897
|
fields.summary = existing + " " + args.summary;
|
|
@@ -1512,7 +1912,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1512
1912
|
} else {
|
|
1513
1913
|
// Append to existing (default)
|
|
1514
1914
|
const issue = await fetchJira(
|
|
1515
|
-
`/issue/${args.issueKey}?fields=description`,
|
|
1915
|
+
`/issue/${args.issueKey}?fields=description`, {}, inst,
|
|
1516
1916
|
);
|
|
1517
1917
|
const existing = issue.fields?.description;
|
|
1518
1918
|
if (existing && existing.content) {
|
|
@@ -1529,7 +1929,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1529
1929
|
}
|
|
1530
1930
|
if (args.removeFromDescription) {
|
|
1531
1931
|
const issue = await fetchJira(
|
|
1532
|
-
`/issue/${args.issueKey}?fields=description`,
|
|
1932
|
+
`/issue/${args.issueKey}?fields=description`, {}, inst,
|
|
1533
1933
|
);
|
|
1534
1934
|
const existing = issue.fields?.description;
|
|
1535
1935
|
if (existing && existing.content) {
|
|
@@ -1582,13 +1982,141 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1582
1982
|
await fetchJira(`/issue/${args.issueKey}`, {
|
|
1583
1983
|
method: "PUT",
|
|
1584
1984
|
body: { fields },
|
|
1585
|
-
});
|
|
1985
|
+
}, inst);
|
|
1586
1986
|
const updated = Object.keys(fields).join(", ");
|
|
1587
1987
|
return {
|
|
1588
1988
|
content: [
|
|
1589
1989
|
{ type: "text", text: `Updated ${args.issueKey}: ${updated}.` },
|
|
1590
1990
|
],
|
|
1591
1991
|
};
|
|
1992
|
+
} else if (name === "jira_get_changelog") {
|
|
1993
|
+
if (!args.issueKey && !args.jql) {
|
|
1994
|
+
return {
|
|
1995
|
+
content: [{ type: "text", text: "Error: Provide either issueKey or jql parameter." }],
|
|
1996
|
+
isError: true,
|
|
1997
|
+
};
|
|
1998
|
+
}
|
|
1999
|
+
if (args.issueKey) {
|
|
2000
|
+
const result = await getChangelog(args.issueKey);
|
|
2001
|
+
return { content: [{ type: "text", text: result.formatted }] };
|
|
2002
|
+
} else {
|
|
2003
|
+
const inst = getInstanceByName(args.instance);
|
|
2004
|
+
const result = await getChangelogsBulk(args.jql, args.maxResults || 50, inst);
|
|
2005
|
+
return { content: [{ type: "text", text: result }] };
|
|
2006
|
+
}
|
|
2007
|
+
} else if (name === "jira_add_instance") {
|
|
2008
|
+
const instName = args.name.trim();
|
|
2009
|
+
const projects = (args.projects || []).map((p) => p.toUpperCase());
|
|
2010
|
+
const authStr = Buffer.from(`${args.email}:${args.token}`).toString("base64");
|
|
2011
|
+
const newInstance = {
|
|
2012
|
+
name: instName,
|
|
2013
|
+
email: args.email,
|
|
2014
|
+
token: args.token,
|
|
2015
|
+
baseUrl: args.baseUrl.replace(/\/$/, ""),
|
|
2016
|
+
projects,
|
|
2017
|
+
auth: authStr,
|
|
2018
|
+
};
|
|
2019
|
+
|
|
2020
|
+
// Update in-memory instances
|
|
2021
|
+
const existingIdx = instances.findIndex((i) => i.name === instName);
|
|
2022
|
+
if (existingIdx >= 0) {
|
|
2023
|
+
instances[existingIdx] = newInstance;
|
|
2024
|
+
} else {
|
|
2025
|
+
instances.push(newInstance);
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
// Update default if requested or if it's the first instance
|
|
2029
|
+
if (args.setDefault || instances.length === 1) {
|
|
2030
|
+
// Can't reassign const, but defaultInstance is used via getInstanceByName/getInstanceForKey
|
|
2031
|
+
// which search the instances array, so this is handled by rawConfig.defaultInstance below
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
// Persist to config file
|
|
2035
|
+
const savedConfig = loadConfigFile();
|
|
2036
|
+
if (!savedConfig.instances) {
|
|
2037
|
+
// Migrate old format
|
|
2038
|
+
if (savedConfig.email) {
|
|
2039
|
+
savedConfig.instances = [{
|
|
2040
|
+
name: "default",
|
|
2041
|
+
email: savedConfig.email,
|
|
2042
|
+
token: savedConfig.token,
|
|
2043
|
+
baseUrl: savedConfig.baseUrl,
|
|
2044
|
+
projects: [],
|
|
2045
|
+
}];
|
|
2046
|
+
savedConfig.defaultInstance = "default";
|
|
2047
|
+
delete savedConfig.email;
|
|
2048
|
+
delete savedConfig.token;
|
|
2049
|
+
delete savedConfig.baseUrl;
|
|
2050
|
+
} else {
|
|
2051
|
+
savedConfig.instances = [];
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
// Save without the computed auth field
|
|
2056
|
+
const toSave = { name: instName, email: args.email, token: args.token, baseUrl: newInstance.baseUrl, projects };
|
|
2057
|
+
const savedIdx = savedConfig.instances.findIndex((i) => i.name === instName);
|
|
2058
|
+
if (savedIdx >= 0) {
|
|
2059
|
+
savedConfig.instances[savedIdx] = toSave;
|
|
2060
|
+
} else {
|
|
2061
|
+
savedConfig.instances.push(toSave);
|
|
2062
|
+
}
|
|
2063
|
+
if (args.setDefault || !savedConfig.defaultInstance) {
|
|
2064
|
+
savedConfig.defaultInstance = instName;
|
|
2065
|
+
}
|
|
2066
|
+
fs.writeFileSync(jiraConfigPath, JSON.stringify(savedConfig, null, 2));
|
|
2067
|
+
|
|
2068
|
+
const action = existingIdx >= 0 ? "Updated" : "Added";
|
|
2069
|
+
let text = `${action} instance "${instName}" (${newInstance.baseUrl}).`;
|
|
2070
|
+
if (projects.length > 0) text += ` Projects: ${projects.join(", ")}.`;
|
|
2071
|
+
if (args.setDefault) text += " Set as default.";
|
|
2072
|
+
|
|
2073
|
+
return { content: [{ type: "text", text }] };
|
|
2074
|
+
|
|
2075
|
+
} else if (name === "jira_remove_instance") {
|
|
2076
|
+
const instName = args.name.trim();
|
|
2077
|
+
|
|
2078
|
+
if (instances.length <= 1) {
|
|
2079
|
+
return {
|
|
2080
|
+
content: [{ type: "text", text: "Cannot remove the last remaining instance." }],
|
|
2081
|
+
isError: true,
|
|
2082
|
+
};
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
const idx = instances.findIndex((i) => i.name === instName);
|
|
2086
|
+
if (idx < 0) {
|
|
2087
|
+
return {
|
|
2088
|
+
content: [{ type: "text", text: `Instance "${instName}" not found.` }],
|
|
2089
|
+
isError: true,
|
|
2090
|
+
};
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
instances.splice(idx, 1);
|
|
2094
|
+
|
|
2095
|
+
// Persist to config file
|
|
2096
|
+
const savedConfig = loadConfigFile();
|
|
2097
|
+
if (savedConfig.instances) {
|
|
2098
|
+
savedConfig.instances = savedConfig.instances.filter((i) => i.name !== instName);
|
|
2099
|
+
if (savedConfig.defaultInstance === instName) {
|
|
2100
|
+
savedConfig.defaultInstance = savedConfig.instances[0]?.name || null;
|
|
2101
|
+
}
|
|
2102
|
+
fs.writeFileSync(jiraConfigPath, JSON.stringify(savedConfig, null, 2));
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
return { content: [{ type: "text", text: `Removed instance "${instName}".` }] };
|
|
2106
|
+
|
|
2107
|
+
} else if (name === "jira_list_instances") {
|
|
2108
|
+
if (instances.length === 0) {
|
|
2109
|
+
return { content: [{ type: "text", text: "No instances configured." }] };
|
|
2110
|
+
}
|
|
2111
|
+
const currentDefault = rawConfig.defaultInstance || instances[0].name;
|
|
2112
|
+
let text = `# Configured Jira Instances (${instances.length})\n\n`;
|
|
2113
|
+
for (const inst of instances) {
|
|
2114
|
+
const isDefault = inst.name === currentDefault ? " **(default)**" : "";
|
|
2115
|
+
const projs = inst.projects?.length > 0 ? `\n Projects: ${inst.projects.join(", ")}` : "";
|
|
2116
|
+
text += `- **${inst.name}**${isDefault}: ${inst.baseUrl} (${inst.email})${projs}\n`;
|
|
2117
|
+
}
|
|
2118
|
+
return { content: [{ type: "text", text }] };
|
|
2119
|
+
|
|
1592
2120
|
} else {
|
|
1593
2121
|
throw new Error(`Unknown tool: ${name}`);
|
|
1594
2122
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rui.branco/jira-mcp",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.9",
|
|
4
4
|
"description": "Jira MCP server for Claude Code - fetch tickets, search with JQL, update tickets, manage comments, change status, and get Figma designs",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
package/setup.js
CHANGED
|
@@ -12,18 +12,92 @@ let args = process.argv.slice(2);
|
|
|
12
12
|
// Skip "setup" arg if called via index.js
|
|
13
13
|
if (args[0] === "setup") args = args.slice(1);
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
// Load existing config if present
|
|
16
|
+
function loadConfig() {
|
|
17
|
+
try {
|
|
18
|
+
if (fs.existsSync(configPath)) {
|
|
19
|
+
return JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
20
|
+
}
|
|
21
|
+
} catch {}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
18
24
|
|
|
25
|
+
function saveConfig(config) {
|
|
19
26
|
if (!fs.existsSync(configDir)) {
|
|
20
27
|
fs.mkdirSync(configDir, { recursive: true });
|
|
21
28
|
}
|
|
29
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Non-interactive: setup add <name> <email> <token> <baseUrl> <projects>
|
|
33
|
+
if (args[0] === "add" && args.length >= 5) {
|
|
34
|
+
const [, name, email, token, baseUrl, ...projectArgs] = args;
|
|
35
|
+
const projects = projectArgs.length > 0
|
|
36
|
+
? projectArgs.join(",").split(",").map((p) => p.trim().toUpperCase()).filter(Boolean)
|
|
37
|
+
: [];
|
|
38
|
+
|
|
39
|
+
const config = loadConfig() || {};
|
|
40
|
+
|
|
41
|
+
// Migrate old format if needed
|
|
42
|
+
if (config.email && !config.instances) {
|
|
43
|
+
config.instances = [{
|
|
44
|
+
name: "default",
|
|
45
|
+
email: config.email,
|
|
46
|
+
token: config.token,
|
|
47
|
+
baseUrl: config.baseUrl,
|
|
48
|
+
projects: [],
|
|
49
|
+
}];
|
|
50
|
+
config.defaultInstance = "default";
|
|
51
|
+
delete config.email;
|
|
52
|
+
delete config.token;
|
|
53
|
+
delete config.baseUrl;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!config.instances) config.instances = [];
|
|
57
|
+
|
|
58
|
+
// Replace or add instance
|
|
59
|
+
const existing = config.instances.findIndex((i) => i.name === name);
|
|
60
|
+
const instance = { name, email, token, baseUrl: baseUrl.replace(/\/$/, ""), projects };
|
|
61
|
+
if (existing >= 0) {
|
|
62
|
+
config.instances[existing] = instance;
|
|
63
|
+
} else {
|
|
64
|
+
config.instances.push(instance);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!config.defaultInstance) config.defaultInstance = name;
|
|
68
|
+
|
|
69
|
+
saveConfig(config);
|
|
70
|
+
console.log(`Instance "${name}" saved to ${configPath}`);
|
|
71
|
+
if (projects.length > 0) {
|
|
72
|
+
console.log(`Projects: ${projects.join(", ")}`);
|
|
73
|
+
}
|
|
74
|
+
process.exit(0);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Non-interactive: setup remove <name>
|
|
78
|
+
if (args[0] === "remove" && args.length >= 2) {
|
|
79
|
+
const name = args[1];
|
|
80
|
+
const config = loadConfig();
|
|
22
81
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
82
|
+
if (!config || !config.instances) {
|
|
83
|
+
console.error("No multi-instance config found.");
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
config.instances = config.instances.filter((i) => i.name !== name);
|
|
88
|
+
if (config.defaultInstance === name) {
|
|
89
|
+
config.defaultInstance = config.instances[0]?.name || null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
saveConfig(config);
|
|
93
|
+
console.log(`Instance "${name}" removed.`);
|
|
94
|
+
process.exit(0);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Non-interactive: setup <email> <token> <baseUrl> (legacy single-instance)
|
|
98
|
+
if (args.length >= 3 && args[0] !== "add" && args[0] !== "remove") {
|
|
99
|
+
const [email, token, baseUrl] = args;
|
|
100
|
+
saveConfig({ email, token, baseUrl: baseUrl.replace(/\/$/, "") });
|
|
27
101
|
console.log(`Config saved to ${configPath}`);
|
|
28
102
|
process.exit(0);
|
|
29
103
|
}
|
|
@@ -41,7 +115,34 @@ function ask(question) {
|
|
|
41
115
|
}
|
|
42
116
|
|
|
43
117
|
async function setup() {
|
|
118
|
+
const existing = loadConfig();
|
|
119
|
+
const hasInstances = existing?.instances?.length > 0;
|
|
120
|
+
const isOldFormat = existing?.email && !existing?.instances;
|
|
121
|
+
|
|
44
122
|
console.log("\n=== Jira MCP Setup ===\n");
|
|
123
|
+
|
|
124
|
+
if (hasInstances || isOldFormat) {
|
|
125
|
+
if (isOldFormat) {
|
|
126
|
+
console.log(`Existing single-instance config found (${existing.baseUrl})\n`);
|
|
127
|
+
} else {
|
|
128
|
+
console.log("Existing instances:");
|
|
129
|
+
for (const inst of existing.instances) {
|
|
130
|
+
const isDefault = inst.name === existing.defaultInstance ? " (default)" : "";
|
|
131
|
+
const projs = inst.projects?.length > 0 ? ` [${inst.projects.join(", ")}]` : "";
|
|
132
|
+
console.log(` - ${inst.name}${isDefault}: ${inst.baseUrl}${projs}`);
|
|
133
|
+
}
|
|
134
|
+
console.log();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const action = await ask("Add new instance, or fresh setup? (add/fresh): ");
|
|
138
|
+
if (action.trim().toLowerCase() === "fresh") {
|
|
139
|
+
// Fall through to single setup
|
|
140
|
+
} else {
|
|
141
|
+
// Add instance to multi-instance config
|
|
142
|
+
return await addInstance(existing);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
45
146
|
console.log("To get your Jira API token:");
|
|
46
147
|
console.log("1. Go to https://id.atlassian.com/manage-profile/security/api-tokens");
|
|
47
148
|
console.log("2. Click 'Create API token'");
|
|
@@ -51,17 +152,85 @@ async function setup() {
|
|
|
51
152
|
const token = await ask("Jira API token: ");
|
|
52
153
|
const baseUrl = await ask("Jira base URL (e.g., https://company.atlassian.net): ");
|
|
53
154
|
|
|
54
|
-
|
|
55
|
-
|
|
155
|
+
saveConfig({ email, token, baseUrl: baseUrl.replace(/\/$/, "") });
|
|
156
|
+
console.log(`\nConfig saved to ${configPath}`);
|
|
157
|
+
|
|
158
|
+
printFigmaStatus();
|
|
159
|
+
printSetupComplete();
|
|
160
|
+
rl.close();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function addInstance(config) {
|
|
164
|
+
// Migrate old format if needed
|
|
165
|
+
if (config.email && !config.instances) {
|
|
166
|
+
const oldName = await ask("Name for your existing instance (e.g., work): ");
|
|
167
|
+
const oldProjects = await ask("Project prefixes for existing instance (comma-separated, e.g., MODS,ENG): ");
|
|
168
|
+
config = {
|
|
169
|
+
instances: [{
|
|
170
|
+
name: oldName.trim() || "default",
|
|
171
|
+
email: config.email,
|
|
172
|
+
token: config.token,
|
|
173
|
+
baseUrl: config.baseUrl,
|
|
174
|
+
projects: oldProjects.split(",").map((p) => p.trim().toUpperCase()).filter(Boolean),
|
|
175
|
+
}],
|
|
176
|
+
defaultInstance: oldName.trim() || "default",
|
|
177
|
+
};
|
|
56
178
|
}
|
|
57
179
|
|
|
58
|
-
|
|
59
|
-
configPath,
|
|
60
|
-
JSON.stringify({ email, token, baseUrl: baseUrl.replace(/\/$/, "") }, null, 2)
|
|
61
|
-
);
|
|
62
|
-
console.log(`\nConfig saved to ${configPath}`);
|
|
180
|
+
if (!config.instances) config.instances = [];
|
|
63
181
|
|
|
64
|
-
|
|
182
|
+
console.log("\n--- Add New Instance ---\n");
|
|
183
|
+
console.log("To get your Jira API token:");
|
|
184
|
+
console.log("1. Go to https://id.atlassian.com/manage-profile/security/api-tokens");
|
|
185
|
+
console.log("2. Click 'Create API token'");
|
|
186
|
+
console.log("3. Copy the token\n");
|
|
187
|
+
|
|
188
|
+
const name = await ask("Instance name (e.g., personal): ");
|
|
189
|
+
const email = await ask("Jira email: ");
|
|
190
|
+
const token = await ask("Jira API token: ");
|
|
191
|
+
const baseUrl = await ask("Jira base URL (e.g., https://company.atlassian.net): ");
|
|
192
|
+
const projectsInput = await ask("Project prefixes (comma-separated, e.g., SIDE,FUN): ");
|
|
193
|
+
const projects = projectsInput.split(",").map((p) => p.trim().toUpperCase()).filter(Boolean);
|
|
194
|
+
|
|
195
|
+
const instance = {
|
|
196
|
+
name: name.trim(),
|
|
197
|
+
email: email.trim(),
|
|
198
|
+
token: token.trim(),
|
|
199
|
+
baseUrl: baseUrl.trim().replace(/\/$/, ""),
|
|
200
|
+
projects,
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
// Replace or add
|
|
204
|
+
const idx = config.instances.findIndex((i) => i.name === instance.name);
|
|
205
|
+
if (idx >= 0) {
|
|
206
|
+
config.instances[idx] = instance;
|
|
207
|
+
} else {
|
|
208
|
+
config.instances.push(instance);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (!config.defaultInstance) config.defaultInstance = instance.name;
|
|
212
|
+
|
|
213
|
+
const setDefault = await ask(`Set "${instance.name}" as default? (y/N): `);
|
|
214
|
+
if (setDefault.trim().toLowerCase() === "y") {
|
|
215
|
+
config.defaultInstance = instance.name;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
saveConfig(config);
|
|
219
|
+
console.log(`\nInstance "${instance.name}" saved to ${configPath}`);
|
|
220
|
+
|
|
221
|
+
console.log("\nAll instances:");
|
|
222
|
+
for (const inst of config.instances) {
|
|
223
|
+
const isDefault = inst.name === config.defaultInstance ? " (default)" : "";
|
|
224
|
+
const projs = inst.projects?.length > 0 ? ` [${inst.projects.join(", ")}]` : "";
|
|
225
|
+
console.log(` - ${inst.name}${isDefault}: ${inst.baseUrl}${projs}`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
printFigmaStatus();
|
|
229
|
+
printSetupComplete();
|
|
230
|
+
rl.close();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function printFigmaStatus() {
|
|
65
234
|
const figmaConfigPath = path.join(process.env.HOME, ".config/figma-mcp/config.json");
|
|
66
235
|
if (fs.existsSync(figmaConfigPath)) {
|
|
67
236
|
console.log("\n[OK] Figma MCP detected - Figma links in tickets will be fetched automatically");
|
|
@@ -69,13 +238,13 @@ async function setup() {
|
|
|
69
238
|
console.log("\n[INFO] Figma MCP not installed - Figma links won't be fetched");
|
|
70
239
|
console.log("To enable Figma integration, install figma-mcp");
|
|
71
240
|
}
|
|
241
|
+
}
|
|
72
242
|
|
|
243
|
+
function printSetupComplete() {
|
|
73
244
|
console.log("\n=== Setup Complete ===");
|
|
74
245
|
console.log("\nIf you haven't already, add to Claude Code with:\n");
|
|
75
246
|
console.log(" claude mcp add --transport stdio jira -- npx -y @rui.branco/jira-mcp");
|
|
76
247
|
console.log("\nThen restart Claude Code and run /mcp to verify.");
|
|
77
|
-
|
|
78
|
-
rl.close();
|
|
79
248
|
}
|
|
80
249
|
|
|
81
250
|
setup().catch((e) => {
|