@aaronsb/jira-cloud-mcp 0.5.11 → 0.6.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.
@@ -1,10 +1,12 @@
1
1
  import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
2
+ import { GraphQLHierarchyWalker, walkTree } from '../client/graphql-hierarchy.js';
3
+ import { renderRollupTree } from '../handlers/plan-handler.js';
2
4
  import { evaluateRow, extractColumnRefs, parseComputeList } from '../utils/cube-dsl.js';
3
5
  import { analysisNextSteps } from '../utils/next-steps.js';
4
6
  import { normalizeArgs } from '../utils/normalize-args.js';
5
7
  const ALL_METRICS = ['points', 'time', 'schedule', 'cycle', 'distribution'];
6
- // flow is opt-in only — requires extra bulk changelog API call
7
- const VALID_GROUP_BY = ['project', 'assignee', 'priority', 'issuetype'];
8
+ // flow and hierarchy are opt-in only
9
+ const VALID_GROUP_BY = ['project', 'assignee', 'priority', 'issuetype', 'parent', 'sprint'];
8
10
  const MAX_ISSUES_HARD = 500; // absolute ceiling for detail metrics — beyond this, context explodes
9
11
  const MAX_ISSUES_DEFAULT = 100;
10
12
  const CUBE_SAMPLE_PCT = 0.2; // 20% of total issues
@@ -270,6 +272,10 @@ export function renderDistribution(issues, groupLimit = DEFAULT_GROUP_LIMIT) {
270
272
  lines.push(`**By priority:** ${mapToString(byPriority, ' | ', groupLimit)}`);
271
273
  const byType = countBy(issues, i => i.issueType || 'Unknown');
272
274
  lines.push(`**By type:** ${mapToString(byType, ' | ', groupLimit)}`);
275
+ const bySprint = countBy(issues, i => i.sprint || '(no sprint)');
276
+ if (bySprint.size > 1 || !bySprint.has('(no sprint)')) {
277
+ lines.push(`**By sprint:** ${mapToString(bySprint, ' | ', groupLimit)}`);
278
+ }
273
279
  return lines.join('\n');
274
280
  }
275
281
  export async function renderFlow(jiraClient, issues) {
@@ -433,6 +439,10 @@ export function groupByJqlClause(dimension, values) {
433
439
  return values.map(v => `priority = "${v}"`);
434
440
  case 'issuetype':
435
441
  return values.map(v => `issuetype = "${v}"`);
442
+ case 'parent':
443
+ return values.map(v => v === '(no parent)' ? 'issue not in childIssuesOf("")' : `parent = ${v}`);
444
+ case 'sprint':
445
+ return values.map(v => v === '(no sprint)' ? 'sprint is EMPTY' : `sprint = "${v}"`);
436
446
  }
437
447
  }
438
448
  async function buildCountRow(jiraClient, label, baseJql, implicitMeasureNames, implicitMeasureDefs) {
@@ -592,6 +602,8 @@ export function extractDimensions(issues, groupLimit = DEFAULT_GROUP_LIMIT) {
592
602
  { name: 'assignee', extractor: i => i.assignee || 'Unassigned' },
593
603
  { name: 'priority', extractor: i => i.priority || 'None' },
594
604
  { name: 'issuetype', extractor: i => i.issueType || 'Unknown' },
605
+ { name: 'parent', extractor: i => i.parent || '(no parent)' },
606
+ { name: 'sprint', extractor: i => i.sprint || '(no sprint)' },
595
607
  ];
596
608
  return dims.map(({ name, extractor }) => {
597
609
  const counts = new Map();
@@ -684,9 +696,185 @@ async function handleCubeSetup(jiraClient, jql) {
684
696
  const dimensions = extractDimensions(issues);
685
697
  return renderCubeSetup(jql, issues.length, dimensions);
686
698
  }
699
+ // ── Hierarchy Metric ──────────────────────────────────────────────────
700
+ const MAX_HIERARCHY_ROOTS = 3;
701
+ const HIERARCHY_MAX_DEPTH = 3;
702
+ const HIERARCHY_MAX_ITEMS = 50;
703
+ async function renderHierarchy(issues, graphqlClient) {
704
+ const lines = ['## Hierarchy Rollups'];
705
+ // Find root issues: those with parents not in the result set
706
+ const issueKeys = new Set(issues.map(i => i.key));
707
+ const parentKeys = new Set();
708
+ for (const issue of issues) {
709
+ if (issue.parent && !issueKeys.has(issue.parent)) {
710
+ parentKeys.add(issue.parent);
711
+ }
712
+ }
713
+ // Also include issues in the set that have no parent (they are roots)
714
+ for (const issue of issues) {
715
+ if (!issue.parent) {
716
+ parentKeys.add(issue.key);
717
+ }
718
+ }
719
+ // If no hierarchy detected, say so
720
+ if (parentKeys.size === 0) {
721
+ lines.push('', '*No parent-child relationships detected in this issue set.*');
722
+ return lines.join('\n');
723
+ }
724
+ // Walk each root (cap at MAX_HIERARCHY_ROOTS)
725
+ const roots = [...parentKeys].slice(0, MAX_HIERARCHY_ROOTS);
726
+ const walker = new GraphQLHierarchyWalker(graphqlClient);
727
+ for (const rootKey of roots) {
728
+ try {
729
+ const { tree } = await walker.walkDown(rootKey, HIERARCHY_MAX_DEPTH, HIERARCHY_MAX_ITEMS);
730
+ const rollup = GraphQLHierarchyWalker.computeRollups(tree);
731
+ lines.push('');
732
+ lines.push(`### ${rootKey}: ${tree.issue.summary}`);
733
+ lines.push(`Progress: ${rollup.resolvedItems}/${rollup.totalItems} (${rollup.progressPct}%) | Points: ${rollup.totalPoints} (${rollup.earnedPoints} earned)`);
734
+ if (rollup.rolledUpStart || rollup.rolledUpEnd) {
735
+ lines.push(`Dates: ${rollup.rolledUpStart ?? '—'} – ${rollup.rolledUpEnd ?? '—'}`);
736
+ }
737
+ if (rollup.conflicts.length > 0) {
738
+ lines.push(`Conflicts: ${rollup.conflicts.map(c => `${c.issueKey}: ${c.message}`).join('; ')}`);
739
+ }
740
+ lines.push('');
741
+ renderRollupTree(tree, lines, ['dates', 'points', 'progress'], '', true);
742
+ }
743
+ catch {
744
+ lines.push('', `*Could not walk hierarchy for ${rootKey}*`);
745
+ }
746
+ }
747
+ if (parentKeys.size > MAX_HIERARCHY_ROOTS) {
748
+ lines.push('', `*Showing ${MAX_HIERARCHY_ROOTS} of ${parentKeys.size} root issues. Use analyze_jira_plan for a focused subtree.*`);
749
+ }
750
+ return lines.join('\n');
751
+ }
752
+ // ── Cache → Issue Mapping ─────────────────────────────────────────────
753
+ /** Map a GraphIssue from cache to JiraIssueDetails for metric renderers */
754
+ function graphIssueToDetails(issue) {
755
+ const statusCategoryMap = {
756
+ 'To Do': 'new',
757
+ 'In Progress': 'indeterminate',
758
+ 'Done': 'done',
759
+ };
760
+ return {
761
+ key: issue.key,
762
+ summary: issue.summary,
763
+ description: '',
764
+ issueType: issue.issueType,
765
+ priority: null,
766
+ parent: issue.parentKey,
767
+ assignee: issue.assignee,
768
+ reporter: '',
769
+ status: issue.status,
770
+ statusCategory: statusCategoryMap[issue.statusCategory] ?? 'unknown',
771
+ resolution: issue.isResolved ? 'Done' : null,
772
+ labels: [],
773
+ created: '',
774
+ updated: '',
775
+ resolutionDate: null,
776
+ statusCategoryChanged: null,
777
+ dueDate: issue.dueDate,
778
+ startDate: issue.startDate,
779
+ storyPoints: issue.storyPoints,
780
+ sprint: null,
781
+ timeEstimate: null,
782
+ issueLinks: [],
783
+ };
784
+ }
785
+ /** Flatten a cached hierarchy tree to JiraIssueDetails[] for existing metric renderers */
786
+ export function flattenCacheToIssueDetails(tree) {
787
+ const issues = [];
788
+ walkTree(tree, (node) => {
789
+ issues.push(graphIssueToDetails(node.issue));
790
+ });
791
+ return issues;
792
+ }
793
+ // ── DataRef Handler (cached plan data) ─────────────────────────────────
794
+ async function handleDataRefAnalysis(cached, args, graphqlClient, groupLimit = DEFAULT_GROUP_LIMIT) {
795
+ const allIssues = flattenCacheToIssueDetails(cached.tree);
796
+ const requested = (args.metrics && Array.isArray(args.metrics))
797
+ ? args.metrics
798
+ : [];
799
+ const fetchMetrics = requested.length > 0
800
+ ? requested.filter(m => ALL_METRICS.includes(m))
801
+ : ALL_METRICS;
802
+ const hasHierarchy = requested.includes('hierarchy');
803
+ const now = new Date();
804
+ const lines = [];
805
+ lines.push(`# Analysis: ${cached.rootKey} (from cache)`);
806
+ lines.push(`Analyzing ${allIssues.length} issues from cached hierarchy walk`);
807
+ lines.push('');
808
+ const renderers = {
809
+ points: () => renderPoints(allIssues),
810
+ time: () => renderTime(allIssues),
811
+ schedule: () => renderSchedule(allIssues, now),
812
+ cycle: () => renderCycle(allIssues, now),
813
+ distribution: () => renderDistribution(allIssues, groupLimit),
814
+ };
815
+ for (const metric of fetchMetrics) {
816
+ if (renderers[metric]) {
817
+ lines.push(renderers[metric]());
818
+ lines.push('');
819
+ }
820
+ }
821
+ // Hierarchy renders the cached tree directly (no re-walk)
822
+ if (hasHierarchy) {
823
+ lines.push('## Hierarchy Rollups');
824
+ lines.push('');
825
+ const rollup = GraphQLHierarchyWalker.computeRollups(cached.tree);
826
+ lines.push(`Progress: ${rollup.resolvedItems}/${rollup.totalItems} (${rollup.progressPct}%) | Points: ${rollup.totalPoints} (${rollup.earnedPoints} earned)`);
827
+ if (rollup.rolledUpStart || rollup.rolledUpEnd) {
828
+ lines.push(`Dates: ${rollup.rolledUpStart ?? '—'} – ${rollup.rolledUpEnd ?? '—'}`);
829
+ }
830
+ lines.push('');
831
+ renderRollupTree(cached.tree, lines, ['dates', 'points', 'progress'], '', true);
832
+ }
833
+ // Flow not supported from cache — no changelog data
834
+ if (requested.includes('flow')) {
835
+ lines.push('');
836
+ lines.push('## Flow\n\n*Flow metric requires changelog data and is not available from cached plan data. Use jql or filterId instead.*');
837
+ }
838
+ // Summary not supported from cache — needs count API
839
+ if (requested.includes('summary')) {
840
+ lines.push('');
841
+ lines.push('## Summary\n\n*Summary metric uses the count API and is not available from cached plan data. Use jql or filterId instead.*');
842
+ }
843
+ const nextSteps = analysisNextSteps(`dataRef:${cached.rootKey}`, allIssues.slice(0, 3).map(i => i.key), false, undefined);
844
+ lines.push(nextSteps);
845
+ return {
846
+ content: [{
847
+ type: 'text',
848
+ text: lines.join('\n'),
849
+ }],
850
+ };
851
+ }
687
852
  // ── Main Handler ───────────────────────────────────────────────────────
688
- export async function handleAnalysisRequest(jiraClient, request) {
853
+ export async function handleAnalysisRequest(jiraClient, request, graphqlClient, cache) {
689
854
  const args = normalizeArgs(request.params?.arguments || {});
855
+ // Parse groupLimit early — needed by dataRef path and summary path
856
+ const rawGroupLimit = Number(args.groupLimit);
857
+ const groupLimit = rawGroupLimit > 0 ? rawGroupLimit : DEFAULT_GROUP_LIMIT;
858
+ // dataRef: analyze cached plan data instead of fetching from Jira
859
+ const dataRef = args.dataRef;
860
+ if (dataRef && typeof dataRef === 'string' && dataRef.trim() !== '') {
861
+ if (!cache) {
862
+ throw new McpError(ErrorCode.InvalidParams, 'dataRef requires the graph object cache (start a walk with analyze_jira_plan first).');
863
+ }
864
+ const cached = cache.get(dataRef);
865
+ if (!cached) {
866
+ throw new McpError(ErrorCode.InvalidParams, `No cached walk for "${dataRef}". Start one with analyze_jira_plan first.`);
867
+ }
868
+ if (cached.state === 'walking') {
869
+ return {
870
+ content: [{
871
+ type: 'text',
872
+ text: `Walk for ${dataRef} is still in progress (${cached.itemCount} items so far). Try again shortly.`,
873
+ }],
874
+ };
875
+ }
876
+ return handleDataRefAnalysis(cached, args, graphqlClient, groupLimit);
877
+ }
690
878
  // Resolve JQL: filterId takes precedence over inline jql
691
879
  let jql;
692
880
  let filterSource;
@@ -708,7 +896,7 @@ export async function handleAnalysisRequest(jiraClient, request) {
708
896
  else {
709
897
  jql = args.jql;
710
898
  if (!jql || typeof jql !== 'string' || jql.trim() === '') {
711
- throw new McpError(ErrorCode.InvalidParams, 'Either jql or filterId parameter is required.');
899
+ throw new McpError(ErrorCode.InvalidParams, 'Either jql, filterId, or dataRef parameter is required.');
712
900
  }
713
901
  }
714
902
  // Parse requested metrics
@@ -718,11 +906,12 @@ export async function handleAnalysisRequest(jiraClient, request) {
718
906
  const hasSummary = requested.includes('summary');
719
907
  const hasCubeSetup = requested.includes('cube_setup');
720
908
  const hasFlow = requested.includes('flow');
909
+ const hasHierarchy = requested.includes('hierarchy');
721
910
  const fetchMetrics = requested.length > 0
722
911
  ? requested.filter(m => ALL_METRICS.includes(m))
723
912
  : ALL_METRICS;
724
- // flow needs issue fetching but isn't in ALL_METRICS (opt-in only)
725
- const needsIssueFetch = fetchMetrics.length > 0 || hasFlow;
913
+ // flow and hierarchy need issue fetching but aren't in ALL_METRICS (opt-in only)
914
+ const needsIssueFetch = fetchMetrics.length > 0 || hasFlow || hasHierarchy;
726
915
  // Parse groupBy
727
916
  const groupBy = (typeof args.groupBy === 'string' && VALID_GROUP_BY.includes(args.groupBy))
728
917
  ? args.groupBy
@@ -732,9 +921,6 @@ export async function handleAnalysisRequest(jiraClient, request) {
732
921
  if (args.compute && Array.isArray(args.compute) && args.compute.length > 0) {
733
922
  compute = parseComputeList(args.compute);
734
923
  }
735
- // Parse groupLimit — soft cap on group/dimension values (no hard cap for summary metrics)
736
- const rawGroupLimit = Number(args.groupLimit);
737
- const groupLimit = rawGroupLimit > 0 ? rawGroupLimit : DEFAULT_GROUP_LIMIT;
738
924
  // Cube setup — discover dimensions from sample, no issue fetching
739
925
  if (hasCubeSetup) {
740
926
  const cubeText = await handleCubeSetup(jiraClient, jql);
@@ -832,6 +1018,16 @@ export async function handleAnalysisRequest(jiraClient, request) {
832
1018
  lines.push('');
833
1019
  lines.push(await renderFlow(jiraClient, allIssues));
834
1020
  }
1021
+ // Hierarchy is opt-in and requires GraphQL client
1022
+ if (hasHierarchy && allIssues.length > 0) {
1023
+ lines.push('');
1024
+ if (graphqlClient) {
1025
+ lines.push(await renderHierarchy(allIssues, graphqlClient));
1026
+ }
1027
+ else {
1028
+ lines.push('## Hierarchy\n\n*Hierarchy metric requires GraphQL (cloudId discovery). Not available for this instance.*');
1029
+ }
1030
+ }
835
1031
  // Next steps
836
1032
  const nextSteps = analysisNextSteps(jql, allIssues.slice(0, 3).map(i => i.key), truncated, groupBy, filterSource);
837
1033
  lines.push(nextSteps);
@@ -0,0 +1,351 @@
1
+ import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
2
+ import { GraphQLHierarchyWalker, collectLeaves, computeDepth, walkTree } from '../client/graphql-hierarchy.js';
3
+ import { planNextSteps } from '../utils/next-steps.js';
4
+ import { normalizeArgs } from '../utils/normalize-args.js';
5
+ const ALL_ROLLUPS = ['dates', 'points', 'progress', 'assignees'];
6
+ const MAX_CHILDREN_DISPLAY = 20;
7
+ export async function handlePlanRequest(_jiraClient, graphqlClient, request, cache) {
8
+ const args = normalizeArgs(request.params?.arguments ?? {});
9
+ const issueKey = args.issueKey;
10
+ if (!issueKey) {
11
+ throw new McpError(ErrorCode.InvalidParams, 'issueKey is required for analyze_jira_plan');
12
+ }
13
+ const operation = args.operation ?? 'analyze';
14
+ // Handle release operation
15
+ if (operation === 'release') {
16
+ if (!cache) {
17
+ return { content: [{ type: 'text', text: 'Cache not available.' }] };
18
+ }
19
+ const released = cache.release(issueKey);
20
+ return {
21
+ content: [{
22
+ type: 'text',
23
+ text: released
24
+ ? `Released cached walk for ${issueKey}.`
25
+ : `No cached walk found for ${issueKey}.`,
26
+ }],
27
+ };
28
+ }
29
+ const rollups = (Array.isArray(args.rollups) ? args.rollups : ALL_ROLLUPS);
30
+ const mode = args.mode ?? 'rollup';
31
+ const focus = args.focus;
32
+ // Try cache-first path
33
+ if (cache) {
34
+ const status = cache.getStatus(issueKey);
35
+ if (status.state === 'error') {
36
+ cache.release(issueKey);
37
+ return {
38
+ content: [{
39
+ type: 'text',
40
+ text: `Walk failed for ${issueKey}: ${status.error ?? 'unknown error'}. Cleared from cache — call again to retry.`,
41
+ }],
42
+ isError: true,
43
+ };
44
+ }
45
+ if (status.state === 'walking') {
46
+ return {
47
+ content: [{
48
+ type: 'text',
49
+ text: `Walking hierarchy for ${issueKey}... ${status.itemCount} items collected so far.\nCall again to check progress or wait for completion.`,
50
+ }],
51
+ };
52
+ }
53
+ if (status.state === 'not_found') {
54
+ cache.startWalk(issueKey, graphqlClient);
55
+ return {
56
+ content: [{
57
+ type: 'text',
58
+ text: `Started hierarchy walk for ${issueKey}. Call again to check progress.\n\n*The walk runs in the background — subsequent calls will show progress or full results once complete.*`,
59
+ }],
60
+ };
61
+ }
62
+ if (status.state === 'complete' || status.state === 'stale') {
63
+ const cached = cache.get(issueKey);
64
+ const staleNote = status.stale
65
+ ? '> **Note:** This data may be stale. Call again to refresh, or use `operation: "release"` to clear.\n\n'
66
+ : '';
67
+ if (status.stale) {
68
+ cache.startWalk(issueKey, graphqlClient);
69
+ }
70
+ // Focus mode: windowed view of a specific node
71
+ if (focus) {
72
+ const output = renderFocusView(cached.tree, focus, rollups);
73
+ return { content: [{ type: 'text', text: staleNote + output }] };
74
+ }
75
+ // Default: summary + entry points (bounded)
76
+ const rollupResult = GraphQLHierarchyWalker.computeRollups(cached.tree);
77
+ const output = mode === 'gaps'
78
+ ? renderGapsSummary(cached.tree, rollups, rollupResult)
79
+ : renderOverview(cached.tree, issueKey, cached.itemCount, rollups, rollupResult);
80
+ return {
81
+ content: [{
82
+ type: 'text',
83
+ text: staleNote + output + planNextSteps(issueKey, mode, rollupResult.conflicts, rollupResult),
84
+ }],
85
+ };
86
+ }
87
+ }
88
+ // Fallback: no cache, walk synchronously with original limits
89
+ const walker = new GraphQLHierarchyWalker(graphqlClient);
90
+ let tree;
91
+ let totalItems;
92
+ try {
93
+ ({ tree, totalItems } = await walker.walkDown(issueKey));
94
+ }
95
+ catch (err) {
96
+ const message = err.message;
97
+ if (message.includes('not found')) {
98
+ throw new McpError(ErrorCode.InvalidParams, `Issue ${issueKey} not found.`);
99
+ }
100
+ throw new McpError(ErrorCode.InternalError, `Hierarchy walk failed: ${message}`);
101
+ }
102
+ const rollupResult = GraphQLHierarchyWalker.computeRollups(tree);
103
+ const output = renderOverview(tree, issueKey, totalItems, rollups);
104
+ return {
105
+ content: [{
106
+ type: 'text',
107
+ text: output + planNextSteps(issueKey, mode, rollupResult.conflicts, rollupResult),
108
+ }],
109
+ };
110
+ }
111
+ // --- Rendering: Overview (summary + entry points) ---
112
+ function renderOverview(tree, issueKey, totalItems, rollups, rollupResult) {
113
+ const lines = [];
114
+ const depth = computeDepth(tree);
115
+ rollupResult ??= GraphQLHierarchyWalker.computeRollups(tree);
116
+ lines.push(`# Plan: ${issueKey} — ${tree.issue.summary}`);
117
+ lines.push(`${totalItems} items, ${depth} levels deep | cached`);
118
+ lines.push('');
119
+ renderSummaryBlock(tree, lines, rollups, rollupResult);
120
+ lines.push('');
121
+ // Entry points: immediate children with their rollup summaries
122
+ if (tree.children.length > 0) {
123
+ lines.push('## Children');
124
+ lines.push('');
125
+ const shown = tree.children.slice(0, MAX_CHILDREN_DISPLAY);
126
+ for (const child of shown) {
127
+ renderNodeLine(child, lines, rollups);
128
+ }
129
+ if (tree.children.length > MAX_CHILDREN_DISPLAY) {
130
+ lines.push(`*...and ${tree.children.length - MAX_CHILDREN_DISPLAY} more — use \`focus\` to navigate*`);
131
+ }
132
+ lines.push('');
133
+ lines.push('*Use `focus: "ISSUE-KEY"` to explore any node and its neighborhood.*');
134
+ }
135
+ return lines.join('\n');
136
+ }
137
+ // --- Rendering: Focus (windowed view of a specific node) ---
138
+ function findInTree(node, key, parent = null) {
139
+ if (node.issue.key === key)
140
+ return { node, parent };
141
+ for (const child of node.children) {
142
+ const found = findInTree(child, key, node);
143
+ if (found)
144
+ return found;
145
+ }
146
+ return null;
147
+ }
148
+ function renderFocusView(tree, focusKey, rollups) {
149
+ const found = findInTree(tree, focusKey);
150
+ if (!found) {
151
+ return `Issue ${focusKey} not found in cached hierarchy. Available root: ${tree.issue.key}`;
152
+ }
153
+ const focusNode = found.node;
154
+ const parentNode = found.parent;
155
+ const lines = [];
156
+ const rollupResult = GraphQLHierarchyWalker.computeRollups(focusNode);
157
+ lines.push(`# Focus: ${focusNode.issue.key} — ${focusNode.issue.summary}`);
158
+ lines.push(`[${focusNode.issue.issueType}] ${focusNode.issue.status}`);
159
+ lines.push('');
160
+ // Parent context
161
+ if (parentNode) {
162
+ const parentRollup = GraphQLHierarchyWalker.computeRollups(parentNode);
163
+ lines.push(`**Parent:** ${parentNode.issue.key} — ${parentNode.issue.summary} [${parentNode.issue.issueType}]`);
164
+ lines.push(` Progress: ${parentRollup.resolvedItems}/${parentRollup.totalItems} (${parentRollup.progressPct}%)`);
165
+ lines.push('');
166
+ }
167
+ // This node's details
168
+ renderSummaryBlock(focusNode, lines, rollups, rollupResult);
169
+ lines.push('');
170
+ // Siblings (if has parent)
171
+ if (parentNode) {
172
+ const siblings = parentNode.children.filter(c => c.issue.key !== focusKey);
173
+ if (siblings.length > 0) {
174
+ lines.push(`## Siblings (${siblings.length})`);
175
+ lines.push('');
176
+ const shown = siblings.slice(0, MAX_CHILDREN_DISPLAY);
177
+ for (const sib of shown) {
178
+ renderNodeLine(sib, lines, rollups);
179
+ }
180
+ if (siblings.length > MAX_CHILDREN_DISPLAY) {
181
+ lines.push(`*...and ${siblings.length - MAX_CHILDREN_DISPLAY} more*`);
182
+ }
183
+ lines.push('');
184
+ }
185
+ }
186
+ // Children
187
+ if (focusNode.children.length > 0) {
188
+ lines.push(`## Children (${focusNode.children.length})`);
189
+ lines.push('');
190
+ const shown = focusNode.children.slice(0, MAX_CHILDREN_DISPLAY);
191
+ for (const child of shown) {
192
+ renderNodeLine(child, lines, rollups);
193
+ }
194
+ if (focusNode.children.length > MAX_CHILDREN_DISPLAY) {
195
+ lines.push(`*...and ${focusNode.children.length - MAX_CHILDREN_DISPLAY} more*`);
196
+ }
197
+ }
198
+ else {
199
+ lines.push('*Leaf node — no children*');
200
+ }
201
+ return lines.join('\n');
202
+ }
203
+ /** Render a single node as a compact line with rollup summary */
204
+ function renderNodeLine(node, lines, rollups) {
205
+ const statusCat = node.issue.statusCategory.toLowerCase();
206
+ const icon = statusCat === 'done' ? '✓' : statusCat === 'in progress' ? '●' : '○';
207
+ const parts = [];
208
+ parts.push(`${icon} **${node.issue.key}**: ${node.issue.summary} [${node.issue.issueType}]`);
209
+ const details = [];
210
+ if (node.children.length > 0) {
211
+ const rollup = GraphQLHierarchyWalker.computeRollups(node);
212
+ if (rollups.includes('progress')) {
213
+ details.push(`${rollup.resolvedItems}/${rollup.totalItems} (${rollup.progressPct}%)`);
214
+ }
215
+ if (rollups.includes('points') && rollup.totalPoints > 0) {
216
+ details.push(`${rollup.earnedPoints}/${rollup.totalPoints} pts`);
217
+ }
218
+ if (rollups.includes('dates') && (rollup.rolledUpStart || rollup.rolledUpEnd)) {
219
+ details.push(`${rollup.rolledUpStart ?? '—'} – ${rollup.rolledUpEnd ?? '—'}`);
220
+ }
221
+ if (rollup.conflicts.length > 0) {
222
+ details.push(`${rollup.conflicts.length} conflicts`);
223
+ }
224
+ }
225
+ else {
226
+ if (node.issue.assignee)
227
+ details.push(node.issue.assignee);
228
+ if (rollups.includes('dates') && (node.issue.startDate || node.issue.dueDate)) {
229
+ details.push(`${node.issue.startDate ?? '—'} – ${node.issue.dueDate ?? '—'}`);
230
+ }
231
+ if (rollups.includes('points') && node.issue.storyPoints != null) {
232
+ details.push(`${node.issue.storyPoints} pts`);
233
+ }
234
+ }
235
+ if (details.length > 0) {
236
+ lines.push(`- ${parts[0]}`);
237
+ lines.push(` ${details.join(' | ')}`);
238
+ }
239
+ else {
240
+ lines.push(`- ${parts[0]}`);
241
+ }
242
+ }
243
+ // --- Rendering: Gaps summary (bounded) ---
244
+ function renderGapsSummary(tree, rollups, rollupResult) {
245
+ const lines = [];
246
+ rollupResult ??= GraphQLHierarchyWalker.computeRollups(tree);
247
+ lines.push(`# Gaps: ${tree.issue.key} — ${tree.issue.summary}`);
248
+ lines.push('');
249
+ const gaps = [];
250
+ for (const c of rollupResult.conflicts) {
251
+ gaps.push(`- **${c.issueKey}** [${c.type}]: ${c.message}`);
252
+ }
253
+ walkTree(tree, (node) => {
254
+ if (node.children.length === 0)
255
+ return;
256
+ if (rollups.includes('dates')) {
257
+ const undated = node.children.filter(c => !c.issue.startDate && !c.issue.dueDate);
258
+ const dated = node.children.length - undated.length;
259
+ if (undated.length > 0 && dated > 0) {
260
+ gaps.push(`- **${node.issue.key}**: ${undated.length}/${node.children.length} children have no dates`);
261
+ }
262
+ }
263
+ if (rollups.includes('points')) {
264
+ const unestimated = node.children.filter(c => c.issue.storyPoints == null);
265
+ const estimated = node.children.length - unestimated.length;
266
+ if (unestimated.length > 0 && estimated > 0) {
267
+ gaps.push(`- **${node.issue.key}**: ${unestimated.length}/${node.children.length} children have no story points`);
268
+ }
269
+ }
270
+ if (rollups.includes('assignees')) {
271
+ const unassigned = node.children.filter(c => !c.issue.assignee && !c.issue.isResolved);
272
+ if (unassigned.length > 0) {
273
+ gaps.push(`- **${node.issue.key}**: ${unassigned.length} active children unassigned`);
274
+ }
275
+ }
276
+ });
277
+ if (gaps.length === 0) {
278
+ lines.push('No gaps or conflicts detected.');
279
+ }
280
+ else {
281
+ const unique = [...new Set(gaps)];
282
+ // Cap output to first 30 gaps
283
+ const shown = unique.slice(0, 30);
284
+ lines.push(...shown);
285
+ if (unique.length > 30) {
286
+ lines.push(`\n*...and ${unique.length - 30} more. Use \`focus\` on a specific subtree to narrow down.*`);
287
+ }
288
+ }
289
+ return lines.join('\n');
290
+ }
291
+ // --- Shared rendering ---
292
+ function renderSummaryBlock(tree, lines, rollups, result) {
293
+ if (rollups.includes('dates')) {
294
+ const own = `${tree.issue.startDate ?? '—'} – ${tree.issue.dueDate ?? '—'}`;
295
+ const derived = `${result.rolledUpStart ?? '—'} – ${result.rolledUpEnd ?? '—'}`;
296
+ lines.push(`**Dates:** own ${own} | rolled-up ${derived}`);
297
+ }
298
+ if (rollups.includes('points') && result.totalPoints > 0) {
299
+ lines.push(`**Points:** ${result.totalPoints} total, ${result.earnedPoints} earned`);
300
+ }
301
+ if (rollups.includes('progress')) {
302
+ lines.push(`**Progress:** ${result.resolvedItems}/${result.totalItems} resolved (${result.progressPct}%)`);
303
+ }
304
+ if (rollups.includes('assignees') && result.assignees.length > 0) {
305
+ lines.push(`**Team:** ${result.assignees.join(', ')}${result.unassignedCount > 0 ? ` | ${result.unassignedCount} unassigned` : ''}`);
306
+ }
307
+ if (result.conflicts.length > 0) {
308
+ lines.push(`**Conflicts:** ${result.conflicts.length} detected`);
309
+ }
310
+ }
311
+ /** Full tree renderer — kept for analysis-handler hierarchy metric (small trees only) */
312
+ export function renderRollupTree(node, lines, rollups, prefix, isLast) {
313
+ const connector = prefix === '' ? '' : (isLast ? '└── ' : '├── ');
314
+ const statusCat = node.issue.statusCategory.toLowerCase();
315
+ const icon = statusCat === 'done' ? '✓' : statusCat === 'in progress' ? '●' : '○';
316
+ const label = `${icon} **${node.issue.key}**: ${node.issue.summary} [${node.issue.issueType}]`;
317
+ lines.push(`${prefix}${connector}${label}`);
318
+ const indent = prefix + (prefix === '' ? '' : (isLast ? ' ' : '│ '));
319
+ if (rollups.includes('dates')) {
320
+ const start = node.issue.startDate ?? '—';
321
+ const due = node.issue.dueDate ?? '—';
322
+ if (start !== '—' || due !== '—' || node.children.length > 0) {
323
+ let dateLine = `${indent} ${start} – ${due}`;
324
+ if (node.children.length > 0) {
325
+ const childRollup = GraphQLHierarchyWalker.computeRollups(node);
326
+ if (childRollup.rolledUpStart || childRollup.rolledUpEnd) {
327
+ dateLine += ` | rolled-up ${childRollup.rolledUpStart ?? '—'} – ${childRollup.rolledUpEnd ?? '—'}`;
328
+ }
329
+ const conflict = childRollup.conflicts.find(c => c.issueKey === node.issue.key);
330
+ if (conflict)
331
+ dateLine += ` ⚠️ ${conflict.message}`;
332
+ }
333
+ lines.push(dateLine);
334
+ }
335
+ }
336
+ if (rollups.includes('points') && node.issue.storyPoints != null) {
337
+ lines.push(`${indent} ${node.issue.storyPoints} pts`);
338
+ }
339
+ if (rollups.includes('progress') && node.children.length > 0) {
340
+ const leaves = collectLeaves(node);
341
+ const resolved = leaves.filter(l => l.isResolved).length;
342
+ lines.push(`${indent} Progress: ${resolved}/${leaves.length} (${leaves.length > 0 ? Math.round(resolved / leaves.length * 100) : 0}%)`);
343
+ }
344
+ if (rollups.includes('assignees') && node.children.length === 0 && node.issue.assignee) {
345
+ lines.push(`${indent} ${node.issue.assignee}`);
346
+ }
347
+ const childPrefix = prefix + (prefix === '' ? '' : (isLast ? ' ' : '│ '));
348
+ node.children.forEach((child, i) => {
349
+ renderRollupTree(child, lines, rollups, childPrefix, i === node.children.length - 1);
350
+ });
351
+ }