@aaronsb/jira-cloud-mcp 0.4.3 → 0.5.1
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.
|
@@ -100,6 +100,7 @@ export class JiraClient {
|
|
|
100
100
|
assignee: fields?.assignee?.displayName || null,
|
|
101
101
|
reporter: fields?.reporter?.displayName || '',
|
|
102
102
|
status: fields?.status?.name || '',
|
|
103
|
+
statusCategory: fields?.status?.statusCategory?.key || 'unknown',
|
|
103
104
|
resolution: fields?.resolution?.name || null,
|
|
104
105
|
labels: fields?.labels || [],
|
|
105
106
|
created: fields?.created || '',
|
|
@@ -107,8 +108,8 @@ export class JiraClient {
|
|
|
107
108
|
resolutionDate: fields?.resolutiondate || null,
|
|
108
109
|
dueDate: fields?.duedate || null,
|
|
109
110
|
startDate: fields?.[this.customFields.startDate] || null,
|
|
110
|
-
storyPoints: fields?.[this.customFields.storyPoints]
|
|
111
|
-
timeEstimate: fields?.timeestimate
|
|
111
|
+
storyPoints: fields?.[this.customFields.storyPoints] ?? null,
|
|
112
|
+
timeEstimate: fields?.timeestimate ?? null,
|
|
112
113
|
issueLinks: (fields?.issuelinks || []).map((link) => ({
|
|
113
114
|
type: link.type?.name || '',
|
|
114
115
|
outward: link.outwardIssue?.key || null,
|
|
@@ -406,6 +407,60 @@ export class JiraClient {
|
|
|
406
407
|
comment: TextProcessor.markdownToAdf(commentBody)
|
|
407
408
|
});
|
|
408
409
|
}
|
|
410
|
+
/** Lightweight search returning only fields needed for analysis (no description, links, rendered HTML) */
|
|
411
|
+
async countIssues(jql) {
|
|
412
|
+
try {
|
|
413
|
+
const cleanJql = jql.replace(/\\"/g, '"');
|
|
414
|
+
console.error(`Counting issues for JQL: ${cleanJql}`);
|
|
415
|
+
const result = await this.client.issueSearch.countIssues({ jql: cleanJql });
|
|
416
|
+
return result.count ?? 0;
|
|
417
|
+
}
|
|
418
|
+
catch (error) {
|
|
419
|
+
console.error('Error counting issues:', error);
|
|
420
|
+
throw error;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
async searchIssuesLean(jql, maxResults = 50, nextPageToken) {
|
|
424
|
+
try {
|
|
425
|
+
const cleanJql = jql.replace(/\\"/g, '"');
|
|
426
|
+
console.error(`Executing lean JQL search with query: ${cleanJql}${nextPageToken ? ' (page token)' : ''}`);
|
|
427
|
+
const leanFields = [
|
|
428
|
+
'summary', 'issuetype', 'priority', 'assignee', 'reporter',
|
|
429
|
+
'status', 'resolution', 'labels', 'created', 'updated',
|
|
430
|
+
'resolutiondate', 'duedate', 'timeestimate',
|
|
431
|
+
this.customFields.startDate, this.customFields.storyPoints,
|
|
432
|
+
];
|
|
433
|
+
const params = {
|
|
434
|
+
jql: cleanJql,
|
|
435
|
+
maxResults: Math.min(maxResults, 50),
|
|
436
|
+
fields: leanFields,
|
|
437
|
+
};
|
|
438
|
+
if (nextPageToken) {
|
|
439
|
+
params.nextPageToken = nextPageToken;
|
|
440
|
+
}
|
|
441
|
+
const timeoutMs = 30_000;
|
|
442
|
+
const searchResults = await Promise.race([
|
|
443
|
+
this.client.issueSearch.searchForIssuesUsingJqlEnhancedSearch(params),
|
|
444
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error(`Lean search timed out after ${timeoutMs / 1000}s — try a narrower JQL query or smaller maxResults`)), timeoutMs)),
|
|
445
|
+
]);
|
|
446
|
+
const issues = (searchResults.issues || []).map(issue => this.mapIssueFields(issue));
|
|
447
|
+
const hasMore = !!searchResults.nextPageToken;
|
|
448
|
+
return {
|
|
449
|
+
issues,
|
|
450
|
+
pagination: {
|
|
451
|
+
startAt: 0,
|
|
452
|
+
maxResults,
|
|
453
|
+
total: hasMore ? issues.length + 1 : issues.length,
|
|
454
|
+
hasMore,
|
|
455
|
+
nextPageToken: searchResults.nextPageToken || undefined,
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
catch (error) {
|
|
460
|
+
console.error('Error executing lean JQL search:', error);
|
|
461
|
+
throw error;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
409
464
|
async searchIssues(jql, startAt = 0, maxResults = 25) {
|
|
410
465
|
try {
|
|
411
466
|
// Remove escaped quotes from JQL
|
|
@@ -15,6 +15,7 @@ const documentationGenerators = {
|
|
|
15
15
|
manage_jira_filter: generateFilterToolDocumentation,
|
|
16
16
|
manage_jira_project: generateProjectToolDocumentation,
|
|
17
17
|
queue_jira_operations: generateQueueToolDocumentation,
|
|
18
|
+
analyze_jira_issues: generateAnalysisToolDocumentation,
|
|
18
19
|
};
|
|
19
20
|
export function generateToolDocumentation(toolName, schema) {
|
|
20
21
|
const generator = documentationGenerators[toolName];
|
|
@@ -460,6 +461,72 @@ function generateQueueToolDocumentation(_schema) {
|
|
|
460
461
|
related_resources: [],
|
|
461
462
|
};
|
|
462
463
|
}
|
|
464
|
+
function generateAnalysisToolDocumentation(schema) {
|
|
465
|
+
return {
|
|
466
|
+
name: "Issue Analysis",
|
|
467
|
+
description: schema.description,
|
|
468
|
+
parameters: {
|
|
469
|
+
jql: {
|
|
470
|
+
type: "string",
|
|
471
|
+
description: "JQL query selecting the issues to analyze.",
|
|
472
|
+
required: true,
|
|
473
|
+
},
|
|
474
|
+
metrics: {
|
|
475
|
+
type: "array of strings",
|
|
476
|
+
description: "Which metric groups to compute. summary uses count API (no cap). Others fetch issue data (subject to maxResults).",
|
|
477
|
+
values: ["summary", "points", "time", "schedule", "cycle", "distribution"],
|
|
478
|
+
},
|
|
479
|
+
groupBy: {
|
|
480
|
+
type: "string",
|
|
481
|
+
description: "Split summary counts by dimension. Use with metrics: ['summary'].",
|
|
482
|
+
values: ["project", "assignee", "priority", "issuetype"],
|
|
483
|
+
},
|
|
484
|
+
maxResults: {
|
|
485
|
+
type: "integer",
|
|
486
|
+
description: "Max issues to fetch for detail metrics (default 100, max 500). Does not apply to summary.",
|
|
487
|
+
},
|
|
488
|
+
},
|
|
489
|
+
metric_groups: {
|
|
490
|
+
summary: "Exact counts — total, open, overdue, high priority, created/resolved last 7 days. No sampling cap. Supports groupBy for cross-project comparison.",
|
|
491
|
+
points: "Earned Value — PV, EV, remaining, SPI, status breakdown, unestimated count",
|
|
492
|
+
time: "Effort — original estimate, completed, remaining by status category",
|
|
493
|
+
schedule: "Risk — date window, overdue count/slip, due soon, concentration risk, missing dates",
|
|
494
|
+
cycle: "Flow — lead time median/mean, throughput, open issue age, oldest open",
|
|
495
|
+
distribution: "Composition — counts by status, assignee, priority, issue type",
|
|
496
|
+
},
|
|
497
|
+
common_use_cases: [
|
|
498
|
+
{
|
|
499
|
+
title: "Cross-project comparison",
|
|
500
|
+
description: "Compare issue counts across multiple projects (exact, no cap):",
|
|
501
|
+
steps: [
|
|
502
|
+
{ description: "Summary by project", code: { jql: "project in (AA, GC, GD, LGS)", metrics: ["summary"], groupBy: "project" } },
|
|
503
|
+
],
|
|
504
|
+
},
|
|
505
|
+
{
|
|
506
|
+
title: "Sprint health check",
|
|
507
|
+
description: "Analyze all issues in the current sprint:",
|
|
508
|
+
steps: [
|
|
509
|
+
{ description: "Full analysis", code: { jql: "sprint in openSprints()" } },
|
|
510
|
+
],
|
|
511
|
+
},
|
|
512
|
+
{
|
|
513
|
+
title: "Schedule risk for a release",
|
|
514
|
+
description: "Check overdue and upcoming deadlines:",
|
|
515
|
+
steps: [
|
|
516
|
+
{ description: "Schedule only", code: { jql: "project = AA AND fixVersion = 2.0", metrics: ["schedule"] } },
|
|
517
|
+
],
|
|
518
|
+
},
|
|
519
|
+
{
|
|
520
|
+
title: "Quick project overview",
|
|
521
|
+
description: "Get exact issue counts without fetching data:",
|
|
522
|
+
steps: [
|
|
523
|
+
{ description: "Summary only", code: { jql: "project = AA", metrics: ["summary"] } },
|
|
524
|
+
],
|
|
525
|
+
},
|
|
526
|
+
],
|
|
527
|
+
related_resources: [],
|
|
528
|
+
};
|
|
529
|
+
}
|
|
463
530
|
function generateGenericToolDocumentation(toolName, schema) {
|
|
464
531
|
const operations = {};
|
|
465
532
|
if (schema.inputSchema?.properties?.operation?.enum) {
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
import { analysisNextSteps } from '../utils/next-steps.js';
|
|
3
|
+
import { normalizeArgs } from '../utils/normalize-args.js';
|
|
4
|
+
const ALL_METRICS = ['points', 'time', 'schedule', 'cycle', 'distribution'];
|
|
5
|
+
const VALID_GROUP_BY = ['project', 'assignee', 'priority', 'issuetype'];
|
|
6
|
+
const MAX_ISSUES = 500;
|
|
7
|
+
// ── Helpers ────────────────────────────────────────────────────────────
|
|
8
|
+
function bucketStatus(category) {
|
|
9
|
+
switch (category) {
|
|
10
|
+
case 'new': return 'To Do';
|
|
11
|
+
case 'indeterminate': return 'In Progress';
|
|
12
|
+
case 'done': return 'Done';
|
|
13
|
+
default: return 'To Do';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/** Parse a date string to a Date without timezone shift for date-only values */
|
|
17
|
+
function parseDate(dateStr) {
|
|
18
|
+
const dateOnly = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
|
19
|
+
if (dateOnly) {
|
|
20
|
+
const [, y, m, d] = dateOnly;
|
|
21
|
+
return new Date(Number(y), Number(m) - 1, Number(d));
|
|
22
|
+
}
|
|
23
|
+
return new Date(dateStr);
|
|
24
|
+
}
|
|
25
|
+
function formatDateShort(date) {
|
|
26
|
+
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
|
27
|
+
}
|
|
28
|
+
function daysBetween(a, b) {
|
|
29
|
+
return Math.round((b.getTime() - a.getTime()) / (1000 * 60 * 60 * 24));
|
|
30
|
+
}
|
|
31
|
+
function formatDuration(seconds) {
|
|
32
|
+
const hours = Math.floor(seconds / 3600);
|
|
33
|
+
const days = Math.floor(hours / 8); // 8-hour work day
|
|
34
|
+
if (days > 0) {
|
|
35
|
+
const remainingHours = hours % 8;
|
|
36
|
+
return remainingHours > 0 ? `${days}d ${remainingHours}h` : `${days}d`;
|
|
37
|
+
}
|
|
38
|
+
const minutes = Math.floor((seconds % 3600) / 60);
|
|
39
|
+
if (hours > 0)
|
|
40
|
+
return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
|
|
41
|
+
return `${minutes}m`;
|
|
42
|
+
}
|
|
43
|
+
function countBy(items, keyFn) {
|
|
44
|
+
const counts = new Map();
|
|
45
|
+
for (const item of items) {
|
|
46
|
+
const key = keyFn(item);
|
|
47
|
+
counts.set(key, (counts.get(key) || 0) + 1);
|
|
48
|
+
}
|
|
49
|
+
return counts;
|
|
50
|
+
}
|
|
51
|
+
function sumBy(items, valueFn) {
|
|
52
|
+
return items.reduce((sum, item) => sum + (valueFn(item) || 0), 0);
|
|
53
|
+
}
|
|
54
|
+
function mapToString(map, separator = ' | ') {
|
|
55
|
+
return [...map.entries()]
|
|
56
|
+
.sort((a, b) => b[1] - a[1])
|
|
57
|
+
.map(([k, v]) => `${k}: ${v}`)
|
|
58
|
+
.join(separator);
|
|
59
|
+
}
|
|
60
|
+
function median(values) {
|
|
61
|
+
if (values.length === 0)
|
|
62
|
+
return 0;
|
|
63
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
64
|
+
const mid = Math.floor(sorted.length / 2);
|
|
65
|
+
return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
66
|
+
}
|
|
67
|
+
function mean(values) {
|
|
68
|
+
if (values.length === 0)
|
|
69
|
+
return 0;
|
|
70
|
+
return values.reduce((s, v) => s + v, 0) / values.length;
|
|
71
|
+
}
|
|
72
|
+
// ── Metric Renderers (exported for testing) ────────────────────────────
|
|
73
|
+
export function renderPoints(issues) {
|
|
74
|
+
const estimated = issues.filter(i => i.storyPoints != null);
|
|
75
|
+
const unestimated = issues.length - estimated.length;
|
|
76
|
+
const byBucket = new Map();
|
|
77
|
+
for (const issue of issues) {
|
|
78
|
+
const bucket = bucketStatus(issue.statusCategory);
|
|
79
|
+
byBucket.set(bucket, (byBucket.get(bucket) ?? 0) + (issue.storyPoints ?? 0));
|
|
80
|
+
}
|
|
81
|
+
const pv = sumBy(issues, i => i.storyPoints);
|
|
82
|
+
const ev = byBucket.get('Done') ?? 0;
|
|
83
|
+
const remaining = pv - ev;
|
|
84
|
+
const spi = pv > 0 ? (ev / pv) : null;
|
|
85
|
+
const lines = ['## Points (Earned Value)', ''];
|
|
86
|
+
lines.push('| Metric | Value |');
|
|
87
|
+
lines.push('|--------|-------|');
|
|
88
|
+
lines.push(`| Planned Value (PV) | ${pv} pts |`);
|
|
89
|
+
lines.push(`| Earned Value (EV) | ${ev} pts |`);
|
|
90
|
+
lines.push(`| Remaining | ${remaining} pts |`);
|
|
91
|
+
lines.push(`| SPI | ${spi !== null ? spi.toFixed(2) : 'N/A (no estimates)'} |`);
|
|
92
|
+
if (unestimated > 0) {
|
|
93
|
+
lines.push(`| Unestimated | ${unestimated} issue${unestimated !== 1 ? 's' : ''} |`);
|
|
94
|
+
}
|
|
95
|
+
lines.push('');
|
|
96
|
+
const bucketStr = ['To Do', 'In Progress', 'Done']
|
|
97
|
+
.map(b => `${b}: ${byBucket.get(b) ?? 0} pts`)
|
|
98
|
+
.join(' | ');
|
|
99
|
+
lines.push(`**By status:** ${bucketStr}`);
|
|
100
|
+
return lines.join('\n');
|
|
101
|
+
}
|
|
102
|
+
export function renderTime(issues) {
|
|
103
|
+
const estimated = issues.filter(i => i.timeEstimate != null);
|
|
104
|
+
const unestimated = issues.length - estimated.length;
|
|
105
|
+
const total = sumBy(issues, i => i.timeEstimate);
|
|
106
|
+
const byBucket = new Map();
|
|
107
|
+
for (const issue of issues) {
|
|
108
|
+
const bucket = bucketStatus(issue.statusCategory);
|
|
109
|
+
byBucket.set(bucket, (byBucket.get(bucket) ?? 0) + (issue.timeEstimate ?? 0));
|
|
110
|
+
}
|
|
111
|
+
const done = byBucket.get('Done') ?? 0;
|
|
112
|
+
const remaining = total - done;
|
|
113
|
+
const lines = ['## Time (Effort)', ''];
|
|
114
|
+
lines.push('| Metric | Value |');
|
|
115
|
+
lines.push('|--------|-------|');
|
|
116
|
+
lines.push(`| Original Estimate | ${formatDuration(total)} |`);
|
|
117
|
+
lines.push(`| Completed | ${formatDuration(done)} |`);
|
|
118
|
+
lines.push(`| Remaining | ${formatDuration(remaining)} |`);
|
|
119
|
+
if (unestimated > 0) {
|
|
120
|
+
lines.push(`| Unestimated | ${unestimated} issue${unestimated !== 1 ? 's' : ''} |`);
|
|
121
|
+
}
|
|
122
|
+
return lines.join('\n');
|
|
123
|
+
}
|
|
124
|
+
export function renderSchedule(issues, now) {
|
|
125
|
+
const lines = ['## Schedule', ''];
|
|
126
|
+
// Date range
|
|
127
|
+
const startDates = issues.filter(i => i.startDate).map(i => parseDate(i.startDate));
|
|
128
|
+
const dueDates = issues.filter(i => i.dueDate).map(i => parseDate(i.dueDate));
|
|
129
|
+
const allDates = [...startDates, ...dueDates];
|
|
130
|
+
if (allDates.length > 0) {
|
|
131
|
+
const earliest = new Date(Math.min(...allDates.map(d => d.getTime())));
|
|
132
|
+
const latest = new Date(Math.max(...allDates.map(d => d.getTime())));
|
|
133
|
+
lines.push(`**Window:** ${formatDateShort(earliest)} - ${formatDateShort(latest)}`);
|
|
134
|
+
}
|
|
135
|
+
// Overdue
|
|
136
|
+
const overdue = issues.filter(i => i.dueDate && !i.resolutionDate && parseDate(i.dueDate) < now);
|
|
137
|
+
if (overdue.length > 0) {
|
|
138
|
+
const totalSlip = overdue.reduce((sum, i) => sum + daysBetween(parseDate(i.dueDate), now), 0);
|
|
139
|
+
const keys = overdue.slice(0, 5).map(i => i.key).join(', ');
|
|
140
|
+
const more = overdue.length > 5 ? ` +${overdue.length - 5} more` : '';
|
|
141
|
+
lines.push(`**Overdue:** ${overdue.length} issue${overdue.length !== 1 ? 's' : ''}, ${totalSlip} days total slip (${keys}${more})`);
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
lines.push('**Overdue:** none');
|
|
145
|
+
}
|
|
146
|
+
// Due soon
|
|
147
|
+
for (const window of [7, 14, 30]) {
|
|
148
|
+
const cutoff = new Date(now.getTime() + window * 24 * 60 * 60 * 1000);
|
|
149
|
+
const dueSoon = issues.filter(i => i.dueDate && !i.resolutionDate &&
|
|
150
|
+
parseDate(i.dueDate) >= now && parseDate(i.dueDate) <= cutoff);
|
|
151
|
+
if (dueSoon.length > 0) {
|
|
152
|
+
lines.push(`**Due next ${window} days:** ${dueSoon.length} issue${dueSoon.length !== 1 ? 's' : ''}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// Concentration risk
|
|
156
|
+
const dueDateCounts = countBy(issues.filter(i => i.dueDate && !i.resolutionDate), i => i.dueDate);
|
|
157
|
+
const concentrated = [...dueDateCounts.entries()]
|
|
158
|
+
.filter(([, count]) => count >= 3)
|
|
159
|
+
.sort((a, b) => b[1] - a[1]);
|
|
160
|
+
if (concentrated.length > 0) {
|
|
161
|
+
const top = concentrated.slice(0, 3)
|
|
162
|
+
.map(([date, count]) => `${formatDateShort(parseDate(date))} has ${count} issues`)
|
|
163
|
+
.join('; ');
|
|
164
|
+
lines.push(`**Concentration:** ${top}`);
|
|
165
|
+
}
|
|
166
|
+
// No due date
|
|
167
|
+
const noDueDate = issues.filter(i => !i.dueDate && !i.resolutionDate);
|
|
168
|
+
if (noDueDate.length > 0) {
|
|
169
|
+
lines.push(`**No due date:** ${noDueDate.length} issue${noDueDate.length !== 1 ? 's' : ''}`);
|
|
170
|
+
}
|
|
171
|
+
return lines.join('\n');
|
|
172
|
+
}
|
|
173
|
+
export function renderCycle(issues, now) {
|
|
174
|
+
const lines = ['## Cycle (Flow Metrics)', ''];
|
|
175
|
+
// Lead time for resolved issues
|
|
176
|
+
const resolved = issues.filter(i => i.resolutionDate && i.created);
|
|
177
|
+
if (resolved.length > 0) {
|
|
178
|
+
const leadTimes = resolved.map(i => daysBetween(parseDate(i.created), parseDate(i.resolutionDate)));
|
|
179
|
+
const med = median(leadTimes);
|
|
180
|
+
const avg = mean(leadTimes);
|
|
181
|
+
lines.push(`**Lead time (resolved):** median ${med.toFixed(1)} days, mean ${avg.toFixed(1)} days (${resolved.length} issues)`);
|
|
182
|
+
// Throughput
|
|
183
|
+
const createdDates = resolved.map(i => parseDate(i.resolutionDate).getTime());
|
|
184
|
+
const earliest = Math.min(...createdDates);
|
|
185
|
+
const latest = Math.max(...createdDates);
|
|
186
|
+
const weeks = Math.max(1, (latest - earliest) / (7 * 24 * 60 * 60 * 1000));
|
|
187
|
+
const throughput = resolved.length / weeks;
|
|
188
|
+
lines.push(`**Throughput:** ${throughput.toFixed(1)} issues/week`);
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
lines.push('**Lead time:** no resolved issues in set');
|
|
192
|
+
}
|
|
193
|
+
// Age of open issues
|
|
194
|
+
const open = issues.filter(i => !i.resolutionDate && i.created);
|
|
195
|
+
if (open.length > 0) {
|
|
196
|
+
const ages = open.map(i => daysBetween(parseDate(i.created), now));
|
|
197
|
+
const avgAge = mean(ages);
|
|
198
|
+
lines.push(`**Open issue age:** mean ${avgAge.toFixed(1)} days (${open.length} issues)`);
|
|
199
|
+
// Oldest open
|
|
200
|
+
const oldest = open
|
|
201
|
+
.map(i => ({ key: i.key, age: daysBetween(parseDate(i.created), now) }))
|
|
202
|
+
.sort((a, b) => b.age - a.age)
|
|
203
|
+
.slice(0, 5);
|
|
204
|
+
const oldestStr = oldest.map(o => `${o.key} (${o.age}d)`).join(', ');
|
|
205
|
+
lines.push(`**Oldest open:** ${oldestStr}`);
|
|
206
|
+
}
|
|
207
|
+
return lines.join('\n');
|
|
208
|
+
}
|
|
209
|
+
export function renderDistribution(issues) {
|
|
210
|
+
const lines = ['## Distribution', ''];
|
|
211
|
+
const byStatus = countBy(issues, i => i.status);
|
|
212
|
+
lines.push(`**By status:** ${mapToString(byStatus)}`);
|
|
213
|
+
const byAssignee = countBy(issues, i => i.assignee || 'Unassigned');
|
|
214
|
+
lines.push(`**By assignee:** ${mapToString(byAssignee)}`);
|
|
215
|
+
const byPriority = countBy(issues, i => i.priority || 'None');
|
|
216
|
+
lines.push(`**By priority:** ${mapToString(byPriority)}`);
|
|
217
|
+
const byType = countBy(issues, i => i.issueType || 'Unknown');
|
|
218
|
+
lines.push(`**By type:** ${mapToString(byType)}`);
|
|
219
|
+
return lines.join('\n');
|
|
220
|
+
}
|
|
221
|
+
/** Extract project keys from JQL like "project in (AA, GC, GD)" or "project = AA" */
|
|
222
|
+
export function extractProjectKeys(jql) {
|
|
223
|
+
// project in (AA, GC, GD)
|
|
224
|
+
const inMatch = jql.match(/project\s+in\s*\(([^)]+)\)/i);
|
|
225
|
+
if (inMatch) {
|
|
226
|
+
return inMatch[1].split(',').map(k => k.trim().replace(/['"]/g, ''));
|
|
227
|
+
}
|
|
228
|
+
// project = AA
|
|
229
|
+
const eqMatch = jql.match(/project\s*=\s*['"]?(\w+)['"]?/i);
|
|
230
|
+
if (eqMatch) {
|
|
231
|
+
return [eqMatch[1]];
|
|
232
|
+
}
|
|
233
|
+
return [];
|
|
234
|
+
}
|
|
235
|
+
/** Remove the project clause from JQL, returning remaining constraints */
|
|
236
|
+
export function removeProjectClause(jql) {
|
|
237
|
+
return jql
|
|
238
|
+
.replace(/project\s+in\s*\([^)]+\)\s*(AND\s*)?/i, '')
|
|
239
|
+
.replace(/project\s*=\s*['"]?\w+['"]?\s*(AND\s*)?/i, '')
|
|
240
|
+
.replace(/^\s*AND\s*/i, '')
|
|
241
|
+
.replace(/\s*AND\s*$/i, '')
|
|
242
|
+
.trim();
|
|
243
|
+
}
|
|
244
|
+
/** Build a scoped JQL by adding a condition to the base query */
|
|
245
|
+
function scopeJql(baseJql, condition) {
|
|
246
|
+
return `(${baseJql}) AND ${condition}`;
|
|
247
|
+
}
|
|
248
|
+
async function buildCountRow(jiraClient, label, baseJql) {
|
|
249
|
+
const [total, unresolved, overdue, highPriority, createdRecently, resolvedRecently] = await Promise.all([
|
|
250
|
+
jiraClient.countIssues(baseJql),
|
|
251
|
+
jiraClient.countIssues(scopeJql(baseJql, 'resolution = Unresolved')),
|
|
252
|
+
jiraClient.countIssues(scopeJql(baseJql, 'resolution = Unresolved AND dueDate < now()')),
|
|
253
|
+
jiraClient.countIssues(scopeJql(baseJql, 'priority in (High, Highest, Critical, Blocker)')),
|
|
254
|
+
jiraClient.countIssues(scopeJql(baseJql, 'created >= -7d')),
|
|
255
|
+
jiraClient.countIssues(scopeJql(baseJql, 'resolved >= -7d')),
|
|
256
|
+
]);
|
|
257
|
+
return { label, total, unresolved, overdue, highPriority, createdRecently, resolvedRecently };
|
|
258
|
+
}
|
|
259
|
+
export function renderSummaryTable(rows) {
|
|
260
|
+
const lines = ['## Summary (exact counts)', ''];
|
|
261
|
+
lines.push('| Scope | Total | Open | Overdue | High+ | Created 7d | Resolved 7d |');
|
|
262
|
+
lines.push('|-------|------:|-----:|--------:|------:|-----------:|------------:|');
|
|
263
|
+
for (const r of rows) {
|
|
264
|
+
lines.push(`| ${r.label} | ${r.total} | ${r.unresolved} | ${r.overdue} | ${r.highPriority} | ${r.createdRecently} | ${r.resolvedRecently} |`);
|
|
265
|
+
}
|
|
266
|
+
// Totals row if multiple
|
|
267
|
+
if (rows.length > 1) {
|
|
268
|
+
const sum = (fn) => rows.reduce((s, r) => s + fn(r), 0);
|
|
269
|
+
lines.push(`| **Total** | **${sum(r => r.total)}** | **${sum(r => r.unresolved)}** | **${sum(r => r.overdue)}** | **${sum(r => r.highPriority)}** | **${sum(r => r.createdRecently)}** | **${sum(r => r.resolvedRecently)}** |`);
|
|
270
|
+
}
|
|
271
|
+
return lines.join('\n');
|
|
272
|
+
}
|
|
273
|
+
async function handleSummary(jiraClient, jql, groupBy) {
|
|
274
|
+
const lines = [];
|
|
275
|
+
lines.push(`# Summary: ${jql}`);
|
|
276
|
+
lines.push(`As of ${formatDateShort(new Date())} — counts are exact (no sampling cap)`);
|
|
277
|
+
if (groupBy === 'project') {
|
|
278
|
+
const keys = extractProjectKeys(jql);
|
|
279
|
+
if (keys.length === 0) {
|
|
280
|
+
throw new McpError(ErrorCode.InvalidParams, 'groupBy "project" requires project keys in JQL (e.g., project in (AA, GC))');
|
|
281
|
+
}
|
|
282
|
+
const remaining = removeProjectClause(jql);
|
|
283
|
+
const rows = await Promise.all(keys.map(k => buildCountRow(jiraClient, k, remaining ? `project = ${k} AND (${remaining})` : `project = ${k}`)));
|
|
284
|
+
rows.sort((a, b) => b.unresolved - a.unresolved);
|
|
285
|
+
lines.push('');
|
|
286
|
+
lines.push(renderSummaryTable(rows));
|
|
287
|
+
}
|
|
288
|
+
else if (groupBy) {
|
|
289
|
+
// For non-project groupBy, get the overall count first
|
|
290
|
+
const overallRow = await buildCountRow(jiraClient, 'All', jql);
|
|
291
|
+
lines.push('');
|
|
292
|
+
lines.push(renderSummaryTable([overallRow]));
|
|
293
|
+
lines.push('');
|
|
294
|
+
lines.push(`*groupBy "${groupBy}" — only "project" supports per-group breakdown currently. Other dimensions coming soon.*`);
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
// No groupBy — single row for the whole JQL
|
|
298
|
+
const row = await buildCountRow(jiraClient, 'All', jql);
|
|
299
|
+
lines.push('');
|
|
300
|
+
lines.push(renderSummaryTable([row]));
|
|
301
|
+
}
|
|
302
|
+
return lines.join('\n');
|
|
303
|
+
}
|
|
304
|
+
// ── Main Handler ───────────────────────────────────────────────────────
|
|
305
|
+
export async function handleAnalysisRequest(jiraClient, request) {
|
|
306
|
+
const args = normalizeArgs(request.params?.arguments || {});
|
|
307
|
+
const jql = args.jql;
|
|
308
|
+
if (!jql || typeof jql !== 'string' || jql.trim() === '') {
|
|
309
|
+
throw new McpError(ErrorCode.InvalidParams, 'jql parameter is required.');
|
|
310
|
+
}
|
|
311
|
+
// Parse requested metrics
|
|
312
|
+
const requested = (args.metrics && Array.isArray(args.metrics))
|
|
313
|
+
? args.metrics
|
|
314
|
+
: [];
|
|
315
|
+
const hasSummary = requested.includes('summary');
|
|
316
|
+
const fetchMetrics = requested.length > 0
|
|
317
|
+
? requested.filter(m => ALL_METRICS.includes(m))
|
|
318
|
+
: ALL_METRICS;
|
|
319
|
+
// Parse groupBy
|
|
320
|
+
const groupBy = (typeof args.groupBy === 'string' && VALID_GROUP_BY.includes(args.groupBy))
|
|
321
|
+
? args.groupBy
|
|
322
|
+
: undefined;
|
|
323
|
+
// If only summary requested, skip issue fetching entirely
|
|
324
|
+
if (hasSummary && fetchMetrics.length === 0) {
|
|
325
|
+
const summaryText = await handleSummary(jiraClient, jql, groupBy);
|
|
326
|
+
const nextSteps = analysisNextSteps(jql, []);
|
|
327
|
+
return {
|
|
328
|
+
content: [{
|
|
329
|
+
type: 'text',
|
|
330
|
+
text: summaryText + '\n' + nextSteps,
|
|
331
|
+
}],
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
const DEFAULT_MAX = 100;
|
|
335
|
+
const maxResults = Math.min(Number(args.maxResults) || DEFAULT_MAX, MAX_ISSUES);
|
|
336
|
+
// Fetch issues using cursor-based pagination (50 per page, Jira enhanced search API)
|
|
337
|
+
const allIssues = [];
|
|
338
|
+
const seen = new Set();
|
|
339
|
+
let nextPageToken;
|
|
340
|
+
let truncated = false;
|
|
341
|
+
const maxPages = Math.ceil(maxResults / 50) + 1;
|
|
342
|
+
let pageCount = 0;
|
|
343
|
+
while (allIssues.length < maxResults) {
|
|
344
|
+
if (++pageCount > maxPages)
|
|
345
|
+
break;
|
|
346
|
+
const remaining = maxResults - allIssues.length;
|
|
347
|
+
const result = await jiraClient.searchIssuesLean(jql, Math.min(50, remaining), nextPageToken);
|
|
348
|
+
for (const issue of result.issues) {
|
|
349
|
+
if (!seen.has(issue.key)) {
|
|
350
|
+
seen.add(issue.key);
|
|
351
|
+
allIssues.push(issue);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if (!result.pagination.hasMore || result.issues.length === 0)
|
|
355
|
+
break;
|
|
356
|
+
nextPageToken = result.pagination.nextPageToken;
|
|
357
|
+
if (allIssues.length >= maxResults) {
|
|
358
|
+
truncated = result.pagination.hasMore;
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
if (allIssues.length === 0 && !hasSummary) {
|
|
363
|
+
return {
|
|
364
|
+
content: [{
|
|
365
|
+
type: 'text',
|
|
366
|
+
text: `# Analysis\n\n**JQL:** \`${jql}\`\n\nNo issues matched this query.`,
|
|
367
|
+
}],
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
const now = new Date();
|
|
371
|
+
const lines = [];
|
|
372
|
+
// Summary first if requested alongside other metrics
|
|
373
|
+
if (hasSummary) {
|
|
374
|
+
const summaryText = await handleSummary(jiraClient, jql, groupBy);
|
|
375
|
+
lines.push(summaryText);
|
|
376
|
+
}
|
|
377
|
+
// Header for fetch-based metrics
|
|
378
|
+
if (fetchMetrics.length > 0 && allIssues.length > 0) {
|
|
379
|
+
if (hasSummary)
|
|
380
|
+
lines.push('');
|
|
381
|
+
lines.push(`# Detail: ${jql}`);
|
|
382
|
+
lines.push(`Analyzed ${allIssues.length} issues (as of ${formatDateShort(now)})`);
|
|
383
|
+
if (truncated) {
|
|
384
|
+
lines.push(`*Results capped at ${maxResults} issues — query may match more.*`);
|
|
385
|
+
}
|
|
386
|
+
// Render requested metrics
|
|
387
|
+
const renderers = {
|
|
388
|
+
points: () => renderPoints(allIssues),
|
|
389
|
+
time: () => renderTime(allIssues),
|
|
390
|
+
schedule: () => renderSchedule(allIssues, now),
|
|
391
|
+
cycle: () => renderCycle(allIssues, now),
|
|
392
|
+
distribution: () => renderDistribution(allIssues),
|
|
393
|
+
};
|
|
394
|
+
for (const metric of fetchMetrics) {
|
|
395
|
+
lines.push('');
|
|
396
|
+
lines.push(renderers[metric]());
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
// Next steps
|
|
400
|
+
const nextSteps = analysisNextSteps(jql, allIssues.slice(0, 3).map(i => i.key));
|
|
401
|
+
lines.push(nextSteps);
|
|
402
|
+
return {
|
|
403
|
+
content: [{
|
|
404
|
+
type: 'text',
|
|
405
|
+
text: lines.join('\n'),
|
|
406
|
+
}],
|
|
407
|
+
};
|
|
408
|
+
}
|
package/build/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
|
5
5
|
import { CallToolRequestSchema, ErrorCode, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ListToolsRequestSchema, McpError, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
6
6
|
import { fieldDiscovery } from './client/field-discovery.js';
|
|
7
7
|
import { JiraClient } from './client/jira-client.js';
|
|
8
|
+
import { handleAnalysisRequest } from './handlers/analysis-handler.js';
|
|
8
9
|
import { handleBoardRequest } from './handlers/board-handlers.js';
|
|
9
10
|
import { handleFilterRequest } from './handlers/filter-handlers.js';
|
|
10
11
|
import { handleIssueRequest } from './handlers/issue-handlers.js';
|
|
@@ -91,6 +92,7 @@ class JiraServer {
|
|
|
91
92
|
manage_jira_board: handleBoardRequest,
|
|
92
93
|
manage_jira_sprint: handleSprintRequest,
|
|
93
94
|
manage_jira_filter: handleFilterRequest,
|
|
95
|
+
analyze_jira_issues: handleAnalysisRequest,
|
|
94
96
|
};
|
|
95
97
|
const handlers = {
|
|
96
98
|
...toolHandlers,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export const toolSchemas = {
|
|
2
2
|
manage_jira_filter: {
|
|
3
3
|
name: 'manage_jira_filter',
|
|
4
|
-
description: 'Search for issues using JQL queries, or manage saved filters',
|
|
4
|
+
description: 'Search for issues using JQL queries, or manage saved filters. Returns issue details (title, status, description). For quantitative questions (counts, totals, overdue, workload), use analyze_jira_issues instead.',
|
|
5
5
|
inputSchema: {
|
|
6
6
|
type: 'object',
|
|
7
7
|
properties: {
|
|
@@ -242,7 +242,7 @@ export const toolSchemas = {
|
|
|
242
242
|
},
|
|
243
243
|
manage_jira_project: {
|
|
244
244
|
name: 'manage_jira_project',
|
|
245
|
-
description: 'List projects or get project
|
|
245
|
+
description: 'List projects or get project configuration and metadata. For issue counts, workload, or cross-project comparison, use analyze_jira_issues with metrics: ["summary"] instead.',
|
|
246
246
|
inputSchema: {
|
|
247
247
|
type: 'object',
|
|
248
248
|
properties: {
|
|
@@ -324,6 +324,38 @@ export const toolSchemas = {
|
|
|
324
324
|
required: ['operation'],
|
|
325
325
|
},
|
|
326
326
|
},
|
|
327
|
+
analyze_jira_issues: {
|
|
328
|
+
name: 'analyze_jira_issues',
|
|
329
|
+
description: 'Compute project metrics over issues selected by JQL. Use "summary" for exact counts across projects (no sampling cap). Use other metrics for detailed analysis of individual issue data. Always prefer this tool over manage_jira_filter or manage_jira_project for quantitative questions (counts, totals, overdue, workload).',
|
|
330
|
+
inputSchema: {
|
|
331
|
+
type: 'object',
|
|
332
|
+
properties: {
|
|
333
|
+
jql: {
|
|
334
|
+
type: 'string',
|
|
335
|
+
description: 'JQL query selecting the issues to analyze. Examples: "project in (AA, GC, LGS)", "sprint in openSprints()", "assignee = currentUser() AND resolution = Unresolved".',
|
|
336
|
+
},
|
|
337
|
+
metrics: {
|
|
338
|
+
type: 'array',
|
|
339
|
+
items: {
|
|
340
|
+
type: 'string',
|
|
341
|
+
enum: ['summary', 'points', 'time', 'schedule', 'cycle', 'distribution'],
|
|
342
|
+
},
|
|
343
|
+
description: 'Which metric groups to compute. summary = exact issue counts via count API (no cap, fastest). points = earned value/SPI. time = effort estimates. schedule = overdue/risk. cycle = lead time/throughput. distribution = counts by status/assignee/priority/type. Default: all except summary.',
|
|
344
|
+
},
|
|
345
|
+
groupBy: {
|
|
346
|
+
type: 'string',
|
|
347
|
+
enum: ['project', 'assignee', 'priority', 'issuetype'],
|
|
348
|
+
description: 'Split summary counts by this dimension. Use with metrics: ["summary"]. "project" produces a per-project comparison table.',
|
|
349
|
+
},
|
|
350
|
+
maxResults: {
|
|
351
|
+
type: 'integer',
|
|
352
|
+
description: 'Max issues to fetch for detail metrics (default 100, max 500). Does not apply to summary (which uses count API).',
|
|
353
|
+
default: 100,
|
|
354
|
+
},
|
|
355
|
+
},
|
|
356
|
+
required: ['jql'],
|
|
357
|
+
},
|
|
358
|
+
},
|
|
327
359
|
queue_jira_operations: {
|
|
328
360
|
name: 'queue_jira_operations',
|
|
329
361
|
description: 'Execute multiple Jira operations in a single call. Operations run sequentially with result references ($0.key) and per-operation error strategies (bail/continue).',
|
|
@@ -337,7 +369,7 @@ export const toolSchemas = {
|
|
|
337
369
|
properties: {
|
|
338
370
|
tool: {
|
|
339
371
|
type: 'string',
|
|
340
|
-
enum: ['manage_jira_issue', 'manage_jira_filter', 'manage_jira_sprint', 'manage_jira_project', 'manage_jira_board'],
|
|
372
|
+
enum: ['manage_jira_issue', 'manage_jira_filter', 'manage_jira_sprint', 'manage_jira_project', 'manage_jira_board', 'analyze_jira_issues'],
|
|
341
373
|
description: 'Which tool to call.',
|
|
342
374
|
},
|
|
343
375
|
args: {
|
|
@@ -125,3 +125,11 @@ export function boardNextSteps(operation, boardId) {
|
|
|
125
125
|
}
|
|
126
126
|
return steps.length > 0 ? formatSteps(steps) : '';
|
|
127
127
|
}
|
|
128
|
+
export function analysisNextSteps(jql, issueKeys) {
|
|
129
|
+
const steps = [];
|
|
130
|
+
if (issueKeys.length > 0) {
|
|
131
|
+
steps.push({ description: 'Get details on a specific issue', tool: 'manage_jira_issue', example: { operation: 'get', issueKey: issueKeys[0] } });
|
|
132
|
+
}
|
|
133
|
+
steps.push({ 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
|
+
return formatSteps(steps);
|
|
135
|
+
}
|
package/package.json
CHANGED