@aaronsb/jira-cloud-mcp 0.5.4 → 0.5.6
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;
|
|
@@ -4,11 +4,12 @@ import { analysisNextSteps } from '../utils/next-steps.js';
|
|
|
4
4
|
import { normalizeArgs } from '../utils/normalize-args.js';
|
|
5
5
|
const ALL_METRICS = ['points', 'time', 'schedule', 'cycle', 'distribution'];
|
|
6
6
|
const VALID_GROUP_BY = ['project', 'assignee', 'priority', 'issuetype'];
|
|
7
|
-
const
|
|
7
|
+
const MAX_ISSUES_HARD = 500; // absolute ceiling for detail metrics — beyond this, context explodes
|
|
8
|
+
const MAX_ISSUES_DEFAULT = 100;
|
|
8
9
|
const CUBE_SAMPLE_PCT = 0.2; // 20% of total issues
|
|
9
10
|
const CUBE_SAMPLE_MIN = 50; // floor — enough for rare dimension values
|
|
10
11
|
const CUBE_SAMPLE_MAX = 500; // ceiling — proven fast with lean search
|
|
11
|
-
const
|
|
12
|
+
const DEFAULT_GROUP_LIMIT = 20;
|
|
12
13
|
const MAX_COUNT_QUERIES = 150; // ADR-206 budget: max count API calls per execution
|
|
13
14
|
const STANDARD_MEASURES = 6; // total, open, overdue, high+, created_7d, resolved_7d
|
|
14
15
|
// ── Helpers ────────────────────────────────────────────────────────────
|
|
@@ -58,11 +59,14 @@ function countBy(items, keyFn) {
|
|
|
58
59
|
function sumBy(items, valueFn) {
|
|
59
60
|
return items.reduce((sum, item) => sum + (valueFn(item) || 0), 0);
|
|
60
61
|
}
|
|
61
|
-
function mapToString(map, separator = ' | ') {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
62
|
+
function mapToString(map, separator = ' | ', limit) {
|
|
63
|
+
const sorted = [...map.entries()].sort((a, b) => b[1] - a[1]);
|
|
64
|
+
const capped = limit ? sorted.slice(0, limit) : sorted;
|
|
65
|
+
const result = capped.map(([k, v]) => `${k}: ${v}`).join(separator);
|
|
66
|
+
if (limit && sorted.length > limit) {
|
|
67
|
+
return `${result} | (+${sorted.length - limit} more — use groupLimit to see all)`;
|
|
68
|
+
}
|
|
69
|
+
return result;
|
|
66
70
|
}
|
|
67
71
|
function median(values) {
|
|
68
72
|
if (values.length === 0)
|
|
@@ -255,16 +259,16 @@ export function renderCycle(issues, now) {
|
|
|
255
259
|
}
|
|
256
260
|
return lines.join('\n');
|
|
257
261
|
}
|
|
258
|
-
export function renderDistribution(issues) {
|
|
262
|
+
export function renderDistribution(issues, groupLimit = DEFAULT_GROUP_LIMIT) {
|
|
259
263
|
const lines = ['## Distribution', ''];
|
|
260
264
|
const byStatus = countBy(issues, i => i.status);
|
|
261
|
-
lines.push(`**By status:** ${mapToString(byStatus)}`);
|
|
265
|
+
lines.push(`**By status:** ${mapToString(byStatus, ' | ', groupLimit)}`);
|
|
262
266
|
const byAssignee = countBy(issues, i => i.assignee || 'Unassigned');
|
|
263
|
-
lines.push(`**By assignee:** ${mapToString(byAssignee)}`);
|
|
267
|
+
lines.push(`**By assignee:** ${mapToString(byAssignee, ' | ', groupLimit)}`);
|
|
264
268
|
const byPriority = countBy(issues, i => i.priority || 'None');
|
|
265
|
-
lines.push(`**By priority:** ${mapToString(byPriority)}`);
|
|
269
|
+
lines.push(`**By priority:** ${mapToString(byPriority, ' | ', groupLimit)}`);
|
|
266
270
|
const byType = countBy(issues, i => i.issueType || 'Unknown');
|
|
267
|
-
lines.push(`**By type:** ${mapToString(byType)}`);
|
|
271
|
+
lines.push(`**By type:** ${mapToString(byType, ' | ', groupLimit)}`);
|
|
268
272
|
return lines.join('\n');
|
|
269
273
|
}
|
|
270
274
|
/** Extract project keys from JQL like "project in (AA, GC, GD)" or "project = AA" */
|
|
@@ -408,7 +412,7 @@ function maxGroupsForBudget(implicitCount) {
|
|
|
408
412
|
const queriesPerGroup = STANDARD_MEASURES + implicitCount;
|
|
409
413
|
return Math.floor(MAX_COUNT_QUERIES / queriesPerGroup);
|
|
410
414
|
}
|
|
411
|
-
async function handleSummary(jiraClient, jql, groupBy, compute) {
|
|
415
|
+
async function handleSummary(jiraClient, jql, groupBy, compute, groupLimit = DEFAULT_GROUP_LIMIT) {
|
|
412
416
|
const lines = [];
|
|
413
417
|
lines.push(`# Summary: ${jql}`);
|
|
414
418
|
lines.push(`As of ${formatDateShort(new Date())} — counts are exact (no sampling cap)`);
|
|
@@ -416,21 +420,26 @@ async function handleSummary(jiraClient, jql, groupBy, compute) {
|
|
|
416
420
|
const implicitDefs = buildImplicitMeasures(jiraClient.customFieldIds);
|
|
417
421
|
const neededImplicits = compute ? detectImplicitMeasures(compute, implicitDefs) : [];
|
|
418
422
|
const groupBudget = maxGroupsForBudget(neededImplicits.length);
|
|
423
|
+
// Effective group cap: user's preference vs API query budget (whichever is smaller)
|
|
424
|
+
const effectiveGroupCap = Math.min(groupLimit, groupBudget);
|
|
419
425
|
if (groupBy === 'project') {
|
|
420
426
|
let keys = extractProjectKeys(jql);
|
|
421
427
|
if (keys.length === 0) {
|
|
422
428
|
throw new McpError(ErrorCode.InvalidParams, 'groupBy "project" requires project keys in JQL (e.g., project in (AA, GC))');
|
|
423
429
|
}
|
|
424
|
-
const capped = keys.length >
|
|
430
|
+
const capped = keys.length > effectiveGroupCap;
|
|
425
431
|
if (capped)
|
|
426
|
-
keys = keys.slice(0,
|
|
432
|
+
keys = keys.slice(0, effectiveGroupCap);
|
|
427
433
|
const remaining = removeProjectClause(jql);
|
|
428
434
|
const rows = await batchParallel(keys.map(k => () => buildCountRow(jiraClient, k, remaining ? `project = ${k} AND (${remaining})` : `project = ${k}`, neededImplicits, implicitDefs)), ROW_BATCH_SIZE);
|
|
429
435
|
rows.sort((a, b) => b.unresolved - a.unresolved);
|
|
430
436
|
lines.push('');
|
|
431
437
|
lines.push(renderSummaryTable(rows, compute));
|
|
432
438
|
if (capped) {
|
|
433
|
-
|
|
439
|
+
const reason = effectiveGroupCap < groupBudget
|
|
440
|
+
? `groupLimit=${effectiveGroupCap} — increase groupLimit to see more`
|
|
441
|
+
: `${MAX_COUNT_QUERIES}-query budget`;
|
|
442
|
+
lines.push(`*Capped at ${effectiveGroupCap} groups (${reason})*`);
|
|
434
443
|
}
|
|
435
444
|
}
|
|
436
445
|
else if (groupBy) {
|
|
@@ -439,23 +448,29 @@ async function handleSummary(jiraClient, jql, groupBy, compute) {
|
|
|
439
448
|
if (issues.length === 0) {
|
|
440
449
|
throw new McpError(ErrorCode.InvalidParams, `No issues matched JQL — cannot discover ${groupBy} values`);
|
|
441
450
|
}
|
|
442
|
-
const dims = extractDimensions(issues);
|
|
451
|
+
const dims = extractDimensions(issues, groupLimit);
|
|
443
452
|
const dim = dims.find(d => d.name === groupBy);
|
|
444
453
|
if (!dim || dim.values.length === 0) {
|
|
445
454
|
throw new McpError(ErrorCode.InvalidParams, `No ${groupBy} values found in sampled issues`);
|
|
446
455
|
}
|
|
447
|
-
// Cap groups to
|
|
448
|
-
const cappedValues = dim.values.slice(0,
|
|
456
|
+
// Cap groups to effective limit
|
|
457
|
+
const cappedValues = dim.values.slice(0, effectiveGroupCap);
|
|
449
458
|
const jqlClause = groupByJqlClause(groupBy, cappedValues);
|
|
450
459
|
const rows = await batchParallel(cappedValues.map((value, idx) => () => buildCountRow(jiraClient, value, `(${jql}) AND ${jqlClause[idx]}`, neededImplicits, implicitDefs)), ROW_BATCH_SIZE);
|
|
451
460
|
rows.sort((a, b) => b.unresolved - a.unresolved);
|
|
452
461
|
lines.push('');
|
|
453
462
|
lines.push(renderSummaryTable(rows, compute));
|
|
454
463
|
if (dim.count > cappedValues.length) {
|
|
455
|
-
const
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
464
|
+
const reasons = [];
|
|
465
|
+
if (cappedValues.length < dim.values.length) {
|
|
466
|
+
reasons.push(effectiveGroupCap < groupBudget
|
|
467
|
+
? `groupLimit=${effectiveGroupCap} — increase groupLimit to see more`
|
|
468
|
+
: `${MAX_COUNT_QUERIES}-query budget`);
|
|
469
|
+
}
|
|
470
|
+
else {
|
|
471
|
+
reasons.push(`from ${issues.length}-issue sample`);
|
|
472
|
+
}
|
|
473
|
+
lines.push(`*Showing top ${cappedValues.length} of ${dim.count} ${groupBy} values (${reasons.join(', ')})*`);
|
|
459
474
|
}
|
|
460
475
|
}
|
|
461
476
|
else {
|
|
@@ -472,7 +487,7 @@ function detectImplicitMeasures(compute, implicitMeasureDefs) {
|
|
|
472
487
|
return Object.keys(implicitMeasureDefs).filter(name => refs.has(name));
|
|
473
488
|
}
|
|
474
489
|
/** Extract distinct dimension values from sampled issues */
|
|
475
|
-
export function extractDimensions(issues) {
|
|
490
|
+
export function extractDimensions(issues, groupLimit = DEFAULT_GROUP_LIMIT) {
|
|
476
491
|
const dims = [
|
|
477
492
|
{ name: 'project', extractor: i => i.key.split('-')[0] },
|
|
478
493
|
{ name: 'status', extractor: i => i.status },
|
|
@@ -486,10 +501,10 @@ export function extractDimensions(issues) {
|
|
|
486
501
|
const val = extractor(issue);
|
|
487
502
|
counts.set(val, (counts.get(val) || 0) + 1);
|
|
488
503
|
}
|
|
489
|
-
// Sort by count descending, cap at
|
|
504
|
+
// Sort by count descending, cap at groupLimit
|
|
490
505
|
const sorted = [...counts.entries()]
|
|
491
506
|
.sort((a, b) => b[1] - a[1])
|
|
492
|
-
.slice(0,
|
|
507
|
+
.slice(0, groupLimit);
|
|
493
508
|
return {
|
|
494
509
|
name,
|
|
495
510
|
values: sorted.map(([v]) => v),
|
|
@@ -521,7 +536,7 @@ export function renderCubeSetup(jql, sampleSize, dimensions) {
|
|
|
521
536
|
lines.push('');
|
|
522
537
|
lines.push(`## Suggested Cubes (budget: ${MAX_COUNT_QUERIES} queries)`);
|
|
523
538
|
for (const dim of dimensions) {
|
|
524
|
-
const groups = Math.min(dim.count,
|
|
539
|
+
const groups = Math.min(dim.count, DEFAULT_GROUP_LIMIT);
|
|
525
540
|
const queries = groups * STANDARD_MEASURES;
|
|
526
541
|
const estSeconds = Math.max(1, Math.round(queries / 12)); // ~12 parallel queries/sec
|
|
527
542
|
const withinBudget = queries <= MAX_COUNT_QUERIES;
|
|
@@ -596,6 +611,9 @@ export async function handleAnalysisRequest(jiraClient, request) {
|
|
|
596
611
|
if (args.compute && Array.isArray(args.compute) && args.compute.length > 0) {
|
|
597
612
|
compute = parseComputeList(args.compute);
|
|
598
613
|
}
|
|
614
|
+
// Parse groupLimit — soft cap on group/dimension values (no hard cap for summary metrics)
|
|
615
|
+
const rawGroupLimit = Number(args.groupLimit);
|
|
616
|
+
const groupLimit = rawGroupLimit > 0 ? rawGroupLimit : DEFAULT_GROUP_LIMIT;
|
|
599
617
|
// Cube setup — discover dimensions from sample, no issue fetching
|
|
600
618
|
if (hasCubeSetup) {
|
|
601
619
|
const cubeText = await handleCubeSetup(jiraClient, jql);
|
|
@@ -609,7 +627,7 @@ export async function handleAnalysisRequest(jiraClient, request) {
|
|
|
609
627
|
}
|
|
610
628
|
// If only summary requested, skip issue fetching entirely
|
|
611
629
|
if (hasSummary && fetchMetrics.length === 0) {
|
|
612
|
-
const summaryText = await handleSummary(jiraClient, jql, groupBy, compute);
|
|
630
|
+
const summaryText = await handleSummary(jiraClient, jql, groupBy, compute, groupLimit);
|
|
613
631
|
const nextSteps = analysisNextSteps(jql, []);
|
|
614
632
|
return {
|
|
615
633
|
content: [{
|
|
@@ -618,8 +636,7 @@ export async function handleAnalysisRequest(jiraClient, request) {
|
|
|
618
636
|
}],
|
|
619
637
|
};
|
|
620
638
|
}
|
|
621
|
-
const
|
|
622
|
-
const maxResults = Math.min(Number(args.maxResults) || DEFAULT_MAX, MAX_ISSUES);
|
|
639
|
+
const maxResults = Math.min(Number(args.maxResults) || MAX_ISSUES_DEFAULT, MAX_ISSUES_HARD);
|
|
623
640
|
// Fetch issues using cursor-based pagination (50 per page, Jira enhanced search API)
|
|
624
641
|
const allIssues = [];
|
|
625
642
|
const seen = new Set();
|
|
@@ -658,7 +675,7 @@ export async function handleAnalysisRequest(jiraClient, request) {
|
|
|
658
675
|
const lines = [];
|
|
659
676
|
// Summary first if requested alongside other metrics
|
|
660
677
|
if (hasSummary) {
|
|
661
|
-
const summaryText = await handleSummary(jiraClient, jql, groupBy, compute);
|
|
678
|
+
const summaryText = await handleSummary(jiraClient, jql, groupBy, compute, groupLimit);
|
|
662
679
|
lines.push(summaryText);
|
|
663
680
|
}
|
|
664
681
|
// Header for fetch-based metrics
|
|
@@ -668,7 +685,7 @@ export async function handleAnalysisRequest(jiraClient, request) {
|
|
|
668
685
|
lines.push(`# Detail: ${jql}`);
|
|
669
686
|
lines.push(`Analyzed ${allIssues.length} issues (as of ${formatDateShort(now)})`);
|
|
670
687
|
if (truncated) {
|
|
671
|
-
lines.push(`*Results capped at ${maxResults} issues —
|
|
688
|
+
lines.push(`*Results capped at ${maxResults} issues — distributions below are approximate. For exact counts, use metrics: ["summary"] with groupBy instead.*`);
|
|
672
689
|
}
|
|
673
690
|
// Render requested metrics
|
|
674
691
|
const renderers = {
|
|
@@ -676,7 +693,7 @@ export async function handleAnalysisRequest(jiraClient, request) {
|
|
|
676
693
|
time: () => renderTime(allIssues),
|
|
677
694
|
schedule: () => renderSchedule(allIssues, now),
|
|
678
695
|
cycle: () => renderCycle(allIssues, now),
|
|
679
|
-
distribution: () => renderDistribution(allIssues),
|
|
696
|
+
distribution: () => renderDistribution(allIssues, groupLimit),
|
|
680
697
|
};
|
|
681
698
|
for (const metric of fetchMetrics) {
|
|
682
699
|
lines.push('');
|
|
@@ -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
|
}
|
|
@@ -326,7 +326,7 @@ export const toolSchemas = {
|
|
|
326
326
|
},
|
|
327
327
|
analyze_jira_issues: {
|
|
328
328
|
name: 'analyze_jira_issues',
|
|
329
|
-
description: 'Compute project metrics over issues selected by JQL.
|
|
329
|
+
description: 'Compute project metrics over issues selected by JQL. For counting and breakdown questions ("how many by status/assignee/priority"), use metrics: ["summary"] with groupBy — this gives exact counts with no issue cap. Use detail metrics (points, time, schedule, cycle, distribution) only when you need per-issue analysis; these are capped at maxResults issues. Always prefer this tool over manage_jira_filter or manage_jira_project for quantitative questions. Read jira://analysis/recipes for composition patterns.',
|
|
330
330
|
inputSchema: {
|
|
331
331
|
type: 'object',
|
|
332
332
|
properties: {
|
|
@@ -340,12 +340,12 @@ export const toolSchemas = {
|
|
|
340
340
|
type: 'string',
|
|
341
341
|
enum: ['summary', 'points', 'time', 'schedule', 'cycle', 'distribution', 'cube_setup'],
|
|
342
342
|
},
|
|
343
|
-
description: 'Which metric groups to compute. summary = exact
|
|
343
|
+
description: 'Which metric groups to compute. summary = exact counts via count API (no issue cap, fastest) — use with groupBy for "how many by assignee/status/priority" questions. distribution = approximate counts from fetched issues (capped by maxResults — use summary + groupBy instead when you need exact counts). cube_setup = discover dimensions before cube queries. points = earned value/SPI. time = effort estimates. schedule = overdue/risk. cycle = lead time/throughput. Default: all detail metrics. For counting/breakdown questions, always prefer summary + groupBy over distribution.',
|
|
344
344
|
},
|
|
345
345
|
groupBy: {
|
|
346
346
|
type: 'string',
|
|
347
347
|
enum: ['project', 'assignee', 'priority', 'issuetype'],
|
|
348
|
-
description: 'Split
|
|
348
|
+
description: 'Split counts by this dimension — produces a breakdown table. Use with metrics: ["summary"] for exact counts. This is the correct approach for "how many issues per assignee/priority/type" questions. "project" produces a per-project comparison.',
|
|
349
349
|
},
|
|
350
350
|
compute: {
|
|
351
351
|
type: 'array',
|
|
@@ -353,6 +353,11 @@ export const toolSchemas = {
|
|
|
353
353
|
description: 'Computed columns for cube execute. Each entry: "name = expr". Arithmetic (+,-,*,/), comparisons (>,<,>=,<=,==,!=). Column refs: total, open, overdue, high, created_7d, resolved_7d. Implicit measures resolved lazily: bugs, unassigned, no_due_date, no_estimate, no_start_date, no_labels, blocked, stale (untouched 60d+), stale_status (stuck in status 30d+), backlog_rot (undated+unassigned+untouched 60d+). Max 5 expressions. Example: ["bug_pct = bugs / total * 100", "rot_pct = backlog_rot / open * 100"].',
|
|
354
354
|
maxItems: 5,
|
|
355
355
|
},
|
|
356
|
+
groupLimit: {
|
|
357
|
+
type: 'integer',
|
|
358
|
+
description: 'Max groups/dimension values to show (default 20). Applies to summary groupBy rows and distribution breakdowns. No hard cap for summary metrics — increase freely for full visibility. For detail metrics the issue fetch is separately capped by maxResults.',
|
|
359
|
+
default: 20,
|
|
360
|
+
},
|
|
356
361
|
maxResults: {
|
|
357
362
|
type: 'integer',
|
|
358
363
|
description: 'Max issues to fetch for detail metrics (default 100, max 500). Does not apply to summary (which uses count API).',
|
|
@@ -132,7 +132,7 @@ export function analysisNextSteps(jql, issueKeys, truncated = false) {
|
|
|
132
132
|
}
|
|
133
133
|
steps.push({ description: 'Discover dimensions for cube analysis', tool: 'analyze_jira_issues', example: { jql, metrics: ['cube_setup'] } }, { description: 'Add computed columns', tool: 'analyze_jira_issues', example: { jql, metrics: ['summary'], groupBy: 'project', compute: ['bug_pct = bugs / total * 100'] } }, { description: 'Narrow the analysis with refined JQL', tool: 'analyze_jira_issues', example: { jql: `${jql} AND priority = High` } }, { description: 'View the full issue list', tool: 'manage_jira_filter', example: { operation: 'execute_jql', jql } });
|
|
134
134
|
if (truncated) {
|
|
135
|
-
steps.push({ description: '
|
|
135
|
+
steps.push({ description: 'Distribution counts above are approximate (issue cap hit). For exact breakdowns use summary + groupBy', tool: 'analyze_jira_issues', example: { jql, metrics: ['summary'], groupBy: 'assignee' } }, { description: 'Or narrow JQL for precise detail metrics', tool: 'analyze_jira_issues', example: { jql: `${jql} AND assignee = currentUser()`, metrics: ['cycle'] } });
|
|
136
136
|
}
|
|
137
137
|
return formatSteps(steps) + '\n- Read `jira://analysis/recipes` for data cube patterns and compute DSL examples';
|
|
138
138
|
}
|
|
@@ -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