@aaronsb/jira-cloud-mcp 0.5.10 → 0.5.11
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.
|
@@ -104,6 +104,7 @@ export class JiraClient {
|
|
|
104
104
|
people.push({ displayName: fields.reporter.displayName, accountId: fields.reporter.accountId, role: 'reporter' });
|
|
105
105
|
}
|
|
106
106
|
return {
|
|
107
|
+
id: issue.id,
|
|
107
108
|
key: issue.key,
|
|
108
109
|
summary: fields?.summary,
|
|
109
110
|
description: fields?.description
|
|
@@ -410,6 +411,38 @@ export class JiraClient {
|
|
|
410
411
|
url: attachment.content || '',
|
|
411
412
|
}));
|
|
412
413
|
}
|
|
414
|
+
async getBulkChangelogs(issueKeys, fieldIds = ['status']) {
|
|
415
|
+
const result = new Map();
|
|
416
|
+
let nextPageToken;
|
|
417
|
+
do {
|
|
418
|
+
const response = await this.client.issues.getBulkChangelogs({
|
|
419
|
+
issueIdsOrKeys: issueKeys,
|
|
420
|
+
fieldIds,
|
|
421
|
+
maxResults: 1000,
|
|
422
|
+
nextPageToken,
|
|
423
|
+
});
|
|
424
|
+
for (const issueLog of response.issueChangeLogs || []) {
|
|
425
|
+
const issueId = issueLog.issueId;
|
|
426
|
+
if (!issueId)
|
|
427
|
+
continue;
|
|
428
|
+
const transitions = result.get(issueId) || [];
|
|
429
|
+
for (const history of issueLog.changeHistories || []) {
|
|
430
|
+
for (const item of history.items || []) {
|
|
431
|
+
if (item.field === 'status') {
|
|
432
|
+
transitions.push({
|
|
433
|
+
date: history.created || '',
|
|
434
|
+
from: item.fromString || '',
|
|
435
|
+
to: item.toString || '',
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
result.set(issueId, transitions);
|
|
441
|
+
}
|
|
442
|
+
nextPageToken = response.nextPageToken ?? undefined;
|
|
443
|
+
} while (nextPageToken);
|
|
444
|
+
return result;
|
|
445
|
+
}
|
|
413
446
|
async getFilter(filterId) {
|
|
414
447
|
return await this.client.filters.getFilter({
|
|
415
448
|
id: parseInt(filterId, 10),
|
|
@@ -528,6 +528,14 @@ function generateAnalysisToolDocumentation(schema) {
|
|
|
528
528
|
{ description: "Summary only", code: { jql: "project = AA", metrics: ["summary"] } },
|
|
529
529
|
],
|
|
530
530
|
},
|
|
531
|
+
{
|
|
532
|
+
title: "Flow analysis — where do issues get stuck?",
|
|
533
|
+
description: "Analyze status transitions, time in status, and bounce patterns:",
|
|
534
|
+
steps: [
|
|
535
|
+
{ description: "Flow metrics", code: { jql: "project = AA AND resolution = Unresolved", metrics: ["flow"] } },
|
|
536
|
+
{ description: "Combined with summary", code: { jql: "project = AA", metrics: ["summary", "flow"], groupBy: "issuetype" } },
|
|
537
|
+
],
|
|
538
|
+
},
|
|
531
539
|
{
|
|
532
540
|
title: "Data cube — discover then compute",
|
|
533
541
|
description: "Two-phase analysis with computed columns:",
|
|
@@ -3,6 +3,7 @@ import { evaluateRow, extractColumnRefs, parseComputeList } from '../utils/cube-
|
|
|
3
3
|
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
|
+
// flow is opt-in only — requires extra bulk changelog API call
|
|
6
7
|
const VALID_GROUP_BY = ['project', 'assignee', 'priority', 'issuetype'];
|
|
7
8
|
const MAX_ISSUES_HARD = 500; // absolute ceiling for detail metrics — beyond this, context explodes
|
|
8
9
|
const MAX_ISSUES_DEFAULT = 100;
|
|
@@ -271,6 +272,100 @@ export function renderDistribution(issues, groupLimit = DEFAULT_GROUP_LIMIT) {
|
|
|
271
272
|
lines.push(`**By type:** ${mapToString(byType, ' | ', groupLimit)}`);
|
|
272
273
|
return lines.join('\n');
|
|
273
274
|
}
|
|
275
|
+
export async function renderFlow(jiraClient, issues) {
|
|
276
|
+
if (issues.length === 0)
|
|
277
|
+
return '## Flow\n\nNo issues to analyze.';
|
|
278
|
+
const issueKeys = issues.map(i => i.key);
|
|
279
|
+
// Build a map of issue key → issue ID for matching bulk response
|
|
280
|
+
const keyToId = new Map();
|
|
281
|
+
const idToKey = new Map();
|
|
282
|
+
for (const issue of issues) {
|
|
283
|
+
if (issue.id) {
|
|
284
|
+
keyToId.set(issue.key, issue.id);
|
|
285
|
+
idToKey.set(issue.id, issue.key);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
const changelogs = await jiraClient.getBulkChangelogs(issueKeys);
|
|
289
|
+
// Aggregate per-status stats
|
|
290
|
+
const statusStats = new Map();
|
|
291
|
+
const issueBounceCounts = new Map(); // issueId → status → entry count
|
|
292
|
+
let totalTransitions = 0;
|
|
293
|
+
for (const [issueId, transitions] of changelogs) {
|
|
294
|
+
if (transitions.length === 0)
|
|
295
|
+
continue;
|
|
296
|
+
totalTransitions += transitions.length;
|
|
297
|
+
// Sort transitions by date
|
|
298
|
+
const sorted = [...transitions].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
|
299
|
+
// Track entries per status for this issue
|
|
300
|
+
const issueStatusEntries = new Map();
|
|
301
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
302
|
+
const t = sorted[i];
|
|
303
|
+
const toStatus = t.to;
|
|
304
|
+
// Count entries into the target status
|
|
305
|
+
issueStatusEntries.set(toStatus, (issueStatusEntries.get(toStatus) || 0) + 1);
|
|
306
|
+
// Calculate time spent in the target status
|
|
307
|
+
const enteredAt = new Date(t.date).getTime();
|
|
308
|
+
const exitedAt = i + 1 < sorted.length
|
|
309
|
+
? new Date(sorted[i + 1].date).getTime()
|
|
310
|
+
: Date.now();
|
|
311
|
+
const daysIn = (exitedAt - enteredAt) / (1000 * 60 * 60 * 24);
|
|
312
|
+
const stats = statusStats.get(toStatus) || { status: toStatus, entries: 0, totalDaysIn: 0, issueCount: 0, bounces: 0 };
|
|
313
|
+
stats.entries++;
|
|
314
|
+
stats.totalDaysIn += daysIn;
|
|
315
|
+
statusStats.set(toStatus, stats);
|
|
316
|
+
}
|
|
317
|
+
issueBounceCounts.set(issueId, issueStatusEntries);
|
|
318
|
+
}
|
|
319
|
+
// Count distinct issues and bounces per status
|
|
320
|
+
const issuesPerStatus = new Map();
|
|
321
|
+
for (const [issueId, statusEntries] of issueBounceCounts) {
|
|
322
|
+
for (const [status, count] of statusEntries) {
|
|
323
|
+
if (!issuesPerStatus.has(status))
|
|
324
|
+
issuesPerStatus.set(status, new Set());
|
|
325
|
+
issuesPerStatus.get(status).add(issueId);
|
|
326
|
+
const stats = statusStats.get(status);
|
|
327
|
+
if (stats && count > 1) {
|
|
328
|
+
stats.bounces += count - 1;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
for (const [status, issueSet] of issuesPerStatus) {
|
|
333
|
+
const stats = statusStats.get(status);
|
|
334
|
+
if (stats)
|
|
335
|
+
stats.issueCount = issueSet.size;
|
|
336
|
+
}
|
|
337
|
+
if (statusStats.size === 0) {
|
|
338
|
+
return '## Flow\n\nNo status transitions found in these issues.';
|
|
339
|
+
}
|
|
340
|
+
// Render table
|
|
341
|
+
const lines = ['## Flow (Status Transitions)', ''];
|
|
342
|
+
lines.push(`${totalTransitions} transitions across ${changelogs.size} issues`);
|
|
343
|
+
lines.push('');
|
|
344
|
+
lines.push('| Status | Entries | Avg days in | Bounce rate | Issues |');
|
|
345
|
+
lines.push('|--------|--------:|------------:|------------:|-------:|');
|
|
346
|
+
const sorted = [...statusStats.values()].sort((a, b) => b.entries - a.entries);
|
|
347
|
+
for (const s of sorted) {
|
|
348
|
+
const avgDays = s.entries > 0 ? (s.totalDaysIn / s.entries).toFixed(1) : '—';
|
|
349
|
+
const bounceRate = s.issueCount > 0 ? Math.round((s.bounces / s.issueCount) * 100) : 0;
|
|
350
|
+
lines.push(`| ${s.status} | ${s.entries} | ${avgDays} | ${bounceRate}% | ${s.issueCount} |`);
|
|
351
|
+
}
|
|
352
|
+
// Top bouncers — issues with most re-entries to any status
|
|
353
|
+
const bouncerScores = [];
|
|
354
|
+
for (const [issueId, statusEntries] of issueBounceCounts) {
|
|
355
|
+
const totalBounces = [...statusEntries.values()].reduce((sum, count) => sum + Math.max(0, count - 1), 0);
|
|
356
|
+
if (totalBounces > 0) {
|
|
357
|
+
const key = idToKey.get(issueId) || issueId;
|
|
358
|
+
bouncerScores.push({ key, bounces: totalBounces });
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
if (bouncerScores.length > 0) {
|
|
362
|
+
bouncerScores.sort((a, b) => b.bounces - a.bounces);
|
|
363
|
+
const top = bouncerScores.slice(0, 5);
|
|
364
|
+
lines.push('');
|
|
365
|
+
lines.push(`**Top bouncers:** ${top.map(b => `${b.key} (${b.bounces}×)`).join(', ')}`);
|
|
366
|
+
}
|
|
367
|
+
return lines.join('\n');
|
|
368
|
+
}
|
|
274
369
|
/** Extract project keys from JQL like "project in (AA, GC, GD)" or "project = AA" */
|
|
275
370
|
export function extractProjectKeys(jql) {
|
|
276
371
|
// project in (AA, GC, GD)
|
|
@@ -622,9 +717,12 @@ export async function handleAnalysisRequest(jiraClient, request) {
|
|
|
622
717
|
: [];
|
|
623
718
|
const hasSummary = requested.includes('summary');
|
|
624
719
|
const hasCubeSetup = requested.includes('cube_setup');
|
|
720
|
+
const hasFlow = requested.includes('flow');
|
|
625
721
|
const fetchMetrics = requested.length > 0
|
|
626
722
|
? requested.filter(m => ALL_METRICS.includes(m))
|
|
627
723
|
: ALL_METRICS;
|
|
724
|
+
// flow needs issue fetching but isn't in ALL_METRICS (opt-in only)
|
|
725
|
+
const needsIssueFetch = fetchMetrics.length > 0 || hasFlow;
|
|
628
726
|
// Parse groupBy
|
|
629
727
|
const groupBy = (typeof args.groupBy === 'string' && VALID_GROUP_BY.includes(args.groupBy))
|
|
630
728
|
? args.groupBy
|
|
@@ -649,8 +747,8 @@ export async function handleAnalysisRequest(jiraClient, request) {
|
|
|
649
747
|
}],
|
|
650
748
|
};
|
|
651
749
|
}
|
|
652
|
-
// If only summary requested, skip issue fetching entirely
|
|
653
|
-
if (hasSummary &&
|
|
750
|
+
// If only summary requested (no flow or detail metrics), skip issue fetching entirely
|
|
751
|
+
if (hasSummary && !needsIssueFetch) {
|
|
654
752
|
const summaryText = await handleSummary(jiraClient, jql, groupBy, compute, groupLimit);
|
|
655
753
|
const nextSteps = analysisNextSteps(jql, [], false, groupBy, filterSource);
|
|
656
754
|
const banner = filterSource ? `*Using saved filter: ${filterSource}*\n\n` : '';
|
|
@@ -708,7 +806,7 @@ export async function handleAnalysisRequest(jiraClient, request) {
|
|
|
708
806
|
lines.push(summaryText);
|
|
709
807
|
}
|
|
710
808
|
// Header for fetch-based metrics
|
|
711
|
-
if (fetchMetrics.length > 0 && allIssues.length > 0) {
|
|
809
|
+
if ((fetchMetrics.length > 0 || hasFlow) && allIssues.length > 0) {
|
|
712
810
|
if (hasSummary)
|
|
713
811
|
lines.push('');
|
|
714
812
|
lines.push(`# Detail: ${jql}`);
|
|
@@ -729,6 +827,11 @@ export async function handleAnalysisRequest(jiraClient, request) {
|
|
|
729
827
|
lines.push(renderers[metric]());
|
|
730
828
|
}
|
|
731
829
|
}
|
|
830
|
+
// Flow is opt-in and requires async bulk changelog fetch
|
|
831
|
+
if (hasFlow && allIssues.length > 0) {
|
|
832
|
+
lines.push('');
|
|
833
|
+
lines.push(await renderFlow(jiraClient, allIssues));
|
|
834
|
+
}
|
|
732
835
|
// Next steps
|
|
733
836
|
const nextSteps = analysisNextSteps(jql, allIssues.slice(0, 3).map(i => i.key), truncated, groupBy, filterSource);
|
|
734
837
|
lines.push(nextSteps);
|
|
@@ -19,8 +19,8 @@ Operation 0 — Save the query as a reusable filter:
|
|
|
19
19
|
Operation 1 — Summary with data quality signals (uses $0.filterId):
|
|
20
20
|
{"tool":"analyze_jira_issues","args":{"filterId":"$0.filterId","metrics":["summary"],"groupBy":"priority","compute":["rot_pct = backlog_rot / open * 100","stale_pct = stale / open * 100","gap_pct = no_estimate / open * 100"]}}
|
|
21
21
|
|
|
22
|
-
Operation 2 —
|
|
23
|
-
{"tool":"analyze_jira_issues","args":{"filterId":"$0.filterId","metrics":["
|
|
22
|
+
Operation 2 — Flow analysis for transition patterns and bottlenecks:
|
|
23
|
+
{"tool":"analyze_jira_issues","args":{"filterId":"$0.filterId","metrics":["flow"],"maxResults":100}}
|
|
24
24
|
|
|
25
25
|
After the pipeline completes, summarize findings:
|
|
26
26
|
- What percentage of the backlog is rotting (no owner, no dates, untouched)?
|
|
@@ -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 or a saved filter. 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)
|
|
329
|
+
description: 'Compute project metrics over issues selected by JQL or a saved filter. 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) for per-issue analysis (capped at maxResults). Use flow for status transition patterns — how issues move through statuses, where they bounce, and how long they stay. Tip: save complex JQL as a filter with manage_jira_filter, then reuse the filterId here for repeated analysis. Read jira://analysis/recipes for composition patterns.',
|
|
330
330
|
inputSchema: {
|
|
331
331
|
type: 'object',
|
|
332
332
|
properties: {
|
|
@@ -342,9 +342,9 @@ export const toolSchemas = {
|
|
|
342
342
|
type: 'array',
|
|
343
343
|
items: {
|
|
344
344
|
type: 'string',
|
|
345
|
-
enum: ['summary', 'points', 'time', 'schedule', 'cycle', 'distribution', 'cube_setup'],
|
|
345
|
+
enum: ['summary', 'points', 'time', 'schedule', 'cycle', 'distribution', 'flow', 'cube_setup'],
|
|
346
346
|
},
|
|
347
|
-
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.',
|
|
347
|
+
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). flow = status transition analysis from bulk changelogs — entries per status, avg time in each, bounce rates, top bouncers. 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 (excluding flow — request flow explicitly). For counting/breakdown questions, always prefer summary + groupBy over distribution.',
|
|
348
348
|
},
|
|
349
349
|
groupBy: {
|
|
350
350
|
type: 'string',
|
package/package.json
CHANGED