jira-ai 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -19,33 +19,6 @@ An AI-friendly CLI for Jira that prioritizes efficiency and security.
19
19
  npm install -g jira-ai
20
20
  ```
21
21
 
22
- ### Install in Claude code:
23
- #### Step 1: Add the Marketplace
24
- Add this marketplace to your Claude instance:
25
- ```bash
26
- claude plugin marketplace add festoinc/management-plugins
27
- ```
28
-
29
- #### Step 2: Install the Plugin
30
- ```bash
31
- claude plugin install jira-ai-connector@management-plugins
32
- ```
33
-
34
- Will be avalibale automatically as skill
35
-
36
-
37
- ### Install in Gemini CLI
38
- #### Step 1: Add the Extension
39
- Add this extension to your Gemini CLI:
40
- ```bash
41
- gemini extension install https://github.com/festoinc/management-plugins
42
- ```
43
-
44
- Will be avalible as slash command
45
- ```bash
46
- /work-with-jira
47
- ```
48
-
49
22
  ## Quick Start
50
23
 
51
24
  Authenticate with credentials (non-interactive — JSON or .env file only):
@@ -76,6 +49,40 @@ Errors are returned as structured JSON to stdout:
76
49
  { "error": true, "message": "Issue not found", "hints": ["Check the issue key"], "exitCode": 1 }
77
50
  ```
78
51
 
52
+ ### Issue Hierarchy Tree
53
+
54
+ Explore issue hierarchies with the `issue tree` command. It returns a directed graph (nodes + edges) representing the full parent-child hierarchy starting from a given issue:
55
+
56
+ ```bash
57
+ jira-ai issue tree PROJ-10
58
+ ```
59
+
60
+ Include linked issues as leaf nodes:
61
+
62
+ ```bash
63
+ jira-ai issue tree PROJ-10 --links
64
+ ```
65
+
66
+ Filter linked issues by type and limit depth:
67
+
68
+ ```bash
69
+ jira-ai issue tree PROJ-10 --links --types "Blocks,Relates" --depth 2 --max-nodes 100
70
+ ```
71
+
72
+ ### Sprint Hierarchy Tree
73
+
74
+ View all issues in a sprint organized by their hierarchy (epics → stories → subtasks):
75
+
76
+ ```bash
77
+ jira-ai sprint tree 42
78
+ ```
79
+
80
+ Customize traversal depth and node limit:
81
+
82
+ ```bash
83
+ jira-ai sprint tree 42 --depth 4 --max-nodes 500
84
+ ```
85
+
79
86
  ### Rich Issue Management
80
87
 
81
88
  Create issues with detailed field support:
package/dist/cli.js CHANGED
@@ -20,6 +20,8 @@ import { listLinkTypesCommand } from './commands/list-link-types.js';
20
20
  import { createTaskCommand } from './commands/create-task.js';
21
21
  import { transitionCommand, listTransitionsCommand } from './commands/transition.js';
22
22
  import { issueAssignCommand } from './commands/issue.js';
23
+ import { issueTreeCommand } from './commands/issue-tree.js';
24
+ import { sprintTreeCommand } from './commands/sprint-tree.js';
23
25
  import { uploadAttachmentCommand, listAttachmentsCommand, downloadAttachmentCommand, deleteAttachmentCommand, } from './commands/attach.js';
24
26
  import { getIssueStatisticsCommand } from './commands/get-issue-statistics.js';
25
27
  import { getPersonWorklogCommand } from './commands/get-person-worklog.js';
@@ -46,6 +48,7 @@ program
46
48
  .description('CLI tool for interacting with Atlassian Jira')
47
49
  .version(getVersion())
48
50
  .option('--compact', 'Output as compact JSON')
51
+ .option('--dry-run', 'Preview changes without executing them')
49
52
  .addHelpText('after', () => {
50
53
  const latestVersion = checkForUpdateSync();
51
54
  if (latestVersion) {
@@ -328,6 +331,19 @@ issueAttach
328
331
  }, {
329
332
  validateArgs: (args) => validateOptions(IssueKeySchema, args[0])
330
333
  }));
334
+ issue
335
+ .command('tree <issue-key>')
336
+ .description('Show the full issue hierarchy tree rooted at an issue, including subtasks and optionally linked issues.')
337
+ .option('--links', 'Include linked issues as single-hop leaf nodes')
338
+ .option('--depth <number>', 'Max traversal depth (default: 3)', '3')
339
+ .option('--max-nodes <number>', 'Max nodes in tree (default: 200)', '200')
340
+ .option('--types <types>', 'Comma-separated link types to include (e.g. Blocks,Relates)')
341
+ .action(withPermission('issue.tree', (issueKey, options) => issueTreeCommand(issueKey, {
342
+ links: options.links || false,
343
+ depth: options.depth ? Number(options.depth) : undefined,
344
+ maxNodes: options.maxNodes ? Number(options.maxNodes) : undefined,
345
+ types: options.types,
346
+ }), { validateArgs: (args) => validateOptions(IssueKeySchema, args[0]) }));
331
347
  // =============================================================================
332
348
  // PROJECT COMMANDS
333
349
  // =============================================================================
@@ -595,6 +611,15 @@ sprint
595
611
  .option('--before <key>', 'Rank before this issue')
596
612
  .option('--after <key>', 'Rank after this issue')
597
613
  .action(withPermission('sprint.move', (sprintId, options) => sprintMoveCommand(Number(sprintId), options), { schema: SprintMoveSchema }));
614
+ sprint
615
+ .command('tree <sprint-id>')
616
+ .description('Show the full issue hierarchy tree for a sprint, grouped by epics.')
617
+ .option('--depth <number>', 'Max traversal depth (default: 3)', '3')
618
+ .option('--max-nodes <number>', 'Max nodes in tree (default: 200)', '200')
619
+ .action(withPermission('sprint.tree', (sprintId, options) => sprintTreeCommand(sprintId, {
620
+ depth: options.depth ? Number(options.depth) : undefined,
621
+ maxNodes: options.maxNodes ? Number(options.maxNodes) : undefined,
622
+ })));
598
623
  // =============================================================================
599
624
  // BACKLOG COMMANDS
600
625
  // =============================================================================
@@ -6,6 +6,7 @@ import { CommandError } from '../lib/errors.js';
6
6
  import { validateOptions, CreateTaskSchema } from '../lib/validation.js';
7
7
  import { isCommandAllowed, isProjectAllowed } from '../lib/settings.js';
8
8
  import { outputResult } from '../lib/json-mode.js';
9
+ import { isDryRun, formatDryRunResult } from '../lib/dry-run.js';
9
10
  export async function createTaskCommand(options) {
10
11
  validateOptions(CreateTaskSchema, options);
11
12
  const { title, project, issueType, parent, priority, description, descriptionFile, labels, component, fixVersion, dueDate, assignee, customField } = options;
@@ -74,6 +75,17 @@ export async function createTaskCommand(options) {
74
75
  issueFields[fieldId] = isNaN(numValue) ? rawValue : numValue;
75
76
  }
76
77
  }
78
+ if (isDryRun()) {
79
+ const changes = {
80
+ project,
81
+ summary: title,
82
+ issueType,
83
+ ...(parent !== undefined ? { parent } : {}),
84
+ ...issueFields,
85
+ };
86
+ formatDryRunResult('issue.create', project, changes);
87
+ return;
88
+ }
77
89
  try {
78
90
  const result = await createIssue({
79
91
  project,
@@ -0,0 +1,6 @@
1
+ import { buildIssueTree } from '../lib/tree-builder.js';
2
+ import { outputResult } from '../lib/json-mode.js';
3
+ export async function issueTreeCommand(issueKey, options) {
4
+ const result = await buildIssueTree(issueKey, options);
5
+ outputResult(result);
6
+ }
@@ -0,0 +1,6 @@
1
+ import { buildSprintTree } from '../lib/tree-builder.js';
2
+ import { outputResult } from '../lib/json-mode.js';
3
+ export async function sprintTreeCommand(sprintId, options) {
4
+ const result = await buildSprintTree(sprintId, options);
5
+ outputResult(result);
6
+ }
@@ -5,13 +5,14 @@ import { outputResult } from '../lib/json-mode.js';
5
5
  import { markdownToAdf } from 'marklassian';
6
6
  import { FieldResolver } from '../lib/field-resolver.js';
7
7
  import { processMentionsInADF } from '../lib/adf-mentions.js';
8
+ import { isDryRun, formatDryRunResult } from '../lib/dry-run.js';
8
9
  export async function transitionCommand(taskId, toStatus, options) {
9
10
  // Validate mutual exclusivity of --comment and --comment-file
10
11
  if (options?.comment && options?.commentFile) {
11
12
  throw new CommandError('Cannot use both --comment and --comment-file flags simultaneously.');
12
13
  }
13
14
  // Check permissions and filters
14
- await validateIssuePermissions(taskId, 'transition');
15
+ const currentIssue = await validateIssuePermissions(taskId, 'transition');
15
16
  try {
16
17
  const transitions = await getIssueTransitions(taskId);
17
18
  const matchingTransitions = transitions.filter((t) => t.to.name.toLowerCase() === toStatus.toLowerCase());
@@ -34,6 +35,35 @@ export async function transitionCommand(taskId, toStatus, options) {
34
35
  });
35
36
  }
36
37
  const transition = matchingTransitions[0];
38
+ if (isDryRun()) {
39
+ const changes = {
40
+ status: {
41
+ from: currentIssue?.status?.name,
42
+ to: transition.to.name,
43
+ },
44
+ };
45
+ if (options?.resolution !== undefined) {
46
+ changes.resolution = { to: options.resolution };
47
+ }
48
+ if (options?.assignee !== undefined) {
49
+ changes.assignee = { to: options.assignee };
50
+ }
51
+ if (options?.fixVersion !== undefined) {
52
+ changes.fixVersions = { to: options.fixVersion };
53
+ }
54
+ if (options?.customFields && options.customFields.length > 0) {
55
+ for (const entry of options.customFields) {
56
+ const eqIdx = entry.indexOf('=');
57
+ if (eqIdx === -1)
58
+ continue;
59
+ const fieldId = entry.slice(0, eqIdx).trim();
60
+ const value = entry.slice(eqIdx + 1).trim();
61
+ changes[fieldId] = { to: value };
62
+ }
63
+ }
64
+ formatDryRunResult('issue.transition', taskId, changes);
65
+ return;
66
+ }
37
67
  // Build optional payload if any options were provided
38
68
  let payload;
39
69
  if (options && Object.keys(options).some(k => options[k] !== undefined)) {
@@ -6,6 +6,7 @@ import { CommandError } from '../lib/errors.js';
6
6
  import { validateOptions, UpdateIssueSchema, IssueKeySchema } from '../lib/validation.js';
7
7
  import { isCommandAllowed, isProjectAllowed } from '../lib/settings.js';
8
8
  import { outputResult } from '../lib/json-mode.js';
9
+ import { isDryRun, formatDryRunResult } from '../lib/dry-run.js';
9
10
  export async function updateIssueCommand(issueKey, options) {
10
11
  validateOptions(IssueKeySchema, issueKey);
11
12
  validateOptions(UpdateIssueSchema, options);
@@ -16,7 +17,47 @@ export async function updateIssueCommand(issueKey, options) {
16
17
  if (!isCommandAllowed('update-issue', projectKey)) {
17
18
  throw new CommandError(`Command 'update-issue' is not allowed for project ${projectKey}.`);
18
19
  }
19
- await validateIssuePermissions(issueKey, 'update-issue');
20
+ const currentIssue = await validateIssuePermissions(issueKey, 'update-issue');
21
+ if (isDryRun()) {
22
+ const changes = {};
23
+ if (options.priority !== undefined) {
24
+ changes.priority = { from: currentIssue?.priority, to: options.priority };
25
+ }
26
+ if (options.summary !== undefined) {
27
+ changes.summary = { from: currentIssue?.summary, to: options.summary };
28
+ }
29
+ if (options.labels !== undefined || options.clearLabels) {
30
+ const newLabels = options.clearLabels ? [] : options.labels?.split(',').map(l => l.trim()).filter(Boolean);
31
+ changes.labels = { from: currentIssue?.labels, to: newLabels };
32
+ }
33
+ if (options.component !== undefined) {
34
+ changes.components = { from: currentIssue?.components, to: options.component };
35
+ }
36
+ if (options.fixVersion !== undefined) {
37
+ changes.fixVersions = { from: currentIssue?.fixVersions, to: options.fixVersion };
38
+ }
39
+ if (options.dueDate !== undefined) {
40
+ changes.dueDate = { from: currentIssue?.duedate, to: options.dueDate };
41
+ }
42
+ if (options.assignee !== undefined) {
43
+ changes.assignee = { from: currentIssue?.assignee?.displayName, to: options.assignee };
44
+ }
45
+ if (options.description !== undefined || options.fromFile !== undefined) {
46
+ changes.description = { from: '(current)', to: '(updated)' };
47
+ }
48
+ if (options.customField && options.customField.length > 0) {
49
+ for (const cf of options.customField) {
50
+ const eqIdx = cf.indexOf('=');
51
+ if (eqIdx === -1)
52
+ continue;
53
+ const fieldId = cf.slice(0, eqIdx);
54
+ const value = cf.slice(eqIdx + 1);
55
+ changes[fieldId] = { to: value };
56
+ }
57
+ }
58
+ formatDryRunResult('issue.update', issueKey, changes);
59
+ return;
60
+ }
20
61
  const fields = {};
21
62
  if (options.priority !== undefined) {
22
63
  if (!options.priority)
@@ -0,0 +1,15 @@
1
+ import { outputResult } from './json-mode.js';
2
+ export function isDryRun() {
3
+ return process.argv.includes('--dry-run');
4
+ }
5
+ export function formatDryRunResult(command, target, changes, preview = {}) {
6
+ const result = {
7
+ dryRun: true,
8
+ command,
9
+ target,
10
+ changes,
11
+ preview,
12
+ message: 'No changes were made. Remove --dry-run to execute.',
13
+ };
14
+ outputResult(result);
15
+ }
@@ -249,13 +249,14 @@ export async function getProjectStatuses(projectIdOrKey) {
249
249
  /**
250
250
  * Search for issues using JQL query
251
251
  */
252
- export async function searchIssuesByJql(jqlQuery, maxResults) {
252
+ export async function searchIssuesByJql(jqlQuery, maxResults, extraFields) {
253
253
  const client = getJiraClient();
254
254
  const filteredJql = applyGlobalFilters(jqlQuery);
255
+ const fields = ['summary', 'status', 'assignee', 'priority', ...(extraFields ?? [])];
255
256
  const response = await client.issueSearch.searchForIssuesUsingJqlEnhancedSearch({
256
257
  jql: filteredJql,
257
258
  maxResults,
258
- fields: ['summary', 'status', 'assignee', 'priority'],
259
+ fields,
259
260
  });
260
261
  return response.issues?.map((issue) => ({
261
262
  key: issue.key || '',
@@ -269,6 +270,12 @@ export async function searchIssuesByJql(jqlQuery, maxResults) {
269
270
  priority: issue.fields?.priority ? {
270
271
  name: issue.fields.priority.name || 'Unknown',
271
272
  } : null,
273
+ issuetype: issue.fields?.issuetype ? {
274
+ name: issue.fields.issuetype.name || 'Unknown',
275
+ } : undefined,
276
+ parent: issue.fields?.parent ? {
277
+ key: issue.fields.parent.key || '',
278
+ } : undefined,
272
279
  })) || [];
273
280
  }
274
281
  /**
@@ -904,7 +911,7 @@ export async function getIssueLinks(issueIdOrKey) {
904
911
  const client = await getJiraClient();
905
912
  const issue = await client.issues.getIssue({
906
913
  issueIdOrKey,
907
- fields: ['issuelinks'],
914
+ fields: ['issuelinks', 'issuetype'],
908
915
  });
909
916
  const raw = issue.fields?.issuelinks ?? [];
910
917
  return raw.map((link) => ({
@@ -921,6 +928,9 @@ export async function getIssueLinks(issueIdOrKey) {
921
928
  key: link.inwardIssue.key,
922
929
  summary: link.inwardIssue.fields?.summary ?? '',
923
930
  status: { name: link.inwardIssue.fields?.status?.name ?? '' },
931
+ issuetype: link.inwardIssue.fields?.issuetype
932
+ ? { name: link.inwardIssue.fields.issuetype.name ?? 'Unknown' }
933
+ : undefined,
924
934
  }
925
935
  : undefined,
926
936
  outwardIssue: link.outwardIssue
@@ -929,6 +939,9 @@ export async function getIssueLinks(issueIdOrKey) {
929
939
  key: link.outwardIssue.key,
930
940
  summary: link.outwardIssue.fields?.summary ?? '',
931
941
  status: { name: link.outwardIssue.fields?.status?.name ?? '' },
942
+ issuetype: link.outwardIssue.fields?.issuetype
943
+ ? { name: link.outwardIssue.fields.issuetype.name ?? 'Unknown' }
944
+ : undefined,
932
945
  }
933
946
  : undefined,
934
947
  }));
@@ -0,0 +1,233 @@
1
+ import { getTaskWithDetails, searchIssuesByJql, getIssueLinks } from './jira-client.js';
2
+ import { getSprintIssues } from './agile-client.js';
3
+ import { applyGlobalFilters } from './settings.js';
4
+ export async function buildIssueTree(issueKey, options) {
5
+ const { depth = 3, maxNodes = 200, links = false, types } = options;
6
+ const nodes = [];
7
+ const edges = [];
8
+ const visited = new Set();
9
+ let truncated = false;
10
+ // Fetch root issue
11
+ const root = await getTaskWithDetails(issueKey);
12
+ // Add parent (epic/ancestor) if exists — derived from root.parent, no extra API call needed
13
+ if (root.parent) {
14
+ const parentKey = root.parent.key;
15
+ if (!visited.has(parentKey)) {
16
+ visited.add(parentKey);
17
+ nodes.push({
18
+ key: parentKey,
19
+ summary: root.parent.summary || '',
20
+ status: root.parent.status?.name || 'Unknown',
21
+ type: 'Epic',
22
+ priority: null,
23
+ assignee: null,
24
+ });
25
+ edges.push({ from: parentKey, to: root.key, relation: 'hierarchy' });
26
+ }
27
+ }
28
+ // Add root node
29
+ if (!visited.has(root.key)) {
30
+ if (nodes.length < maxNodes) {
31
+ visited.add(root.key);
32
+ nodes.push({
33
+ key: root.key,
34
+ summary: root.summary,
35
+ status: root.status.name,
36
+ type: root.type || 'Unknown',
37
+ priority: root.priority || null,
38
+ assignee: root.assignee?.displayName || null,
39
+ });
40
+ }
41
+ else {
42
+ truncated = true;
43
+ return { root: issueKey, nodes, edges, depth: 0, truncated: true, totalNodes: nodes.length };
44
+ }
45
+ }
46
+ // BFS traversal using batched JQL: parent in (key1, key2, ...)
47
+ let currentLevel = [root.key];
48
+ let currentDepth = 0;
49
+ while (currentLevel.length > 0 && currentDepth < depth) {
50
+ if (nodes.length >= maxNodes) {
51
+ truncated = true;
52
+ break;
53
+ }
54
+ const jql = applyGlobalFilters(`parent in (${currentLevel.join(',')})`);
55
+ const children = await searchIssuesByJql(jql, 1000, ['issuetype', 'parent']);
56
+ const nextLevel = [];
57
+ for (const child of children) {
58
+ if (visited.has(child.key))
59
+ continue;
60
+ if (nodes.length >= maxNodes) {
61
+ truncated = true;
62
+ break;
63
+ }
64
+ visited.add(child.key);
65
+ nodes.push({
66
+ key: child.key,
67
+ summary: child.summary,
68
+ status: child.status.name,
69
+ type: child.issuetype?.name || 'Unknown',
70
+ priority: child.priority?.name || null,
71
+ assignee: child.assignee?.displayName || null,
72
+ });
73
+ // Determine parent for edge: when there's one parent in currentLevel all children map to it
74
+ let parentKey;
75
+ if (currentLevel.length === 1) {
76
+ parentKey = currentLevel[0];
77
+ }
78
+ else {
79
+ parentKey = child.parent?.key;
80
+ }
81
+ if (parentKey) {
82
+ edges.push({
83
+ from: parentKey,
84
+ to: child.key,
85
+ relation: child.issuetype?.name === 'Sub-task' ? 'subtask' : 'hierarchy',
86
+ });
87
+ }
88
+ nextLevel.push(child.key);
89
+ }
90
+ if (truncated)
91
+ break;
92
+ currentLevel = nextLevel;
93
+ currentDepth++;
94
+ }
95
+ // Add linked issues as single-hop leaf nodes when --links is true
96
+ if (links) {
97
+ const issueLinks = await getIssueLinks(issueKey);
98
+ const allowedTypes = types ? types.split(',').map((t) => t.trim()) : null;
99
+ await Promise.all(issueLinks.map(async (link) => {
100
+ const linkedIssue = link.outwardIssue || link.inwardIssue;
101
+ if (!linkedIssue)
102
+ return;
103
+ const linkType = link.type.name;
104
+ if (allowedTypes && !allowedTypes.includes(linkType))
105
+ return;
106
+ if (visited.has(linkedIssue.key))
107
+ return;
108
+ if (nodes.length >= maxNodes) {
109
+ truncated = true;
110
+ return;
111
+ }
112
+ visited.add(linkedIssue.key);
113
+ nodes.push({
114
+ key: linkedIssue.key,
115
+ summary: linkedIssue.summary || '',
116
+ status: linkedIssue.status?.name || 'Unknown',
117
+ type: linkedIssue.issuetype?.name || 'Unknown',
118
+ priority: null,
119
+ assignee: null,
120
+ });
121
+ edges.push({ from: issueKey, to: linkedIssue.key, relation: linkType });
122
+ }));
123
+ }
124
+ return {
125
+ root: issueKey,
126
+ nodes,
127
+ edges,
128
+ depth: currentDepth,
129
+ truncated,
130
+ totalNodes: nodes.length,
131
+ };
132
+ }
133
+ export async function buildSprintTree(sprintId, options) {
134
+ const { depth = 3, maxNodes = 200 } = options;
135
+ const nodes = [];
136
+ const edges = [];
137
+ let truncated = false;
138
+ // Virtual sprint root node
139
+ const sprintRootKey = `sprint-${sprintId}`;
140
+ nodes.push({
141
+ key: sprintRootKey,
142
+ summary: `Sprint ${sprintId}`,
143
+ status: 'active',
144
+ type: 'sprint',
145
+ priority: null,
146
+ assignee: null,
147
+ });
148
+ // Fetch all sprint issues
149
+ const { issues } = await getSprintIssues(Number(sprintId));
150
+ if (issues.length === 0) {
151
+ return { root: sprintRootKey, nodes, edges, depth: 0, truncated: false, totalNodes: 1 };
152
+ }
153
+ // Build lookup map
154
+ const issueMap = new Map();
155
+ for (const issue of issues) {
156
+ issueMap.set(issue.key, issue);
157
+ }
158
+ const visited = new Set([sprintRootKey]);
159
+ let maxDepth = 0;
160
+ // Build parent-to-children index once to avoid O(n^2) inner loop in BFS
161
+ const parentToChildren = new Map();
162
+ for (const issue of issues) {
163
+ const parentKey = issue.fields.parent?.key;
164
+ if (parentKey && issueMap.has(parentKey)) {
165
+ const siblings = parentToChildren.get(parentKey) ?? [];
166
+ siblings.push(issue.key);
167
+ parentToChildren.set(parentKey, siblings);
168
+ }
169
+ }
170
+ // Seed BFS with top-level issues: those whose parent is absent or outside the sprint
171
+ let queue = [];
172
+ for (const issue of issues) {
173
+ const parentKey = issue.fields.parent?.key;
174
+ if (!parentKey || !issueMap.has(parentKey)) {
175
+ queue.push({ key: issue.key, d: 1 });
176
+ }
177
+ }
178
+ while (queue.length > 0) {
179
+ const next = [];
180
+ for (const { key, d } of queue) {
181
+ if (visited.has(key))
182
+ continue;
183
+ if (d > depth)
184
+ continue;
185
+ if (nodes.length >= maxNodes + 1) {
186
+ // maxNodes + 1 because sprint root is not counted against maxNodes
187
+ truncated = true;
188
+ break;
189
+ }
190
+ const issue = issueMap.get(key);
191
+ if (!issue)
192
+ continue;
193
+ visited.add(key);
194
+ const fields = issue.fields;
195
+ nodes.push({
196
+ key: issue.key,
197
+ summary: fields.summary || '',
198
+ status: fields.status?.name || 'Unknown',
199
+ type: fields.issuetype?.name || 'Unknown',
200
+ priority: fields.priority?.name || null,
201
+ assignee: fields.assignee?.displayName || null,
202
+ });
203
+ // Edge: connect to parent if it's in the visited set, otherwise to sprint root
204
+ const parentKey = fields.parent?.key;
205
+ if (parentKey && visited.has(parentKey)) {
206
+ edges.push({ from: parentKey, to: key, relation: 'hierarchy' });
207
+ }
208
+ else {
209
+ edges.push({ from: sprintRootKey, to: key, relation: 'hierarchy' });
210
+ }
211
+ if (d > maxDepth)
212
+ maxDepth = d;
213
+ // Enqueue children using the pre-built index
214
+ const childKeys = parentToChildren.get(key) ?? [];
215
+ for (const childKey of childKeys) {
216
+ if (!visited.has(childKey)) {
217
+ next.push({ key: childKey, d: d + 1 });
218
+ }
219
+ }
220
+ }
221
+ if (truncated)
222
+ break;
223
+ queue = next;
224
+ }
225
+ return {
226
+ root: sprintRootKey,
227
+ nodes,
228
+ edges,
229
+ depth: maxDepth,
230
+ truncated,
231
+ totalNodes: nodes.length,
232
+ };
233
+ }
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jira-ai",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "AI friendly Jira CLI to save context",
5
5
  "type": "module",
6
6
  "main": "dist/cli.js",