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 +37 -0
- package/dist/cli.js +52 -5
- package/dist/commands/attach.js +112 -0
- package/dist/commands/run-jql.js +26 -2
- package/dist/lib/jira-client.js +92 -5
- package/dist/lib/settings.js +10 -0
- package/dist/lib/validation.js +14 -0
- package/package.json +1 -1
- package/settings.yaml +9 -0
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
|
|
134
|
-
.description('Execute a JQL search query.
|
|
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
|
-
|
|
140
|
-
|
|
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
|
+
}
|
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
|
@@ -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'] : [],
|
|
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
|
+
}
|
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',
|
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
|
|
@@ -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
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"
|