@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.
- package/build/client/field-discovery.js +24 -0
- package/build/client/graph-object-cache.js +127 -0
- package/build/client/graphql-client.js +95 -0
- package/build/client/graphql-hierarchy.js +253 -0
- package/build/client/jira-client.js +22 -0
- package/build/handlers/analysis-handler.js +205 -9
- package/build/handlers/plan-handler.js +351 -0
- package/build/index.js +72 -2
- package/build/schemas/tool-schemas.js +45 -5
- package/build/utils/next-steps.js +51 -1
- package/package.json +1 -1
- package/build/worker.js +0 -200
|
@@ -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
|
|
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
|
|
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
|
|
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
|
+
}
|