jira-ai 1.1.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 +71 -27
- package/dist/cli.js +34 -4
- package/dist/commands/issue-tree.js +6 -0
- package/dist/commands/run-jql.js +26 -2
- package/dist/commands/sprint-tree.js +6 -0
- package/dist/lib/jira-client.js +16 -3
- package/dist/lib/settings.js +10 -0
- package/dist/lib/tree-builder.js +233 -0
- package/dist/lib/tree-types.js +1 -0
- package/dist/lib/validation.js +4 -0
- package/package.json +1 -1
- package/settings.yaml +9 -0
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:
|
|
@@ -191,6 +198,43 @@ When `authType` is set to `service_account`, jira-ai automatically:
|
|
|
191
198
|
|
|
192
199
|
Existing configurations using standard API tokens are unaffected.
|
|
193
200
|
|
|
201
|
+
## Saved Queries
|
|
202
|
+
|
|
203
|
+
Define reusable JQL queries in your `settings.yaml` under the `saved-queries` key to avoid repeating common searches.
|
|
204
|
+
|
|
205
|
+
### Configuration
|
|
206
|
+
|
|
207
|
+
Add a `saved-queries` map to your `settings.yaml` — each key is a query name (lowercase alphanumeric with hyphens) and each value is a JQL string:
|
|
208
|
+
|
|
209
|
+
```yaml
|
|
210
|
+
saved-queries:
|
|
211
|
+
my-open-bugs: "project = PROJ AND status = Open AND issuetype = Bug"
|
|
212
|
+
overdue-tasks: "project = PROJ AND duedate < now() AND status != Done"
|
|
213
|
+
my-assignee: "assignee = currentUser()"
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
### Usage
|
|
217
|
+
|
|
218
|
+
Run a saved query by name:
|
|
219
|
+
|
|
220
|
+
```bash
|
|
221
|
+
jira-ai issue search --query my-open-bugs
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
List all configured saved queries:
|
|
225
|
+
|
|
226
|
+
```bash
|
|
227
|
+
jira-ai issue search --list-queries
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Combine with result limits:
|
|
231
|
+
|
|
232
|
+
```bash
|
|
233
|
+
jira-ai issue search --query overdue-tasks --limit 10
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Saved queries are mutually exclusive with raw JQL — you cannot provide both a positional JQL argument and `--query` at the same time.
|
|
237
|
+
|
|
194
238
|
## Configuration & Restrictions
|
|
195
239
|
|
|
196
240
|
Tool allows you to have very complex configutations of what Projects/Jira commands/Issue types you would have acess to thought the tool.
|
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';
|
|
@@ -131,14 +133,20 @@ issue
|
|
|
131
133
|
.option('--custom-field <field=value>', 'Custom field in fieldId=value format (repeatable)', (val, prev) => [...(prev || []), val], [])
|
|
132
134
|
.action(withPermission('issue.create', createTaskCommand, { schema: CreateTaskSchema }));
|
|
133
135
|
issue
|
|
134
|
-
.command('search
|
|
135
|
-
.description('Execute a JQL search query.
|
|
136
|
+
.command('search [jql-query]')
|
|
137
|
+
.description('Execute a JQL search query. Provide raw JQL or use --query to reference a saved query.')
|
|
136
138
|
.option('-l, --limit <number>', 'Maximum number of results (default: 50)', '50')
|
|
139
|
+
.option('--query <name>', 'Use a saved query by name (mutually exclusive with positional JQL)')
|
|
140
|
+
.option('--list-queries', 'List all available saved queries')
|
|
137
141
|
.action(withPermission('issue.search', runJqlCommand, {
|
|
138
142
|
schema: RunJqlSchema,
|
|
139
143
|
validateArgs: (args) => {
|
|
140
|
-
|
|
141
|
-
|
|
144
|
+
const jqlQuery = args[0];
|
|
145
|
+
const opts = args[args.length - 2];
|
|
146
|
+
const hasQuery = opts && opts.query;
|
|
147
|
+
const hasListQueries = opts && opts.listQueries;
|
|
148
|
+
if (!hasQuery && !hasListQueries && (typeof jqlQuery !== 'string' || jqlQuery.trim() === '')) {
|
|
149
|
+
throw new CliError('JQL query cannot be empty. Provide a JQL query, use --query <name>, or use --list-queries.');
|
|
142
150
|
}
|
|
143
151
|
}
|
|
144
152
|
}));
|
|
@@ -322,6 +330,19 @@ issueAttach
|
|
|
322
330
|
}, {
|
|
323
331
|
validateArgs: (args) => validateOptions(IssueKeySchema, args[0])
|
|
324
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]) }));
|
|
325
346
|
// =============================================================================
|
|
326
347
|
// PROJECT COMMANDS
|
|
327
348
|
// =============================================================================
|
|
@@ -589,6 +610,15 @@ sprint
|
|
|
589
610
|
.option('--before <key>', 'Rank before this issue')
|
|
590
611
|
.option('--after <key>', 'Rank after this issue')
|
|
591
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
|
+
})));
|
|
592
622
|
// =============================================================================
|
|
593
623
|
// BACKLOG COMMANDS
|
|
594
624
|
// =============================================================================
|
package/dist/commands/run-jql.js
CHANGED
|
@@ -1,11 +1,35 @@
|
|
|
1
1
|
import { searchIssuesByJql } from '../lib/jira-client.js';
|
|
2
2
|
import { outputResult } from '../lib/json-mode.js';
|
|
3
|
+
import { getSavedQuery, listSavedQueries } from '../lib/settings.js';
|
|
4
|
+
import { CliError } from '../types/errors.js';
|
|
3
5
|
export async function runJqlCommand(jqlQuery, options) {
|
|
4
|
-
//
|
|
6
|
+
// Handle --list-queries
|
|
7
|
+
if (options.listQueries) {
|
|
8
|
+
const queries = listSavedQueries();
|
|
9
|
+
outputResult({ queries });
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
// Mutual exclusion: can't have both positional JQL and --query
|
|
13
|
+
if (jqlQuery && jqlQuery.trim() !== '' && options.query) {
|
|
14
|
+
throw new CliError('Cannot specify both JQL query and --query. Use one or the other.');
|
|
15
|
+
}
|
|
16
|
+
let resolvedJql;
|
|
17
|
+
if (options.query) {
|
|
18
|
+
const savedJql = getSavedQuery(options.query);
|
|
19
|
+
if (savedJql === undefined) {
|
|
20
|
+
const available = listSavedQueries().map((q) => q.name).join(', ');
|
|
21
|
+
const availableMsg = available ? available : '(none)';
|
|
22
|
+
throw new CliError(`Saved query '${options.query}' not found. Available: ${availableMsg}`);
|
|
23
|
+
}
|
|
24
|
+
resolvedJql = savedJql;
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
resolvedJql = jqlQuery;
|
|
28
|
+
}
|
|
5
29
|
let maxResults = options.limit || 50;
|
|
6
30
|
if (maxResults > 1000) {
|
|
7
31
|
maxResults = 1000;
|
|
8
32
|
}
|
|
9
|
-
const issues = await searchIssuesByJql(
|
|
33
|
+
const issues = await searchIssuesByJql(resolvedJql, maxResults);
|
|
10
34
|
outputResult(issues);
|
|
11
35
|
}
|
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
|
}));
|
package/dist/lib/settings.js
CHANGED
|
@@ -4,6 +4,16 @@ import os from 'os';
|
|
|
4
4
|
import yaml from 'js-yaml';
|
|
5
5
|
import { CliError } from '../types/errors.js';
|
|
6
6
|
import { SettingsSchema } from './validation.js';
|
|
7
|
+
export function getSavedQuery(name) {
|
|
8
|
+
const settings = loadSettings();
|
|
9
|
+
return settings.savedQueries?.[name];
|
|
10
|
+
}
|
|
11
|
+
export function listSavedQueries() {
|
|
12
|
+
const settings = loadSettings();
|
|
13
|
+
if (!settings.savedQueries)
|
|
14
|
+
return [];
|
|
15
|
+
return Object.entries(settings.savedQueries).map(([name, jql]) => ({ name, jql }));
|
|
16
|
+
}
|
|
7
17
|
// Mapping from old flat command names to new hierarchical paths
|
|
8
18
|
export const LEGACY_COMMAND_MAP = {
|
|
9
19
|
'me': 'user.me',
|
|
@@ -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/dist/lib/validation.js
CHANGED
|
@@ -113,6 +113,8 @@ export const UpdateDescriptionSchema = z.object({
|
|
|
113
113
|
});
|
|
114
114
|
export const RunJqlSchema = z.object({
|
|
115
115
|
limit: NumericStringSchema.optional(),
|
|
116
|
+
query: z.string().optional(),
|
|
117
|
+
listQueries: z.boolean().optional(),
|
|
116
118
|
});
|
|
117
119
|
export const TimeframeSchema = z.string().regex(/^\d+d$/, 'Timeframe must be in format like "7d" or "30d"');
|
|
118
120
|
export const GetPersonWorklogSchema = z.object({
|
|
@@ -157,10 +159,12 @@ export const OrganizationSettingsSchema = z.object({
|
|
|
157
159
|
'allowed-commands': z.array(z.string()).nullish().transform(val => val || DEFAULT_ALLOWED_COMMANDS),
|
|
158
160
|
'allowed-confluence-spaces': z.array(z.string()).nullish().transform(val => val || ['all']),
|
|
159
161
|
});
|
|
162
|
+
export const SavedQueryNameSchema = z.string().regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/, 'Query name must be lowercase alphanumeric with hyphens (e.g., "my-query"), cannot start or end with a hyphen');
|
|
160
163
|
export const SettingsSchema = z.object({
|
|
161
164
|
defaults: OrganizationSettingsSchema.optional(),
|
|
162
165
|
projects: z.array(ProjectSettingSchema).optional(),
|
|
163
166
|
commands: z.array(z.string()).optional(),
|
|
167
|
+
savedQueries: z.record(SavedQueryNameSchema, z.string().min(1)).optional(),
|
|
164
168
|
});
|
|
165
169
|
// =============================================================================
|
|
166
170
|
// EPIC VALIDATION SCHEMAS
|
package/package.json
CHANGED
package/settings.yaml
CHANGED
|
@@ -14,3 +14,12 @@ commands:
|
|
|
14
14
|
- run-jql
|
|
15
15
|
- list-issue-types
|
|
16
16
|
- project-statuses
|
|
17
|
+
|
|
18
|
+
# Saved Queries: Define reusable JQL queries by name.
|
|
19
|
+
# Keys must be lowercase alphanumeric with optional hyphens (e.g., "my-query").
|
|
20
|
+
# Values are JQL query strings. Saved queries are used with:
|
|
21
|
+
# jira-ai issue search --query <name>
|
|
22
|
+
# jira-ai issue search --list-queries
|
|
23
|
+
# saved-queries:
|
|
24
|
+
# my-open-bugs: "project = PROJ AND status = Open AND issuetype = Bug"
|
|
25
|
+
# overdue-tasks: "project = PROJ AND duedate < now() AND status != Done"
|