jira-ai 1.0.1 → 1.2.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
@@ -191,6 +191,43 @@ When `authType` is set to `service_account`, jira-ai automatically:
191
191
 
192
192
  Existing configurations using standard API tokens are unaffected.
193
193
 
194
+ ## Saved Queries
195
+
196
+ Define reusable JQL queries in your `settings.yaml` under the `saved-queries` key to avoid repeating common searches.
197
+
198
+ ### Configuration
199
+
200
+ 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:
201
+
202
+ ```yaml
203
+ saved-queries:
204
+ my-open-bugs: "project = PROJ AND status = Open AND issuetype = Bug"
205
+ overdue-tasks: "project = PROJ AND duedate < now() AND status != Done"
206
+ my-assignee: "assignee = currentUser()"
207
+ ```
208
+
209
+ ### Usage
210
+
211
+ Run a saved query by name:
212
+
213
+ ```bash
214
+ jira-ai issue search --query my-open-bugs
215
+ ```
216
+
217
+ List all configured saved queries:
218
+
219
+ ```bash
220
+ jira-ai issue search --list-queries
221
+ ```
222
+
223
+ Combine with result limits:
224
+
225
+ ```bash
226
+ jira-ai issue search --query overdue-tasks --limit 10
227
+ ```
228
+
229
+ Saved queries are mutually exclusive with raw JQL — you cannot provide both a positional JQL argument and `--query` at the same time.
230
+
194
231
  ## Configuration & Restrictions
195
232
 
196
233
  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,7 @@ 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 { uploadAttachmentCommand, listAttachmentsCommand, downloadAttachmentCommand, deleteAttachmentCommand, } from './commands/attach.js';
23
24
  import { getIssueStatisticsCommand } from './commands/get-issue-statistics.js';
24
25
  import { getPersonWorklogCommand } from './commands/get-person-worklog.js';
25
26
  import { isCommandAllowed, getAllowedCommands } from './lib/settings.js';
@@ -36,7 +37,7 @@ import { checkForUpdate, formatUpdateMessage, checkForUpdateSync } from './lib/u
36
37
  import { CliError } from './types/errors.js';
37
38
  import { CommandError } from './lib/errors.js';
38
39
  import { initJsonMode, outputError } from './lib/json-mode.js';
39
- import { CreateTaskSchema, UpdateDescriptionSchema, ProjectFieldsSchema, ConfluenceAddCommentSchema, RunJqlSchema, GetPersonWorklogSchema, GetIssueStatisticsSchema, EpicListSchema, EpicCreateSchema, EpicUpdateSchema, EpicLinkSchema, EpicMaxSchema, validateOptions, IssueKeySchema, ProjectKeySchema, TimeframeSchema, BoardListSchema, BoardIssuesSchema, BoardRankSchema, SprintListSchema, SprintCreateSchema, SprintUpdateSchema, SprintIssuesSchema, SprintMoveSchema, BacklogMoveSchema, } from './lib/validation.js';
40
+ import { CreateTaskSchema, UpdateDescriptionSchema, ProjectFieldsSchema, ConfluenceAddCommentSchema, RunJqlSchema, GetPersonWorklogSchema, GetIssueStatisticsSchema, EpicListSchema, EpicCreateSchema, EpicUpdateSchema, EpicLinkSchema, EpicMaxSchema, validateOptions, IssueKeySchema, ProjectKeySchema, TimeframeSchema, BoardListSchema, BoardIssuesSchema, BoardRankSchema, SprintListSchema, SprintCreateSchema, SprintUpdateSchema, SprintIssuesSchema, SprintMoveSchema, BacklogMoveSchema, AttachUploadSchema, AttachDownloadSchema, } from './lib/validation.js';
40
41
  import { realpathSync } from 'fs';
41
42
  // Create CLI program
42
43
  const program = new Command();
@@ -130,14 +131,20 @@ issue
130
131
  .option('--custom-field <field=value>', 'Custom field in fieldId=value format (repeatable)', (val, prev) => [...(prev || []), val], [])
131
132
  .action(withPermission('issue.create', createTaskCommand, { schema: CreateTaskSchema }));
132
133
  issue
133
- .command('search <jql-query>')
134
- .description('Execute a JQL search query. Returns issues with key, summary, status, assignee, and priority.')
134
+ .command('search [jql-query]')
135
+ .description('Execute a JQL search query. Provide raw JQL or use --query to reference a saved query.')
135
136
  .option('-l, --limit <number>', 'Maximum number of results (default: 50)', '50')
137
+ .option('--query <name>', 'Use a saved query by name (mutually exclusive with positional JQL)')
138
+ .option('--list-queries', 'List all available saved queries')
136
139
  .action(withPermission('issue.search', runJqlCommand, {
137
140
  schema: RunJqlSchema,
138
141
  validateArgs: (args) => {
139
- if (typeof args[0] !== 'string' || args[0].trim() === '') {
140
- throw new CliError('JQL query cannot be empty');
142
+ const jqlQuery = args[0];
143
+ const opts = args[args.length - 2];
144
+ const hasQuery = opts && opts.query;
145
+ const hasListQueries = opts && opts.listQueries;
146
+ if (!hasQuery && !hasListQueries && (typeof jqlQuery !== 'string' || jqlQuery.trim() === '')) {
147
+ throw new CliError('JQL query cannot be empty. Provide a JQL query, use --query <name>, or use --list-queries.');
141
148
  }
142
149
  }
143
150
  }));
@@ -281,6 +288,46 @@ issueLink
281
288
  .command('types')
282
289
  .description('List all available issue link types for the Jira instance.')
283
290
  .action(withPermission('issue.link.types', listLinkTypesCommand));
291
+ // Issue attach subcommands
292
+ const issueAttach = issue
293
+ .command('attach')
294
+ .description('Manage issue attachments');
295
+ issueAttach
296
+ .command('upload <issue-key>')
297
+ .description('Upload one or more files as attachments to a Jira issue.')
298
+ .requiredOption('--file <path...>', 'File path(s) to upload (repeatable)')
299
+ .action(withPermission('issue.attach.upload', (issueKey, options) => {
300
+ return uploadAttachmentCommand(issueKey, options.file);
301
+ }, {
302
+ schema: AttachUploadSchema,
303
+ validateArgs: (args) => validateOptions(IssueKeySchema, args[0])
304
+ }));
305
+ issueAttach
306
+ .command('list <issue-key>')
307
+ .description('List all attachments for a Jira issue.')
308
+ .action(withPermission('issue.attach.list', listAttachmentsCommand, {
309
+ validateArgs: (args) => validateOptions(IssueKeySchema, args[0])
310
+ }));
311
+ issueAttach
312
+ .command('download <issue-key>')
313
+ .description('Download an attachment from a Jira issue.')
314
+ .requiredOption('--id <attachment-id>', 'Attachment ID to download')
315
+ .option('--output <path>', 'Output file path (defaults to attachment filename in current directory)')
316
+ .action(withPermission('issue.attach.download', (issueKey, options) => {
317
+ return downloadAttachmentCommand(issueKey, options.id, options.output);
318
+ }, {
319
+ schema: AttachDownloadSchema,
320
+ validateArgs: (args) => validateOptions(IssueKeySchema, args[0])
321
+ }));
322
+ issueAttach
323
+ .command('delete <issue-key>')
324
+ .description('Delete an attachment from a Jira issue.')
325
+ .requiredOption('--id <attachment-id>', 'Attachment ID to delete')
326
+ .action(withPermission('issue.attach.delete', (issueKey, options) => {
327
+ return deleteAttachmentCommand(issueKey, options.id);
328
+ }, {
329
+ validateArgs: (args) => validateOptions(IssueKeySchema, args[0])
330
+ }));
284
331
  // =============================================================================
285
332
  // PROJECT COMMANDS
286
333
  // =============================================================================
@@ -0,0 +1,112 @@
1
+ import { addIssueAttachment, getIssueAttachments, downloadAttachment, deleteAttachment, validateIssuePermissions, } from '../lib/jira-client.js';
2
+ import { CommandError } from '../lib/errors.js';
3
+ import { validateOptions, IssueKeySchema } from '../lib/validation.js';
4
+ import { outputResult } from '../lib/json-mode.js';
5
+ export async function uploadAttachmentCommand(issueKey, filePaths) {
6
+ if (!issueKey || issueKey.trim() === '') {
7
+ throw new CommandError('Issue key is required');
8
+ }
9
+ validateOptions(IssueKeySchema, issueKey);
10
+ if (!filePaths || filePaths.length === 0) {
11
+ throw new CommandError('At least one file path is required');
12
+ }
13
+ await validateIssuePermissions(issueKey, 'issue.attach.upload');
14
+ try {
15
+ const attachments = await addIssueAttachment(issueKey, filePaths);
16
+ outputResult({ success: true, issueKey, attachments });
17
+ }
18
+ catch (error) {
19
+ if (error instanceof CommandError)
20
+ throw error;
21
+ const errorMsg = error.message?.toLowerCase() || '';
22
+ const hints = [];
23
+ if (errorMsg.includes('404')) {
24
+ hints.push('Check that the issue key is correct');
25
+ }
26
+ else if (errorMsg.includes('403')) {
27
+ hints.push('You may not have permission to attach files to this issue');
28
+ }
29
+ else if (errorMsg.includes('enoent') || errorMsg.includes('no such file')) {
30
+ hints.push('Check that the file path is correct and the file exists');
31
+ }
32
+ throw new CommandError(`Failed to upload attachment: ${error.message}`, { hints });
33
+ }
34
+ }
35
+ export async function listAttachmentsCommand(issueKey) {
36
+ if (!issueKey || issueKey.trim() === '') {
37
+ throw new CommandError('Issue key is required');
38
+ }
39
+ validateOptions(IssueKeySchema, issueKey);
40
+ await validateIssuePermissions(issueKey, 'issue.attach.list');
41
+ try {
42
+ const attachments = await getIssueAttachments(issueKey);
43
+ outputResult(attachments);
44
+ }
45
+ catch (error) {
46
+ if (error instanceof CommandError)
47
+ throw error;
48
+ const errorMsg = error.message?.toLowerCase() || '';
49
+ const hints = [];
50
+ if (errorMsg.includes('404')) {
51
+ hints.push('Check that the issue key is correct');
52
+ }
53
+ else if (errorMsg.includes('403')) {
54
+ hints.push('You may not have permission to view attachments for this issue');
55
+ }
56
+ throw new CommandError(`Failed to list attachments: ${error.message}`, { hints });
57
+ }
58
+ }
59
+ export async function downloadAttachmentCommand(issueKey, attachmentId, outputPath) {
60
+ if (!issueKey || issueKey.trim() === '') {
61
+ throw new CommandError('Issue key is required');
62
+ }
63
+ validateOptions(IssueKeySchema, issueKey);
64
+ if (!attachmentId || attachmentId.trim() === '') {
65
+ throw new CommandError('Attachment ID is required');
66
+ }
67
+ await validateIssuePermissions(issueKey, 'issue.attach.download');
68
+ try {
69
+ const savedPath = await downloadAttachment(issueKey, attachmentId, outputPath);
70
+ outputResult({ success: true, attachmentId, outputPath: savedPath });
71
+ }
72
+ catch (error) {
73
+ if (error instanceof CommandError)
74
+ throw error;
75
+ const errorMsg = error.message?.toLowerCase() || '';
76
+ const hints = [];
77
+ if (errorMsg.includes('404')) {
78
+ hints.push('Check that the attachment ID is correct');
79
+ }
80
+ else if (errorMsg.includes('403')) {
81
+ hints.push('You may not have permission to download this attachment');
82
+ }
83
+ throw new CommandError(`Failed to download attachment: ${error.message}`, { hints });
84
+ }
85
+ }
86
+ export async function deleteAttachmentCommand(issueKey, attachmentId) {
87
+ if (!issueKey || issueKey.trim() === '') {
88
+ throw new CommandError('Issue key is required');
89
+ }
90
+ validateOptions(IssueKeySchema, issueKey);
91
+ if (!attachmentId || attachmentId.trim() === '') {
92
+ throw new CommandError('Attachment ID is required');
93
+ }
94
+ await validateIssuePermissions(issueKey, 'issue.attach.delete');
95
+ try {
96
+ await deleteAttachment(issueKey, attachmentId);
97
+ outputResult({ success: true, issueKey, attachmentId });
98
+ }
99
+ catch (error) {
100
+ if (error instanceof CommandError)
101
+ throw error;
102
+ const errorMsg = error.message?.toLowerCase() || '';
103
+ const hints = [];
104
+ if (errorMsg.includes('404')) {
105
+ hints.push('Check that the attachment ID is correct');
106
+ }
107
+ else if (errorMsg.includes('403')) {
108
+ hints.push('You may not have permission to delete this attachment');
109
+ }
110
+ throw new CommandError(`Failed to delete attachment: ${error.message}`, { hints });
111
+ }
112
+ }
@@ -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
  }
@@ -110,6 +110,7 @@ export async function getTaskWithDetails(taskId, options = {}) {
110
110
  'issuetype',
111
111
  'priority',
112
112
  'resolution',
113
+ 'attachment',
113
114
  ],
114
115
  });
115
116
  // Extract comments
@@ -175,11 +176,15 @@ export async function getTaskWithDetails(taskId, options = {}) {
175
176
  // Extract watchers
176
177
  const watchers = [];
177
178
  if (issue.fields.watches?.isWatching) {
178
- // If we only need to know if the current user is watching, isWatching is enough.
179
- // But the requirement might mean "any of these roles".
180
- // For now, if isWatching is true, we add current user's accountId (placeholder)
181
- // Actually, we can fetch watchers detail if needed, but Jira's getIssue returns watches info for current user.
182
179
  }
180
+ // Extract attachments
181
+ const attachments = (issue.fields.attachment || []).map((att) => ({
182
+ id: att.id,
183
+ filename: att.filename,
184
+ size: att.size,
185
+ author: att.author?.displayName || 'Unknown',
186
+ created: att.created,
187
+ }));
183
188
  return {
184
189
  id: issue.id || '',
185
190
  key: issue.key || '',
@@ -208,7 +213,8 @@ export async function getTaskWithDetails(taskId, options = {}) {
208
213
  parent,
209
214
  subtasks,
210
215
  history,
211
- watchers: issue.fields.watches?.isWatching ? ['CURRENT_USER'] : [], // Simple flag for now
216
+ watchers: issue.fields.watches?.isWatching ? ['CURRENT_USER'] : [],
217
+ attachments,
212
218
  };
213
219
  }
214
220
  /**
@@ -951,3 +957,84 @@ export async function getAvailableLinkTypes() {
951
957
  }));
952
958
  }
953
959
  export const getEpics = listEpics;
960
+ /**
961
+ * Upload one or more files as attachments to a Jira issue
962
+ */
963
+ export async function addIssueAttachment(issueKey, filePaths) {
964
+ const client = getJiraClient();
965
+ const fs = await import('fs');
966
+ const path = await import('path');
967
+ const attachments = filePaths.map((filePath) => ({
968
+ filename: path.basename(filePath),
969
+ file: fs.readFileSync(filePath),
970
+ }));
971
+ const result = await client.issueAttachments.addAttachment({
972
+ issueIdOrKey: issueKey,
973
+ attachment: attachments,
974
+ });
975
+ const items = Array.isArray(result) ? result : [result];
976
+ return items.map((att) => ({
977
+ id: att.id || '',
978
+ filename: att.filename || '',
979
+ mimeType: att.mimeType || '',
980
+ size: att.size || 0,
981
+ created: att.created || '',
982
+ author: {
983
+ displayName: att.author?.displayName || '',
984
+ emailAddress: att.author?.emailAddress,
985
+ },
986
+ content: att.content || '',
987
+ }));
988
+ }
989
+ /**
990
+ * List all attachments for a Jira issue
991
+ */
992
+ export async function getIssueAttachments(issueKey) {
993
+ const client = getJiraClient();
994
+ const issue = await client.issues.getIssue({
995
+ issueIdOrKey: issueKey,
996
+ fields: ['attachment'],
997
+ });
998
+ const attachments = issue.fields?.attachment || [];
999
+ return attachments.map((att) => ({
1000
+ id: att.id || '',
1001
+ filename: att.filename || '',
1002
+ mimeType: att.mimeType || '',
1003
+ size: att.size || 0,
1004
+ created: att.created || '',
1005
+ author: {
1006
+ displayName: att.author?.displayName || '',
1007
+ emailAddress: att.author?.emailAddress,
1008
+ },
1009
+ content: att.content || '',
1010
+ }));
1011
+ }
1012
+ /**
1013
+ * Download an attachment by ID, saving to outputPath (or current dir if not specified)
1014
+ * Returns the path where the file was saved
1015
+ */
1016
+ export async function downloadAttachment(issueKey, attachmentId, outputPath) {
1017
+ const client = getJiraClient();
1018
+ const fs = await import('fs');
1019
+ const path = await import('path');
1020
+ // Get attachment metadata to find the filename
1021
+ const issue = await client.issues.getIssue({
1022
+ issueIdOrKey: issueKey,
1023
+ fields: ['attachment'],
1024
+ });
1025
+ const attachments = issue.fields?.attachment || [];
1026
+ const meta = attachments.find((a) => a.id === attachmentId);
1027
+ const rawFilename = meta?.filename || attachmentId;
1028
+ const safeFilename = path.basename(rawFilename).replace(/\.\./g, '');
1029
+ const destPath = outputPath || path.join(process.cwd(), safeFilename);
1030
+ const content = await client.issueAttachments.getAttachmentContent(attachmentId);
1031
+ fs.writeFileSync(destPath, Buffer.from(content));
1032
+ return destPath;
1033
+ }
1034
+ /**
1035
+ * Delete an attachment by ID
1036
+ */
1037
+ export async function deleteAttachment(issueKey, attachmentId) {
1038
+ const client = getJiraClient();
1039
+ await client.issueAttachments.removeAttachment(attachmentId);
1040
+ }
@@ -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',
@@ -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
@@ -240,3 +244,13 @@ export const SprintMoveSchema = z.object({
240
244
  export const BacklogMoveSchema = z.object({
241
245
  issues: z.array(z.string().trim().min(1)).min(1, 'At least one issue key is required'),
242
246
  });
247
+ // =============================================================================
248
+ // ATTACHMENT VALIDATION SCHEMAS
249
+ // =============================================================================
250
+ export const AttachUploadSchema = z.object({
251
+ file: z.array(z.string().trim().min(1, 'File path is required')).min(1, 'At least one file path is required'),
252
+ });
253
+ export const AttachDownloadSchema = z.object({
254
+ id: z.string().trim().min(1, 'Attachment ID is required'),
255
+ output: z.string().trim().optional(),
256
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jira-ai",
3
- "version": "1.0.1",
3
+ "version": "1.2.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"