@aaronsb/jira-cloud-mcp 0.5.4 → 0.5.5
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.
|
@@ -95,10 +95,20 @@ export class JiraClient {
|
|
|
95
95
|
/** Maps a raw Jira API issue to our JiraIssueDetails shape */
|
|
96
96
|
mapIssueFields(issue) {
|
|
97
97
|
const fields = issue.fields ?? issue.fields;
|
|
98
|
+
// Extract people with accountIds for @mention support
|
|
99
|
+
const people = [];
|
|
100
|
+
if (fields?.assignee?.accountId && fields?.assignee?.displayName) {
|
|
101
|
+
people.push({ displayName: fields.assignee.displayName, accountId: fields.assignee.accountId, role: 'assignee' });
|
|
102
|
+
}
|
|
103
|
+
if (fields?.reporter?.accountId && fields?.reporter?.displayName) {
|
|
104
|
+
people.push({ displayName: fields.reporter.displayName, accountId: fields.reporter.accountId, role: 'reporter' });
|
|
105
|
+
}
|
|
98
106
|
return {
|
|
99
107
|
key: issue.key,
|
|
100
108
|
summary: fields?.summary,
|
|
101
|
-
description:
|
|
109
|
+
description: fields?.description
|
|
110
|
+
? TextProcessor.adfToMarkdown(fields.description)
|
|
111
|
+
: '',
|
|
102
112
|
issueType: fields?.issuetype?.name || '',
|
|
103
113
|
priority: fields?.priority?.name || null,
|
|
104
114
|
parent: fields?.parent?.key || null,
|
|
@@ -121,6 +131,7 @@ export class JiraClient {
|
|
|
121
131
|
outward: link.outwardIssue?.key || null,
|
|
122
132
|
inward: link.inwardIssue?.key || null,
|
|
123
133
|
})),
|
|
134
|
+
people: people.length > 0 ? people : undefined,
|
|
124
135
|
};
|
|
125
136
|
}
|
|
126
137
|
async getIssue(issueKey, includeComments = false, includeAttachments = false, customFieldMeta) {
|
|
@@ -139,7 +150,7 @@ export class JiraClient {
|
|
|
139
150
|
const params = {
|
|
140
151
|
issueIdOrKey: issueKey,
|
|
141
152
|
fields,
|
|
142
|
-
expand: includeComments ? '
|
|
153
|
+
expand: includeComments ? 'comments' : undefined
|
|
143
154
|
};
|
|
144
155
|
const issue = await this.client.issues.getIssue(params);
|
|
145
156
|
const issueDetails = this.mapIssueFields(issue);
|
|
@@ -171,9 +182,24 @@ export class JiraClient {
|
|
|
171
182
|
.map(comment => ({
|
|
172
183
|
id: comment.id,
|
|
173
184
|
author: comment.author.displayName,
|
|
174
|
-
body: comment.body?.content ? TextProcessor.
|
|
185
|
+
body: comment.body?.content ? TextProcessor.adfToMarkdown(comment.body) : String(comment.body),
|
|
175
186
|
created: comment.created,
|
|
176
187
|
}));
|
|
188
|
+
// Add comment authors to people list (deduplicated, capped at 10 total)
|
|
189
|
+
const existingIds = new Set((issueDetails.people || []).map(p => p.accountId));
|
|
190
|
+
const commentAuthors = [];
|
|
191
|
+
for (const comment of issue.fields.comment.comments) {
|
|
192
|
+
const aid = comment.author?.accountId;
|
|
193
|
+
const name = comment.author?.displayName;
|
|
194
|
+
if (aid && name && !existingIds.has(aid)) {
|
|
195
|
+
existingIds.add(aid);
|
|
196
|
+
commentAuthors.push({ displayName: name, accountId: aid, role: 'commenter' });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (commentAuthors.length > 0) {
|
|
200
|
+
const people = [...(issueDetails.people || []), ...commentAuthors];
|
|
201
|
+
issueDetails.people = people.slice(0, 10);
|
|
202
|
+
}
|
|
177
203
|
}
|
|
178
204
|
if (includeAttachments && issue.fields.attachment) {
|
|
179
205
|
issueDetails.attachments = issue.fields.attachment
|
|
@@ -375,7 +401,6 @@ export class JiraClient {
|
|
|
375
401
|
const searchResults = await this.client.issueSearch.searchForIssuesUsingJql({
|
|
376
402
|
jql: filter.jql,
|
|
377
403
|
fields: this.issueFields,
|
|
378
|
-
expand: 'renderedFields'
|
|
379
404
|
});
|
|
380
405
|
return (searchResults.issues || []).map(issue => this.mapIssueFields(issue));
|
|
381
406
|
}
|
|
@@ -477,7 +502,6 @@ export class JiraClient {
|
|
|
477
502
|
jql: cleanJql,
|
|
478
503
|
maxResults: Math.min(maxResults, 100),
|
|
479
504
|
fields: this.issueFields,
|
|
480
|
-
expand: 'renderedFields',
|
|
481
505
|
});
|
|
482
506
|
const issues = (searchResults.issues || []).map(issue => this.mapIssueFields(issue));
|
|
483
507
|
// Note: Enhanced search API uses token-based pagination, not offset-based
|
|
@@ -578,7 +602,7 @@ export class JiraClient {
|
|
|
578
602
|
async getPopulatedFields(issueKey) {
|
|
579
603
|
const issue = await this.client.issues.getIssue({
|
|
580
604
|
issueIdOrKey: issueKey,
|
|
581
|
-
expand: '
|
|
605
|
+
expand: 'names',
|
|
582
606
|
});
|
|
583
607
|
const fieldNames = issue.names || {};
|
|
584
608
|
const fields = issue.fields;
|
|
@@ -122,11 +122,11 @@ export function renderIssue(issue, transitions) {
|
|
|
122
122
|
}
|
|
123
123
|
if (issue.resolution)
|
|
124
124
|
lines.push(`Resolution: ${issue.resolution}`);
|
|
125
|
-
// Description —
|
|
125
|
+
// Description — already markdown from ADF conversion
|
|
126
126
|
if (issue.description) {
|
|
127
127
|
lines.push('');
|
|
128
128
|
lines.push('Description:');
|
|
129
|
-
lines.push(
|
|
129
|
+
lines.push(issue.description);
|
|
130
130
|
}
|
|
131
131
|
// Issue links
|
|
132
132
|
if (issue.issueLinks && issue.issueLinks.length > 0) {
|
|
@@ -150,7 +150,8 @@ export function renderIssue(issue, transitions) {
|
|
|
150
150
|
}
|
|
151
151
|
for (let i = 0; i < recentComments.length; i++) {
|
|
152
152
|
const comment = recentComments[i];
|
|
153
|
-
|
|
153
|
+
const preview = comment.body.split('\n').filter((l) => l.trim()).slice(0, 2).join(' | ');
|
|
154
|
+
lines.push(`[${startIdx + i}/${issue.comments.length}] ${comment.author} (${formatDate(comment.created)}): ${truncate(preview, 200)}`);
|
|
154
155
|
}
|
|
155
156
|
}
|
|
156
157
|
// Custom fields (from catalog discovery)
|
|
@@ -172,6 +173,18 @@ export function renderIssue(issue, transitions) {
|
|
|
172
173
|
lines.push(`${t.name} -> ${t.to.name} (id: ${t.id})`);
|
|
173
174
|
}
|
|
174
175
|
}
|
|
176
|
+
// People — accountIds for @mentions and assignee operations
|
|
177
|
+
if (issue.people && issue.people.length > 0) {
|
|
178
|
+
lines.push('');
|
|
179
|
+
lines.push('People:');
|
|
180
|
+
for (const person of issue.people) {
|
|
181
|
+
lines.push(`${person.displayName} | ${person.role} | accountId: ${person.accountId}`);
|
|
182
|
+
}
|
|
183
|
+
lines.push('Use accountId to assign issues or @mention in comments');
|
|
184
|
+
}
|
|
185
|
+
// Formatting hint — remind the agent that descriptions/comments accept markdown
|
|
186
|
+
lines.push('');
|
|
187
|
+
lines.push('Formatting: write markdown for descriptions and comments (headings, **bold**, *italic*, ~~strikethrough~~, `code`, lists)');
|
|
175
188
|
return lines.join('\n');
|
|
176
189
|
}
|
|
177
190
|
/**
|
|
@@ -210,7 +223,7 @@ export function renderIssueSearchResults(issues, pagination, jql) {
|
|
|
210
223
|
meta.push(`due ${formatDate(issue.dueDate)}`);
|
|
211
224
|
lines.push(meta.join(' | '));
|
|
212
225
|
if (issue.description) {
|
|
213
|
-
const desc =
|
|
226
|
+
const desc = issue.description.split('\n').filter((l) => l.trim()).slice(0, 2).join(' | ');
|
|
214
227
|
if (desc.length > 0) {
|
|
215
228
|
lines.push(` ${truncate(desc, 120)}`);
|
|
216
229
|
}
|
|
@@ -1,6 +1,46 @@
|
|
|
1
1
|
import MarkdownIt from 'markdown-it';
|
|
2
|
+
// Jira accountIds: hex strings or "712020:uuid-format"
|
|
3
|
+
const MENTION_RE = /@([a-zA-Z0-9][a-zA-Z0-9:_-]{9,})/g;
|
|
2
4
|
export class TextProcessor {
|
|
3
|
-
static md = new MarkdownIt();
|
|
5
|
+
static md = new MarkdownIt().enable('strikethrough');
|
|
6
|
+
/**
|
|
7
|
+
* Split text into alternating text and mention ADF nodes.
|
|
8
|
+
* Recognizes @accountId patterns and converts them to ADF mention nodes.
|
|
9
|
+
*/
|
|
10
|
+
static splitMentions(text, marks) {
|
|
11
|
+
const nodes = [];
|
|
12
|
+
let lastIndex = 0;
|
|
13
|
+
MENTION_RE.lastIndex = 0;
|
|
14
|
+
let match;
|
|
15
|
+
while ((match = MENTION_RE.exec(text)) !== null) {
|
|
16
|
+
// Text before the mention
|
|
17
|
+
if (match.index > lastIndex) {
|
|
18
|
+
const before = text.slice(lastIndex, match.index);
|
|
19
|
+
const node = { type: 'text', text: before };
|
|
20
|
+
if (marks?.length)
|
|
21
|
+
node.marks = marks;
|
|
22
|
+
nodes.push(node);
|
|
23
|
+
}
|
|
24
|
+
// Mention node
|
|
25
|
+
nodes.push({
|
|
26
|
+
type: 'mention',
|
|
27
|
+
attrs: { id: match[1], text: `@${match[1]}` },
|
|
28
|
+
});
|
|
29
|
+
lastIndex = MENTION_RE.lastIndex;
|
|
30
|
+
}
|
|
31
|
+
// Remaining text after last mention
|
|
32
|
+
if (lastIndex < text.length) {
|
|
33
|
+
const remaining = text.slice(lastIndex);
|
|
34
|
+
const node = { type: 'text', text: remaining };
|
|
35
|
+
if (marks?.length)
|
|
36
|
+
node.marks = marks;
|
|
37
|
+
nodes.push(node);
|
|
38
|
+
}
|
|
39
|
+
// No mentions found — return empty so caller uses original logic
|
|
40
|
+
if (nodes.length === 0)
|
|
41
|
+
return [];
|
|
42
|
+
return nodes;
|
|
43
|
+
}
|
|
4
44
|
static markdownToAdf(markdown) {
|
|
5
45
|
// Replace literal \n sequences with actual newlines so markdown-it
|
|
6
46
|
// correctly splits paragraphs. MCP JSON transport may deliver these
|
|
@@ -73,6 +113,24 @@ export class TextProcessor {
|
|
|
73
113
|
for (let i = 0; i < token.children.length; i++) {
|
|
74
114
|
const child = token.children[i];
|
|
75
115
|
if (child.type === 'text') {
|
|
116
|
+
// Check for @accountId mentions in this text segment
|
|
117
|
+
const mentionNodes = TextProcessor.splitMentions(child.content, marks.length > 0 ? marks : undefined);
|
|
118
|
+
if (mentionNodes.length > 0) {
|
|
119
|
+
// Flush any pending plain text first
|
|
120
|
+
if (currentText) {
|
|
121
|
+
lastBlock.content.push({
|
|
122
|
+
type: 'text',
|
|
123
|
+
text: currentText,
|
|
124
|
+
...(marks.length > 0 && { marks })
|
|
125
|
+
});
|
|
126
|
+
currentText = '';
|
|
127
|
+
}
|
|
128
|
+
lastBlock.content.push(...mentionNodes);
|
|
129
|
+
// Reset marks after flushing mention-bearing text
|
|
130
|
+
if (marks.length > 0)
|
|
131
|
+
marks = [];
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
76
134
|
if (currentText && marks.length > 0) {
|
|
77
135
|
lastBlock.content.push({
|
|
78
136
|
type: 'text',
|
|
@@ -104,6 +162,16 @@ export class TextProcessor {
|
|
|
104
162
|
}
|
|
105
163
|
marks.push({ type: 'em' });
|
|
106
164
|
}
|
|
165
|
+
else if (child.type === 's_open') {
|
|
166
|
+
if (currentText) {
|
|
167
|
+
lastBlock.content.push({
|
|
168
|
+
type: 'text',
|
|
169
|
+
text: currentText
|
|
170
|
+
});
|
|
171
|
+
currentText = '';
|
|
172
|
+
}
|
|
173
|
+
marks.push({ type: 'strike' });
|
|
174
|
+
}
|
|
107
175
|
else if (child.type === 'link_open') {
|
|
108
176
|
if (currentText) {
|
|
109
177
|
lastBlock.content.push({
|
|
@@ -183,6 +251,134 @@ export class TextProcessor {
|
|
|
183
251
|
}
|
|
184
252
|
return '';
|
|
185
253
|
}
|
|
254
|
+
/**
|
|
255
|
+
* Convert ADF to markdown, preserving formatting for round-trip fidelity.
|
|
256
|
+
* This is the inverse of markdownToAdf — the agent reads markdown and
|
|
257
|
+
* writes markdown, so the formatting survives the Jira round-trip.
|
|
258
|
+
*/
|
|
259
|
+
static adfToMarkdown(node) {
|
|
260
|
+
if (!node)
|
|
261
|
+
return '';
|
|
262
|
+
switch (node.type) {
|
|
263
|
+
case 'doc':
|
|
264
|
+
return (node.content || [])
|
|
265
|
+
.map((child) => TextProcessor.adfToMarkdown(child))
|
|
266
|
+
.join('\n\n')
|
|
267
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
268
|
+
.trim();
|
|
269
|
+
case 'paragraph':
|
|
270
|
+
return (node.content || [])
|
|
271
|
+
.map((child) => TextProcessor.adfToMarkdown(child))
|
|
272
|
+
.join('');
|
|
273
|
+
case 'heading': {
|
|
274
|
+
const level = node.attrs?.level || 1;
|
|
275
|
+
const prefix = '#'.repeat(level);
|
|
276
|
+
const text = (node.content || [])
|
|
277
|
+
.map((child) => TextProcessor.adfToMarkdown(child))
|
|
278
|
+
.join('');
|
|
279
|
+
return `${prefix} ${text}`;
|
|
280
|
+
}
|
|
281
|
+
case 'text': {
|
|
282
|
+
let text = node.text || '';
|
|
283
|
+
if (node.marks) {
|
|
284
|
+
// Apply inline marks first (bold, italic, etc.), then link outermost
|
|
285
|
+
// so we get **[text](url)** not [**text**](url)
|
|
286
|
+
const inlineMarks = node.marks.filter((m) => m.type !== 'link');
|
|
287
|
+
const linkMark = node.marks.find((m) => m.type === 'link');
|
|
288
|
+
for (const mark of inlineMarks) {
|
|
289
|
+
switch (mark.type) {
|
|
290
|
+
case 'strong':
|
|
291
|
+
text = `**${text}**`;
|
|
292
|
+
break;
|
|
293
|
+
case 'em':
|
|
294
|
+
text = `*${text}*`;
|
|
295
|
+
break;
|
|
296
|
+
case 'strike':
|
|
297
|
+
text = `~~${text}~~`;
|
|
298
|
+
break;
|
|
299
|
+
case 'code':
|
|
300
|
+
text = `\`${text}\``;
|
|
301
|
+
break;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (linkMark) {
|
|
305
|
+
text = `[${text}](${linkMark.attrs?.href || ''})`;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return text;
|
|
309
|
+
}
|
|
310
|
+
case 'mention':
|
|
311
|
+
// Emit @accountId so the agent can reuse it in comments
|
|
312
|
+
return `@${node.attrs?.id || node.attrs?.text?.replace('@', '') || ''}`;
|
|
313
|
+
case 'hardBreak':
|
|
314
|
+
return '\n';
|
|
315
|
+
case 'bulletList':
|
|
316
|
+
return (node.content || [])
|
|
317
|
+
.map((child) => TextProcessor.adfToMarkdown(child))
|
|
318
|
+
.join('\n');
|
|
319
|
+
case 'orderedList':
|
|
320
|
+
return (node.content || [])
|
|
321
|
+
.map((child, i) => {
|
|
322
|
+
const itemText = TextProcessor.adfListItemContent(child);
|
|
323
|
+
return `${i + 1}. ${itemText}`;
|
|
324
|
+
})
|
|
325
|
+
.join('\n');
|
|
326
|
+
case 'listItem': {
|
|
327
|
+
const itemText = TextProcessor.adfListItemContent(node);
|
|
328
|
+
return `- ${itemText}`;
|
|
329
|
+
}
|
|
330
|
+
case 'codeBlock': {
|
|
331
|
+
const lang = node.attrs?.language || '';
|
|
332
|
+
const code = (node.content || [])
|
|
333
|
+
.map((child) => child.text || '')
|
|
334
|
+
.join('');
|
|
335
|
+
return `\`\`\`${lang}\n${code}\n\`\`\``;
|
|
336
|
+
}
|
|
337
|
+
case 'blockquote': {
|
|
338
|
+
const content = (node.content || [])
|
|
339
|
+
.map((child) => TextProcessor.adfToMarkdown(child))
|
|
340
|
+
.join('\n');
|
|
341
|
+
return content.split('\n').map((line) => `> ${line}`).join('\n');
|
|
342
|
+
}
|
|
343
|
+
case 'rule':
|
|
344
|
+
return '---';
|
|
345
|
+
case 'table':
|
|
346
|
+
case 'tableRow':
|
|
347
|
+
case 'tableHeader':
|
|
348
|
+
case 'tableCell':
|
|
349
|
+
// Flatten table content to text — ADF tables don't round-trip well through markdown
|
|
350
|
+
return (node.content || [])
|
|
351
|
+
.map((child) => TextProcessor.adfToMarkdown(child))
|
|
352
|
+
.join(node.type === 'tableRow' ? ' | ' : '\n');
|
|
353
|
+
case 'mediaSingle':
|
|
354
|
+
case 'media':
|
|
355
|
+
// Media nodes can't round-trip; skip silently
|
|
356
|
+
return '';
|
|
357
|
+
case 'inlineCard':
|
|
358
|
+
return node.attrs?.url || '';
|
|
359
|
+
default:
|
|
360
|
+
// Unknown node — recurse into children if present
|
|
361
|
+
if (node.content) {
|
|
362
|
+
return (node.content || [])
|
|
363
|
+
.map((child) => TextProcessor.adfToMarkdown(child))
|
|
364
|
+
.join('');
|
|
365
|
+
}
|
|
366
|
+
return node.text || '';
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
/** Extract text content from a listItem node (skipping the wrapping paragraph) */
|
|
370
|
+
static adfListItemContent(node) {
|
|
371
|
+
return (node.content || [])
|
|
372
|
+
.map((child) => {
|
|
373
|
+
if (child.type === 'paragraph') {
|
|
374
|
+
return (child.content || [])
|
|
375
|
+
.map((c) => TextProcessor.adfToMarkdown(c))
|
|
376
|
+
.join('');
|
|
377
|
+
}
|
|
378
|
+
return TextProcessor.adfToMarkdown(child);
|
|
379
|
+
})
|
|
380
|
+
.join('\n');
|
|
381
|
+
}
|
|
186
382
|
static isFieldPopulated(value) {
|
|
187
383
|
if (value === null || value === undefined)
|
|
188
384
|
return false;
|
package/package.json
CHANGED