jira-ai 1.2.0 → 1.3.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 +34 -27
- package/dist/cli.js +24 -0
- package/dist/commands/issue-tree.js +6 -0
- package/dist/commands/sprint-tree.js +6 -0
- package/dist/lib/jira-client.js +16 -3
- package/dist/lib/tree-builder.js +233 -0
- package/dist/lib/tree-types.js +1 -0
- package/package.json +1 -1
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';
|
|
@@ -328,6 +330,19 @@ issueAttach
|
|
|
328
330
|
}, {
|
|
329
331
|
validateArgs: (args) => validateOptions(IssueKeySchema, args[0])
|
|
330
332
|
}));
|
|
333
|
+
issue
|
|
334
|
+
.command('tree <issue-key>')
|
|
335
|
+
.description('Show the full issue hierarchy tree rooted at an issue, including subtasks and optionally linked issues.')
|
|
336
|
+
.option('--links', 'Include linked issues as single-hop leaf nodes')
|
|
337
|
+
.option('--depth <number>', 'Max traversal depth (default: 3)', '3')
|
|
338
|
+
.option('--max-nodes <number>', 'Max nodes in tree (default: 200)', '200')
|
|
339
|
+
.option('--types <types>', 'Comma-separated link types to include (e.g. Blocks,Relates)')
|
|
340
|
+
.action(withPermission('issue.tree', (issueKey, options) => issueTreeCommand(issueKey, {
|
|
341
|
+
links: options.links || false,
|
|
342
|
+
depth: options.depth ? Number(options.depth) : undefined,
|
|
343
|
+
maxNodes: options.maxNodes ? Number(options.maxNodes) : undefined,
|
|
344
|
+
types: options.types,
|
|
345
|
+
}), { validateArgs: (args) => validateOptions(IssueKeySchema, args[0]) }));
|
|
331
346
|
// =============================================================================
|
|
332
347
|
// PROJECT COMMANDS
|
|
333
348
|
// =============================================================================
|
|
@@ -595,6 +610,15 @@ sprint
|
|
|
595
610
|
.option('--before <key>', 'Rank before this issue')
|
|
596
611
|
.option('--after <key>', 'Rank after this issue')
|
|
597
612
|
.action(withPermission('sprint.move', (sprintId, options) => sprintMoveCommand(Number(sprintId), options), { schema: SprintMoveSchema }));
|
|
613
|
+
sprint
|
|
614
|
+
.command('tree <sprint-id>')
|
|
615
|
+
.description('Show the full issue hierarchy tree for a sprint, grouped by epics.')
|
|
616
|
+
.option('--depth <number>', 'Max traversal depth (default: 3)', '3')
|
|
617
|
+
.option('--max-nodes <number>', 'Max nodes in tree (default: 200)', '200')
|
|
618
|
+
.action(withPermission('sprint.tree', (sprintId, options) => sprintTreeCommand(sprintId, {
|
|
619
|
+
depth: options.depth ? Number(options.depth) : undefined,
|
|
620
|
+
maxNodes: options.maxNodes ? Number(options.maxNodes) : undefined,
|
|
621
|
+
})));
|
|
598
622
|
// =============================================================================
|
|
599
623
|
// BACKLOG COMMANDS
|
|
600
624
|
// =============================================================================
|
package/dist/lib/jira-client.js
CHANGED
|
@@ -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
|
|
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 {};
|