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 +34 -27
- package/dist/cli.js +25 -0
- package/dist/commands/create-task.js +12 -0
- package/dist/commands/issue-tree.js +6 -0
- package/dist/commands/sprint-tree.js +6 -0
- package/dist/commands/transition.js +31 -1
- package/dist/commands/update-issue.js +42 -1
- package/dist/lib/dry-run.js +15 -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';
|
|
@@ -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,
|
|
@@ -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
|
+
}
|
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 {};
|