@aaronsb/jira-cloud-mcp 0.4.2 → 0.5.0
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/build/client/jira-client.js +45 -2
- package/build/docs/tool-documentation.js +54 -0
- package/build/handlers/analysis-handler.js +300 -0
- package/build/index.js +2 -0
- package/build/mcp/markdown-renderer.js +11 -5
- package/build/schemas/tool-schemas.js +28 -1
- package/build/utils/next-steps.js +8 -0
- package/package.json +1 -1
|
@@ -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,48 @@ 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 searchIssuesLean(jql, maxResults = 50, nextPageToken) {
|
|
412
|
+
try {
|
|
413
|
+
const cleanJql = jql.replace(/\\"/g, '"');
|
|
414
|
+
console.error(`Executing lean JQL search with query: ${cleanJql}${nextPageToken ? ' (page token)' : ''}`);
|
|
415
|
+
const leanFields = [
|
|
416
|
+
'summary', 'issuetype', 'priority', 'assignee', 'reporter',
|
|
417
|
+
'status', 'resolution', 'labels', 'created', 'updated',
|
|
418
|
+
'resolutiondate', 'duedate', 'timeestimate',
|
|
419
|
+
this.customFields.startDate, this.customFields.storyPoints,
|
|
420
|
+
];
|
|
421
|
+
const params = {
|
|
422
|
+
jql: cleanJql,
|
|
423
|
+
maxResults: Math.min(maxResults, 50),
|
|
424
|
+
fields: leanFields,
|
|
425
|
+
};
|
|
426
|
+
if (nextPageToken) {
|
|
427
|
+
params.nextPageToken = nextPageToken;
|
|
428
|
+
}
|
|
429
|
+
const timeoutMs = 30_000;
|
|
430
|
+
const searchResults = await Promise.race([
|
|
431
|
+
this.client.issueSearch.searchForIssuesUsingJqlEnhancedSearch(params),
|
|
432
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error(`Lean search timed out after ${timeoutMs / 1000}s — try a narrower JQL query or smaller maxResults`)), timeoutMs)),
|
|
433
|
+
]);
|
|
434
|
+
const issues = (searchResults.issues || []).map(issue => this.mapIssueFields(issue));
|
|
435
|
+
const hasMore = !!searchResults.nextPageToken;
|
|
436
|
+
return {
|
|
437
|
+
issues,
|
|
438
|
+
pagination: {
|
|
439
|
+
startAt: 0,
|
|
440
|
+
maxResults,
|
|
441
|
+
total: hasMore ? issues.length + 1 : issues.length,
|
|
442
|
+
hasMore,
|
|
443
|
+
nextPageToken: searchResults.nextPageToken || undefined,
|
|
444
|
+
}
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
catch (error) {
|
|
448
|
+
console.error('Error executing lean JQL search:', error);
|
|
449
|
+
throw error;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
409
452
|
async searchIssues(jql, startAt = 0, maxResults = 25) {
|
|
410
453
|
try {
|
|
411
454
|
// 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,59 @@ 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 include. Default: all.",
|
|
477
|
+
values: ["points", "time", "schedule", "cycle", "distribution"],
|
|
478
|
+
},
|
|
479
|
+
maxResults: {
|
|
480
|
+
type: "integer",
|
|
481
|
+
description: "Max issues to analyze (default 100, max 500).",
|
|
482
|
+
},
|
|
483
|
+
},
|
|
484
|
+
metric_groups: {
|
|
485
|
+
points: "Earned Value — PV, EV, remaining, SPI, status breakdown, unestimated count",
|
|
486
|
+
time: "Effort — original estimate, completed, remaining by status category",
|
|
487
|
+
schedule: "Risk — date window, overdue count/slip, due soon, concentration risk, missing dates",
|
|
488
|
+
cycle: "Flow — lead time median/mean, throughput, open issue age, oldest open",
|
|
489
|
+
distribution: "Composition — counts by status, assignee, priority, issue type",
|
|
490
|
+
},
|
|
491
|
+
common_use_cases: [
|
|
492
|
+
{
|
|
493
|
+
title: "Sprint health check",
|
|
494
|
+
description: "Analyze all issues in the current sprint:",
|
|
495
|
+
steps: [
|
|
496
|
+
{ description: "Full analysis", code: { jql: "sprint in openSprints()" } },
|
|
497
|
+
],
|
|
498
|
+
},
|
|
499
|
+
{
|
|
500
|
+
title: "Schedule risk for a release",
|
|
501
|
+
description: "Check overdue and upcoming deadlines:",
|
|
502
|
+
steps: [
|
|
503
|
+
{ description: "Schedule only", code: { jql: "project = AA AND fixVersion = 2.0", metrics: ["schedule"] } },
|
|
504
|
+
],
|
|
505
|
+
},
|
|
506
|
+
{
|
|
507
|
+
title: "Workload balance",
|
|
508
|
+
description: "See how work is distributed across team members:",
|
|
509
|
+
steps: [
|
|
510
|
+
{ description: "Distribution only", code: { jql: "project = AA AND resolution = Unresolved", metrics: ["distribution"] } },
|
|
511
|
+
],
|
|
512
|
+
},
|
|
513
|
+
],
|
|
514
|
+
related_resources: [],
|
|
515
|
+
};
|
|
516
|
+
}
|
|
463
517
|
function generateGenericToolDocumentation(toolName, schema) {
|
|
464
518
|
const operations = {};
|
|
465
519
|
if (schema.inputSchema?.properties?.operation?.enum) {
|
|
@@ -0,0 +1,300 @@
|
|
|
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 MAX_ISSUES = 500;
|
|
6
|
+
// ── Helpers ────────────────────────────────────────────────────────────
|
|
7
|
+
function bucketStatus(category) {
|
|
8
|
+
switch (category) {
|
|
9
|
+
case 'new': return 'To Do';
|
|
10
|
+
case 'indeterminate': return 'In Progress';
|
|
11
|
+
case 'done': return 'Done';
|
|
12
|
+
default: return 'To Do';
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/** Parse a date string to a Date without timezone shift for date-only values */
|
|
16
|
+
function parseDate(dateStr) {
|
|
17
|
+
const dateOnly = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
|
18
|
+
if (dateOnly) {
|
|
19
|
+
const [, y, m, d] = dateOnly;
|
|
20
|
+
return new Date(Number(y), Number(m) - 1, Number(d));
|
|
21
|
+
}
|
|
22
|
+
return new Date(dateStr);
|
|
23
|
+
}
|
|
24
|
+
function formatDateShort(date) {
|
|
25
|
+
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
|
26
|
+
}
|
|
27
|
+
function daysBetween(a, b) {
|
|
28
|
+
return Math.round((b.getTime() - a.getTime()) / (1000 * 60 * 60 * 24));
|
|
29
|
+
}
|
|
30
|
+
function formatDuration(seconds) {
|
|
31
|
+
const hours = Math.floor(seconds / 3600);
|
|
32
|
+
const days = Math.floor(hours / 8); // 8-hour work day
|
|
33
|
+
if (days > 0) {
|
|
34
|
+
const remainingHours = hours % 8;
|
|
35
|
+
return remainingHours > 0 ? `${days}d ${remainingHours}h` : `${days}d`;
|
|
36
|
+
}
|
|
37
|
+
const minutes = Math.floor((seconds % 3600) / 60);
|
|
38
|
+
if (hours > 0)
|
|
39
|
+
return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
|
|
40
|
+
return `${minutes}m`;
|
|
41
|
+
}
|
|
42
|
+
function countBy(items, keyFn) {
|
|
43
|
+
const counts = new Map();
|
|
44
|
+
for (const item of items) {
|
|
45
|
+
const key = keyFn(item);
|
|
46
|
+
counts.set(key, (counts.get(key) || 0) + 1);
|
|
47
|
+
}
|
|
48
|
+
return counts;
|
|
49
|
+
}
|
|
50
|
+
function sumBy(items, valueFn) {
|
|
51
|
+
return items.reduce((sum, item) => sum + (valueFn(item) || 0), 0);
|
|
52
|
+
}
|
|
53
|
+
function mapToString(map, separator = ' | ') {
|
|
54
|
+
return [...map.entries()]
|
|
55
|
+
.sort((a, b) => b[1] - a[1])
|
|
56
|
+
.map(([k, v]) => `${k}: ${v}`)
|
|
57
|
+
.join(separator);
|
|
58
|
+
}
|
|
59
|
+
function median(values) {
|
|
60
|
+
if (values.length === 0)
|
|
61
|
+
return 0;
|
|
62
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
63
|
+
const mid = Math.floor(sorted.length / 2);
|
|
64
|
+
return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
65
|
+
}
|
|
66
|
+
function mean(values) {
|
|
67
|
+
if (values.length === 0)
|
|
68
|
+
return 0;
|
|
69
|
+
return values.reduce((s, v) => s + v, 0) / values.length;
|
|
70
|
+
}
|
|
71
|
+
// ── Metric Renderers (exported for testing) ────────────────────────────
|
|
72
|
+
export function renderPoints(issues) {
|
|
73
|
+
const estimated = issues.filter(i => i.storyPoints != null);
|
|
74
|
+
const unestimated = issues.length - estimated.length;
|
|
75
|
+
const byBucket = new Map();
|
|
76
|
+
for (const issue of issues) {
|
|
77
|
+
const bucket = bucketStatus(issue.statusCategory);
|
|
78
|
+
byBucket.set(bucket, (byBucket.get(bucket) ?? 0) + (issue.storyPoints ?? 0));
|
|
79
|
+
}
|
|
80
|
+
const pv = sumBy(issues, i => i.storyPoints);
|
|
81
|
+
const ev = byBucket.get('Done') ?? 0;
|
|
82
|
+
const remaining = pv - ev;
|
|
83
|
+
const spi = pv > 0 ? (ev / pv) : null;
|
|
84
|
+
const lines = ['## Points (Earned Value)', ''];
|
|
85
|
+
lines.push('| Metric | Value |');
|
|
86
|
+
lines.push('|--------|-------|');
|
|
87
|
+
lines.push(`| Planned Value (PV) | ${pv} pts |`);
|
|
88
|
+
lines.push(`| Earned Value (EV) | ${ev} pts |`);
|
|
89
|
+
lines.push(`| Remaining | ${remaining} pts |`);
|
|
90
|
+
lines.push(`| SPI | ${spi !== null ? spi.toFixed(2) : 'N/A (no estimates)'} |`);
|
|
91
|
+
if (unestimated > 0) {
|
|
92
|
+
lines.push(`| Unestimated | ${unestimated} issue${unestimated !== 1 ? 's' : ''} |`);
|
|
93
|
+
}
|
|
94
|
+
lines.push('');
|
|
95
|
+
const bucketStr = ['To Do', 'In Progress', 'Done']
|
|
96
|
+
.map(b => `${b}: ${byBucket.get(b) ?? 0} pts`)
|
|
97
|
+
.join(' | ');
|
|
98
|
+
lines.push(`**By status:** ${bucketStr}`);
|
|
99
|
+
return lines.join('\n');
|
|
100
|
+
}
|
|
101
|
+
export function renderTime(issues) {
|
|
102
|
+
const estimated = issues.filter(i => i.timeEstimate != null);
|
|
103
|
+
const unestimated = issues.length - estimated.length;
|
|
104
|
+
const total = sumBy(issues, i => i.timeEstimate);
|
|
105
|
+
const byBucket = new Map();
|
|
106
|
+
for (const issue of issues) {
|
|
107
|
+
const bucket = bucketStatus(issue.statusCategory);
|
|
108
|
+
byBucket.set(bucket, (byBucket.get(bucket) ?? 0) + (issue.timeEstimate ?? 0));
|
|
109
|
+
}
|
|
110
|
+
const done = byBucket.get('Done') ?? 0;
|
|
111
|
+
const remaining = total - done;
|
|
112
|
+
const lines = ['## Time (Effort)', ''];
|
|
113
|
+
lines.push('| Metric | Value |');
|
|
114
|
+
lines.push('|--------|-------|');
|
|
115
|
+
lines.push(`| Original Estimate | ${formatDuration(total)} |`);
|
|
116
|
+
lines.push(`| Completed | ${formatDuration(done)} |`);
|
|
117
|
+
lines.push(`| Remaining | ${formatDuration(remaining)} |`);
|
|
118
|
+
if (unestimated > 0) {
|
|
119
|
+
lines.push(`| Unestimated | ${unestimated} issue${unestimated !== 1 ? 's' : ''} |`);
|
|
120
|
+
}
|
|
121
|
+
return lines.join('\n');
|
|
122
|
+
}
|
|
123
|
+
export function renderSchedule(issues, now) {
|
|
124
|
+
const lines = ['## Schedule', ''];
|
|
125
|
+
// Date range
|
|
126
|
+
const startDates = issues.filter(i => i.startDate).map(i => parseDate(i.startDate));
|
|
127
|
+
const dueDates = issues.filter(i => i.dueDate).map(i => parseDate(i.dueDate));
|
|
128
|
+
const allDates = [...startDates, ...dueDates];
|
|
129
|
+
if (allDates.length > 0) {
|
|
130
|
+
const earliest = new Date(Math.min(...allDates.map(d => d.getTime())));
|
|
131
|
+
const latest = new Date(Math.max(...allDates.map(d => d.getTime())));
|
|
132
|
+
lines.push(`**Window:** ${formatDateShort(earliest)} - ${formatDateShort(latest)}`);
|
|
133
|
+
}
|
|
134
|
+
// Overdue
|
|
135
|
+
const overdue = issues.filter(i => i.dueDate && !i.resolutionDate && parseDate(i.dueDate) < now);
|
|
136
|
+
if (overdue.length > 0) {
|
|
137
|
+
const totalSlip = overdue.reduce((sum, i) => sum + daysBetween(parseDate(i.dueDate), now), 0);
|
|
138
|
+
const keys = overdue.slice(0, 5).map(i => i.key).join(', ');
|
|
139
|
+
const more = overdue.length > 5 ? ` +${overdue.length - 5} more` : '';
|
|
140
|
+
lines.push(`**Overdue:** ${overdue.length} issue${overdue.length !== 1 ? 's' : ''}, ${totalSlip} days total slip (${keys}${more})`);
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
lines.push('**Overdue:** none');
|
|
144
|
+
}
|
|
145
|
+
// Due soon
|
|
146
|
+
for (const window of [7, 14, 30]) {
|
|
147
|
+
const cutoff = new Date(now.getTime() + window * 24 * 60 * 60 * 1000);
|
|
148
|
+
const dueSoon = issues.filter(i => i.dueDate && !i.resolutionDate &&
|
|
149
|
+
parseDate(i.dueDate) >= now && parseDate(i.dueDate) <= cutoff);
|
|
150
|
+
if (dueSoon.length > 0) {
|
|
151
|
+
lines.push(`**Due next ${window} days:** ${dueSoon.length} issue${dueSoon.length !== 1 ? 's' : ''}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// Concentration risk
|
|
155
|
+
const dueDateCounts = countBy(issues.filter(i => i.dueDate && !i.resolutionDate), i => i.dueDate);
|
|
156
|
+
const concentrated = [...dueDateCounts.entries()]
|
|
157
|
+
.filter(([, count]) => count >= 3)
|
|
158
|
+
.sort((a, b) => b[1] - a[1]);
|
|
159
|
+
if (concentrated.length > 0) {
|
|
160
|
+
const top = concentrated.slice(0, 3)
|
|
161
|
+
.map(([date, count]) => `${formatDateShort(parseDate(date))} has ${count} issues`)
|
|
162
|
+
.join('; ');
|
|
163
|
+
lines.push(`**Concentration:** ${top}`);
|
|
164
|
+
}
|
|
165
|
+
// No due date
|
|
166
|
+
const noDueDate = issues.filter(i => !i.dueDate && !i.resolutionDate);
|
|
167
|
+
if (noDueDate.length > 0) {
|
|
168
|
+
lines.push(`**No due date:** ${noDueDate.length} issue${noDueDate.length !== 1 ? 's' : ''}`);
|
|
169
|
+
}
|
|
170
|
+
return lines.join('\n');
|
|
171
|
+
}
|
|
172
|
+
export function renderCycle(issues, now) {
|
|
173
|
+
const lines = ['## Cycle (Flow Metrics)', ''];
|
|
174
|
+
// Lead time for resolved issues
|
|
175
|
+
const resolved = issues.filter(i => i.resolutionDate && i.created);
|
|
176
|
+
if (resolved.length > 0) {
|
|
177
|
+
const leadTimes = resolved.map(i => daysBetween(parseDate(i.created), parseDate(i.resolutionDate)));
|
|
178
|
+
const med = median(leadTimes);
|
|
179
|
+
const avg = mean(leadTimes);
|
|
180
|
+
lines.push(`**Lead time (resolved):** median ${med.toFixed(1)} days, mean ${avg.toFixed(1)} days (${resolved.length} issues)`);
|
|
181
|
+
// Throughput
|
|
182
|
+
const createdDates = resolved.map(i => parseDate(i.resolutionDate).getTime());
|
|
183
|
+
const earliest = Math.min(...createdDates);
|
|
184
|
+
const latest = Math.max(...createdDates);
|
|
185
|
+
const weeks = Math.max(1, (latest - earliest) / (7 * 24 * 60 * 60 * 1000));
|
|
186
|
+
const throughput = resolved.length / weeks;
|
|
187
|
+
lines.push(`**Throughput:** ${throughput.toFixed(1)} issues/week`);
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
lines.push('**Lead time:** no resolved issues in set');
|
|
191
|
+
}
|
|
192
|
+
// Age of open issues
|
|
193
|
+
const open = issues.filter(i => !i.resolutionDate && i.created);
|
|
194
|
+
if (open.length > 0) {
|
|
195
|
+
const ages = open.map(i => daysBetween(parseDate(i.created), now));
|
|
196
|
+
const avgAge = mean(ages);
|
|
197
|
+
lines.push(`**Open issue age:** mean ${avgAge.toFixed(1)} days (${open.length} issues)`);
|
|
198
|
+
// Oldest open
|
|
199
|
+
const oldest = open
|
|
200
|
+
.map(i => ({ key: i.key, age: daysBetween(parseDate(i.created), now) }))
|
|
201
|
+
.sort((a, b) => b.age - a.age)
|
|
202
|
+
.slice(0, 5);
|
|
203
|
+
const oldestStr = oldest.map(o => `${o.key} (${o.age}d)`).join(', ');
|
|
204
|
+
lines.push(`**Oldest open:** ${oldestStr}`);
|
|
205
|
+
}
|
|
206
|
+
return lines.join('\n');
|
|
207
|
+
}
|
|
208
|
+
export function renderDistribution(issues) {
|
|
209
|
+
const lines = ['## Distribution', ''];
|
|
210
|
+
const byStatus = countBy(issues, i => i.status);
|
|
211
|
+
lines.push(`**By status:** ${mapToString(byStatus)}`);
|
|
212
|
+
const byAssignee = countBy(issues, i => i.assignee || 'Unassigned');
|
|
213
|
+
lines.push(`**By assignee:** ${mapToString(byAssignee)}`);
|
|
214
|
+
const byPriority = countBy(issues, i => i.priority || 'None');
|
|
215
|
+
lines.push(`**By priority:** ${mapToString(byPriority)}`);
|
|
216
|
+
const byType = countBy(issues, i => i.issueType || 'Unknown');
|
|
217
|
+
lines.push(`**By type:** ${mapToString(byType)}`);
|
|
218
|
+
return lines.join('\n');
|
|
219
|
+
}
|
|
220
|
+
// ── Main Handler ───────────────────────────────────────────────────────
|
|
221
|
+
export async function handleAnalysisRequest(jiraClient, request) {
|
|
222
|
+
const args = normalizeArgs(request.params?.arguments || {});
|
|
223
|
+
const jql = args.jql;
|
|
224
|
+
if (!jql || typeof jql !== 'string' || jql.trim() === '') {
|
|
225
|
+
throw new McpError(ErrorCode.InvalidParams, 'jql parameter is required.');
|
|
226
|
+
}
|
|
227
|
+
const DEFAULT_MAX = 100;
|
|
228
|
+
const maxResults = Math.min(Number(args.maxResults) || DEFAULT_MAX, MAX_ISSUES);
|
|
229
|
+
// Parse requested metrics
|
|
230
|
+
let metrics = ALL_METRICS;
|
|
231
|
+
if (args.metrics && Array.isArray(args.metrics)) {
|
|
232
|
+
const requested = args.metrics;
|
|
233
|
+
const valid = requested.filter(m => ALL_METRICS.includes(m));
|
|
234
|
+
if (valid.length > 0)
|
|
235
|
+
metrics = valid;
|
|
236
|
+
}
|
|
237
|
+
// Fetch issues using cursor-based pagination (50 per page, Jira enhanced search API)
|
|
238
|
+
const allIssues = [];
|
|
239
|
+
const seen = new Set();
|
|
240
|
+
let nextPageToken;
|
|
241
|
+
let truncated = false;
|
|
242
|
+
const maxPages = Math.ceil(maxResults / 50) + 1;
|
|
243
|
+
let pageCount = 0;
|
|
244
|
+
while (allIssues.length < maxResults) {
|
|
245
|
+
if (++pageCount > maxPages)
|
|
246
|
+
break;
|
|
247
|
+
const remaining = maxResults - allIssues.length;
|
|
248
|
+
const result = await jiraClient.searchIssuesLean(jql, Math.min(50, remaining), nextPageToken);
|
|
249
|
+
for (const issue of result.issues) {
|
|
250
|
+
if (!seen.has(issue.key)) {
|
|
251
|
+
seen.add(issue.key);
|
|
252
|
+
allIssues.push(issue);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (!result.pagination.hasMore || result.issues.length === 0)
|
|
256
|
+
break;
|
|
257
|
+
nextPageToken = result.pagination.nextPageToken;
|
|
258
|
+
if (allIssues.length >= maxResults) {
|
|
259
|
+
truncated = result.pagination.hasMore;
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (allIssues.length === 0) {
|
|
264
|
+
return {
|
|
265
|
+
content: [{
|
|
266
|
+
type: 'text',
|
|
267
|
+
text: `# Analysis\n\n**JQL:** \`${jql}\`\n\nNo issues matched this query.`,
|
|
268
|
+
}],
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
const now = new Date();
|
|
272
|
+
const lines = [];
|
|
273
|
+
// Header
|
|
274
|
+
lines.push(`# Analysis: ${jql}`);
|
|
275
|
+
lines.push(`Analyzed ${allIssues.length} issues (as of ${formatDateShort(now)})`);
|
|
276
|
+
if (truncated) {
|
|
277
|
+
lines.push(`*Results capped at ${maxResults} issues — query may match more.*`);
|
|
278
|
+
}
|
|
279
|
+
// Render requested metrics
|
|
280
|
+
const renderers = {
|
|
281
|
+
points: () => renderPoints(allIssues),
|
|
282
|
+
time: () => renderTime(allIssues),
|
|
283
|
+
schedule: () => renderSchedule(allIssues, now),
|
|
284
|
+
cycle: () => renderCycle(allIssues, now),
|
|
285
|
+
distribution: () => renderDistribution(allIssues),
|
|
286
|
+
};
|
|
287
|
+
for (const metric of metrics) {
|
|
288
|
+
lines.push('');
|
|
289
|
+
lines.push(renderers[metric]());
|
|
290
|
+
}
|
|
291
|
+
// Next steps
|
|
292
|
+
const nextSteps = analysisNextSteps(jql, allIssues.slice(0, 3).map(i => i.key));
|
|
293
|
+
lines.push(nextSteps);
|
|
294
|
+
return {
|
|
295
|
+
content: [{
|
|
296
|
+
type: 'text',
|
|
297
|
+
text: lines.join('\n'),
|
|
298
|
+
}],
|
|
299
|
+
};
|
|
300
|
+
}
|
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,
|
|
@@ -19,12 +19,18 @@
|
|
|
19
19
|
function formatDate(dateStr) {
|
|
20
20
|
if (!dateStr)
|
|
21
21
|
return 'Not set';
|
|
22
|
+
// Date-only strings (YYYY-MM-DD) are parsed as UTC midnight by Date constructor,
|
|
23
|
+
// then toLocaleDateString shifts them by local TZ offset — causing off-by-one.
|
|
24
|
+
// Parse date-only values directly to avoid timezone shifting.
|
|
25
|
+
const dateOnly = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
|
26
|
+
if (dateOnly) {
|
|
27
|
+
const [, y, m, d] = dateOnly;
|
|
28
|
+
const date = new Date(Number(y), Number(m) - 1, Number(d));
|
|
29
|
+
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
|
30
|
+
}
|
|
31
|
+
// Full datetime strings include timezone info, so they render correctly.
|
|
22
32
|
const date = new Date(dateStr);
|
|
23
|
-
return date.toLocaleDateString('en-US', {
|
|
24
|
-
year: 'numeric',
|
|
25
|
-
month: 'short',
|
|
26
|
-
day: 'numeric'
|
|
27
|
-
});
|
|
33
|
+
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
|
28
34
|
}
|
|
29
35
|
/**
|
|
30
36
|
* Format status with visual indicator
|
|
@@ -324,6 +324,33 @@ 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 a set of issues selected by JQL. Returns deterministic calculations: earned value (story points), effort tracking, schedule risk, flow/cycle metrics, and distribution breakdowns. Use this when asked about totals, progress, overdue items, workload balance, or any quantitative question about issues.',
|
|
330
|
+
inputSchema: {
|
|
331
|
+
type: 'object',
|
|
332
|
+
properties: {
|
|
333
|
+
jql: {
|
|
334
|
+
type: 'string',
|
|
335
|
+
description: 'JQL query selecting the issues to analyze. Examples: "sprint in openSprints()", "project = AA AND fixVersion = 2.0", "assignee = currentUser() AND resolution = Unresolved".',
|
|
336
|
+
},
|
|
337
|
+
metrics: {
|
|
338
|
+
type: 'array',
|
|
339
|
+
items: {
|
|
340
|
+
type: 'string',
|
|
341
|
+
enum: ['points', 'time', 'schedule', 'cycle', 'distribution'],
|
|
342
|
+
},
|
|
343
|
+
description: 'Which metric groups to include. Default: all. points = earned value/SPI, time = effort estimates, schedule = due dates/overdue/risk, cycle = lead time/throughput/age, distribution = counts by status/assignee/priority/type.',
|
|
344
|
+
},
|
|
345
|
+
maxResults: {
|
|
346
|
+
type: 'integer',
|
|
347
|
+
description: 'Max issues to analyze (default 100, max 500).',
|
|
348
|
+
default: 100,
|
|
349
|
+
},
|
|
350
|
+
},
|
|
351
|
+
required: ['jql'],
|
|
352
|
+
},
|
|
353
|
+
},
|
|
327
354
|
queue_jira_operations: {
|
|
328
355
|
name: 'queue_jira_operations',
|
|
329
356
|
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 +364,7 @@ export const toolSchemas = {
|
|
|
337
364
|
properties: {
|
|
338
365
|
tool: {
|
|
339
366
|
type: 'string',
|
|
340
|
-
enum: ['manage_jira_issue', 'manage_jira_filter', 'manage_jira_sprint', 'manage_jira_project', 'manage_jira_board'],
|
|
367
|
+
enum: ['manage_jira_issue', 'manage_jira_filter', 'manage_jira_sprint', 'manage_jira_project', 'manage_jira_board', 'analyze_jira_issues'],
|
|
341
368
|
description: 'Which tool to call.',
|
|
342
369
|
},
|
|
343
370
|
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