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 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 <jql-query>')
135
- .description('Execute a JQL search query. Returns issues with key, summary, status, assignee, and priority.')
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
- if (typeof args[0] !== 'string' || args[0].trim() === '') {
141
- throw new CliError('JQL query cannot be empty');
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
  // =============================================================================
@@ -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
+ }
@@ -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
- // Parse and validate limit parameter
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(jqlQuery, maxResults);
33
+ const issues = await searchIssuesByJql(resolvedJql, maxResults);
10
34
  outputResult(issues);
11
35
  }
@@ -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
+ }
@@ -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
  }));
@@ -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 {};
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jira-ai",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "AI friendly Jira CLI to save context",
5
5
  "type": "module",
6
6
  "main": "dist/cli.js",
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"