@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 && fetchMetrics.length === 0) {
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 — Cycle metrics for staleness distribution:
23
- {"tool":"analyze_jira_issues","args":{"filterId":"$0.filterId","metrics":["cycle"],"maxResults":100}}
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) 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. 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.',
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aaronsb/jira-cloud-mcp",
3
- "version": "0.5.10",
3
+ "version": "0.5.11",
4
4
  "mcpName": "io.github.aaronsb/jira-cloud",
5
5
  "description": "Model Context Protocol (MCP) server for Jira Cloud - enables AI assistants to interact with Jira",
6
6
  "type": "module",