@pixlcore/xycli 1.0.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 +543 -0
- package/docs/help.md +2587 -0
- package/docs/screenshots.md +219 -0
- package/docs/sync.md +920 -0
- package/docs/tutorial.md +204 -0
- package/index.js +444 -0
- package/lib/alerts.js +735 -0
- package/lib/apikey.js +425 -0
- package/lib/buckets.js +607 -0
- package/lib/categories.js +323 -0
- package/lib/channels.js +290 -0
- package/lib/config.js +86 -0
- package/lib/dashboard.js +237 -0
- package/lib/doc.js +131 -0
- package/lib/events.js +1314 -0
- package/lib/groups.js +1038 -0
- package/lib/jobs.js +1254 -0
- package/lib/log.js +125 -0
- package/lib/marketplace.js +386 -0
- package/lib/monitors.js +412 -0
- package/lib/plugins.js +491 -0
- package/lib/secrets.js +441 -0
- package/lib/servers.js +1502 -0
- package/lib/snapshots.js +498 -0
- package/lib/sync.js +963 -0
- package/lib/system.js +612 -0
- package/lib/tags.js +250 -0
- package/lib/tickets.js +681 -0
- package/lib/transfer.js +447 -0
- package/lib/utils.js +2745 -0
- package/lib/webhook.js +414 -0
- package/logo.webp +0 -0
- package/package.json +38 -0
- package/test/README.md +97 -0
- package/test/api-validation.test.js +170 -0
- package/test/categories.test.js +246 -0
- package/test/channels.test.js +209 -0
- package/test/config.test.js +96 -0
- package/test/doc.test.js +84 -0
- package/test/groups.test.js +211 -0
- package/test/help.test.js +26 -0
- package/test/helpers/common.js +127 -0
- package/test/helpers/pagination-preload.cjs +18 -0
- package/test/log.test.js +103 -0
- package/test/markdown.test.js +61 -0
- package/test/marketplace.test.js +229 -0
- package/test/monitors.test.js +227 -0
- package/test/pagination.test.js +138 -0
- package/test/plugins.test.js +351 -0
- package/test/secrets.test.js +271 -0
- package/test/servers.test.js +303 -0
- package/test/snapshots.test.js +167 -0
- package/test/sync-unit.test.js +446 -0
- package/test/sync.test.js +542 -0
- package/test/system.test.js +94 -0
- package/test/tags.test.js +230 -0
- package/test/tickets.test.js +261 -0
- package/test/toast.test.js +73 -0
- package/test/transfer-apikey.test.js +96 -0
- package/test/transfer-workflow.test.js +122 -0
- package/test/transfer.test.js +314 -0
- package/test/webhook.test.js +332 -0
- package/test.js +49 -0
package/lib/tickets.js
ADDED
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
// Ticket Management Layer
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const Path = require('path');
|
|
5
|
+
const cli = require('pixl-cli');
|
|
6
|
+
const Tools = cli.Tools;
|
|
7
|
+
|
|
8
|
+
const TICKET_TYPES = ['issue', 'feature', 'release', 'change', 'maintenance', 'question', 'other'];
|
|
9
|
+
const TICKET_STATUSES = ['draft', 'open', 'closed'];
|
|
10
|
+
const TICKET_LIST_FIELDS = ['assignees', 'cc', 'notify', 'tags'];
|
|
11
|
+
const TICKET_EDIT_FIELDS = [
|
|
12
|
+
'subject', 'body', 'type', 'status', 'category', 'server',
|
|
13
|
+
'assignees', 'assign', 'cc', 'notify', 'due', 'tags', 'tag'
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
module.exports = {
|
|
17
|
+
|
|
18
|
+
async cmd_tickets() {
|
|
19
|
+
// Tickets live in the indexed database rather than the global memory lists.
|
|
20
|
+
await this.cmd_search_tickets();
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
async cmd_ticket() {
|
|
24
|
+
// Route Ticket operations, or treat a bare number / ID as a detail lookup.
|
|
25
|
+
var cmd = this.args.other.shift();
|
|
26
|
+
if (!cmd) return this.dieUsage('ticket');
|
|
27
|
+
|
|
28
|
+
switch (cmd) {
|
|
29
|
+
case 'list':
|
|
30
|
+
case 'search': await this.cmd_search_tickets(); break;
|
|
31
|
+
case 'get': await this.cmd_get_ticket(); break;
|
|
32
|
+
case 'create': await this.cmd_create_ticket(); break;
|
|
33
|
+
case 'update': await this.cmd_update_ticket(); break;
|
|
34
|
+
case 'comment': await this.cmd_add_ticket_comment(); break;
|
|
35
|
+
case 'upload': await this.cmd_upload_ticket_files(); break;
|
|
36
|
+
case 'download': await this.cmd_download_ticket_file(); break;
|
|
37
|
+
case 'delete': await this.cmd_delete_ticket(); break;
|
|
38
|
+
|
|
39
|
+
default:
|
|
40
|
+
this.args.other.unshift(cmd);
|
|
41
|
+
if ('comment' in this.args) await this.cmd_add_ticket_comment();
|
|
42
|
+
else await this.cmd_get_ticket();
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
async cmd_search_tickets() {
|
|
48
|
+
// Convert friendly named options into the same Unbase query language used
|
|
49
|
+
// by the web UI, while preserving an optional raw positional query.
|
|
50
|
+
this.prepSearchArgs();
|
|
51
|
+
await this.getMultiple();
|
|
52
|
+
|
|
53
|
+
var query_parts = [];
|
|
54
|
+
if (this.args.other.length) query_parts.push(this.args.other.join(' '));
|
|
55
|
+
delete this.args.other;
|
|
56
|
+
|
|
57
|
+
if ('query' in this.args) {
|
|
58
|
+
if ((typeof(this.args.query) != 'string') || !this.args.query.trim()) this.die("Ticket query must be a non-empty string.");
|
|
59
|
+
query_parts.push(this.args.query.trim());
|
|
60
|
+
delete this.args.query;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
var sort_by = this.args.sort_by || '_id';
|
|
64
|
+
var sort_dir = this.parseTicketSortDirection(this.args.sort_dir);
|
|
65
|
+
delete this.args.sort_by;
|
|
66
|
+
delete this.args.sort_dir;
|
|
67
|
+
|
|
68
|
+
var aliases = {
|
|
69
|
+
assignee: 'assignees',
|
|
70
|
+
assign: 'assignees',
|
|
71
|
+
tag: 'tags',
|
|
72
|
+
number: 'num',
|
|
73
|
+
date: 'created'
|
|
74
|
+
};
|
|
75
|
+
var searchable = [
|
|
76
|
+
'subject', 'body', 'changes', 'status', 'username', 'assignees',
|
|
77
|
+
'cc', 'type', 'category', 'tags', 'created', 'due', 'num'
|
|
78
|
+
];
|
|
79
|
+
if (!searchable.concat(['_id']).includes(sort_by)) this.die("Unsupported Ticket sort field: " + sort_by);
|
|
80
|
+
|
|
81
|
+
Object.keys(this.args).forEach( key => {
|
|
82
|
+
var field = aliases[key] || key;
|
|
83
|
+
if (!searchable.includes(field)) {
|
|
84
|
+
var suggestion = this.findClosestString(key, searchable.concat(Object.keys(aliases)));
|
|
85
|
+
this.die("Unsupported Ticket search option: --" + key + (suggestion ? '. Did you mean "--' + suggestion + '"?' : ''));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
var values = this.parseTicketList(this.args[key], key);
|
|
89
|
+
if (!values.length) this.die("Ticket search option cannot be empty: --" + key);
|
|
90
|
+
if (field == 'category') values = values.map( value => this.resolveTicketCategory(value) );
|
|
91
|
+
if (field == 'tags') values = values.map( value => this.resolveTicketTag(value) );
|
|
92
|
+
if (field == 'status') values.forEach( value => this.validateTicketChoice(value, 'status', this.getTicketStatuses()) );
|
|
93
|
+
if (field == 'type') values.forEach( value => this.validateTicketChoice(value, 'type', this.getTicketTypes()) );
|
|
94
|
+
if ((field == 'created') || (field == 'due')) {
|
|
95
|
+
values = values.map( value => this.getDateRangeQuery(field, value) || (field + ':' + value) );
|
|
96
|
+
query_parts.push(values.join(' '));
|
|
97
|
+
}
|
|
98
|
+
else query_parts.push(field + ':' + values.map( value => this.quoteTicketQueryValue(value) ).join('|'));
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
var query = query_parts.join(' ').trim() || '*';
|
|
102
|
+
if (this.format.match(/json/)) {
|
|
103
|
+
var result = await this.searchTickets(query, sort_by, sort_dir);
|
|
104
|
+
return this.jsonOutput(result.rows);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
await this.printTickets({
|
|
108
|
+
title: query == '*' ? 'All Tickets' : 'Ticket Search Results',
|
|
109
|
+
query: query,
|
|
110
|
+
sort_by: sort_by,
|
|
111
|
+
sort_dir: sort_dir
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
this.printSuggestedCommands({
|
|
115
|
+
"View Ticket details": "xy ticket TICKET_NUMBER_OR_ID",
|
|
116
|
+
"Create a Ticket": 'xy ticket create --subject "My Ticket" --status draft',
|
|
117
|
+
"Show open Tickets": "xy tickets --status open",
|
|
118
|
+
"Show assigned Tickets": "xy tickets --assignee USERNAME",
|
|
119
|
+
"Search Ticket text": 'xy tickets "search words"'
|
|
120
|
+
});
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
async cmd_get_ticket() {
|
|
124
|
+
// Pagination applies to the associated completed-job section.
|
|
125
|
+
this.prepSearchArgs();
|
|
126
|
+
var selector = this.consumeTicketSelector();
|
|
127
|
+
if (Tools.numKeys(this.args)) return this.die("Unsupported Ticket get option: --" + Tools.firstKey(this.args));
|
|
128
|
+
|
|
129
|
+
await this.getMultiple();
|
|
130
|
+
var ticket = await this.fetchTicket(selector);
|
|
131
|
+
if (this.format.match(/json/)) return this.jsonOutput(ticket);
|
|
132
|
+
|
|
133
|
+
var comments = (ticket.changes || []).filter( change => change.type == 'comment' );
|
|
134
|
+
this.printBoxList({
|
|
135
|
+
title: 'Ticket #' + ticket.num,
|
|
136
|
+
rows: [
|
|
137
|
+
[ 'Number', this.color('theme').bold('#' + ticket.num) ],
|
|
138
|
+
[ 'Ticket ID', gray(ticket.id) ],
|
|
139
|
+
[ 'Subject', this.color('theme').bold(ticket.subject) ],
|
|
140
|
+
[ 'Status', this.getNiceTicketStatus(ticket.status) ],
|
|
141
|
+
[ 'Type', this.getNiceTicketType(ticket.type) ],
|
|
142
|
+
[ 'Category', this.getNiceCategory(ticket.category) ],
|
|
143
|
+
[ 'Server', this.getNiceServer(ticket.server) ],
|
|
144
|
+
[ 'Assignees', this.getNiceTicketList(ticket.assignees) ],
|
|
145
|
+
[ 'Cc', this.getNiceTicketList(ticket.cc) ],
|
|
146
|
+
[ 'Notify', this.getNiceTicketList(ticket.notify) ],
|
|
147
|
+
[ 'Due', ticket.due ? this.getNiceDateTime(ticket.due, false, true) : gray('(None)') ],
|
|
148
|
+
[ 'Tags', this.getNiceTagList(ticket.tags || []) ],
|
|
149
|
+
[ 'Events', Tools.commify((ticket.events || []).length) ],
|
|
150
|
+
[ 'Files', Tools.commify((ticket.files || []).length) ],
|
|
151
|
+
[ 'Comments', Tools.commify(comments.length) ],
|
|
152
|
+
[ 'Author', ticket.username || gray('(Unknown)') ],
|
|
153
|
+
[ 'Created', this.getNiceDateTime(ticket.created, true, true) ],
|
|
154
|
+
[ 'Modified', this.getNiceDateTime(ticket.modified, true, true) ]
|
|
155
|
+
]
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
this.printTicketBody(ticket.body);
|
|
159
|
+
this.printTicketComments(comments);
|
|
160
|
+
|
|
161
|
+
if (ticket.events && ticket.events.length) this.printTicketEvents(ticket.events || []);
|
|
162
|
+
if (ticket.files && ticket.files.length) this.printTicketFiles(ticket.files || []);
|
|
163
|
+
|
|
164
|
+
// Match the UI by showing active jobs first and completed jobs below.
|
|
165
|
+
var active_jobs = Object.values(this.activeJobs).filter( job => (job.tickets || []).includes(ticket.id) );
|
|
166
|
+
if (active_jobs.length) this.printActiveJobs({ title: 'Active Ticket Jobs', rows: active_jobs });
|
|
167
|
+
await this.printCompletedJobs({
|
|
168
|
+
title: 'Ticket Jobs',
|
|
169
|
+
query: 'tickets:' + ticket.id,
|
|
170
|
+
hide_empty: true
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
this.printSuggestedCommands({
|
|
174
|
+
"Add a comment": `xy ticket ${ticket.num} --comment "My comment"`,
|
|
175
|
+
"Update Ticket": `xy ticket update ${ticket.num} --subject "New subject"`,
|
|
176
|
+
"Assign a user": `xy ticket update ${ticket.num} --assign USERNAME`,
|
|
177
|
+
"Add a Tag": `xy ticket update ${ticket.num} --tag TAG_ID`,
|
|
178
|
+
"Upload a file": `xy ticket upload ${ticket.num} --file FILE`,
|
|
179
|
+
"Download a file": ticket.files && ticket.files.length ? `xy ticket download ${ticket.num} ${ticket.files[0].id}` : '',
|
|
180
|
+
"Close Ticket": ticket.status != 'closed' ? `xy ticket update ${ticket.num} close` : '',
|
|
181
|
+
"Reopen Ticket": ticket.status == 'closed' ? `xy ticket update ${ticket.num} open` : '',
|
|
182
|
+
"Delete Ticket": `xy ticket delete ${ticket.num}`
|
|
183
|
+
});
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
async cmd_create_ticket() {
|
|
187
|
+
await this.getMultiple();
|
|
188
|
+
if (this.args.other.length) return this.dieUsage('ticket create');
|
|
189
|
+
delete this.args.other;
|
|
190
|
+
|
|
191
|
+
var files = this.consumeTicketFileArgs();
|
|
192
|
+
this.validateTicketUploadFiles(files);
|
|
193
|
+
var params = this.prepareTicketParams({
|
|
194
|
+
status: 'open',
|
|
195
|
+
type: 'change',
|
|
196
|
+
body: '',
|
|
197
|
+
category: '',
|
|
198
|
+
server: '',
|
|
199
|
+
assignees: [],
|
|
200
|
+
cc: [],
|
|
201
|
+
notify: [],
|
|
202
|
+
due: 0,
|
|
203
|
+
tags: []
|
|
204
|
+
}, this.args, true);
|
|
205
|
+
if (!params.subject) return this.dieUsage('ticket create');
|
|
206
|
+
|
|
207
|
+
var data = await this.callStandardAPI('createTicket', params, { text: 'Creating Ticket...' });
|
|
208
|
+
if (this.dry) {
|
|
209
|
+
if (files.length) this.jsonOutput({ files: files, save: true });
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (files.length) {
|
|
214
|
+
var upload = await this.callStandardAPI('uploadUserTicketFiles', {
|
|
215
|
+
ticket: data.ticket.id,
|
|
216
|
+
save: true
|
|
217
|
+
}, {
|
|
218
|
+
text: 'Uploading Ticket files...',
|
|
219
|
+
files: files
|
|
220
|
+
});
|
|
221
|
+
data.ticket.files = upload.files || [];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (this.format.match(/json/)) return this.jsonOutput(data.ticket);
|
|
225
|
+
this.toast('✅', 'green', "Successfully created Ticket #" + data.ticket.num + ': ' + data.ticket.subject);
|
|
226
|
+
this.printSuggestedCommands({
|
|
227
|
+
"View Ticket details": `xy ticket ${data.ticket.num}`,
|
|
228
|
+
"Add a comment": `xy ticket ${data.ticket.num} --comment "My comment"`,
|
|
229
|
+
"Upload a file": `xy ticket upload ${data.ticket.num} --file FILE`,
|
|
230
|
+
"Update Ticket": `xy ticket update ${data.ticket.num} --status open`,
|
|
231
|
+
"List all Tickets": "xy tickets"
|
|
232
|
+
});
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
async cmd_update_ticket() {
|
|
236
|
+
var selector = this.consumeTicketSelector(true);
|
|
237
|
+
var action = this.args.other.shift();
|
|
238
|
+
if (this.args.other.length) this.die("Unexpected argument after Ticket update action: " + this.args.other[0]);
|
|
239
|
+
delete this.args.other;
|
|
240
|
+
|
|
241
|
+
if (action) {
|
|
242
|
+
var statuses = { close: 'closed', closed: 'closed', open: 'open', reopen: 'open', draft: 'draft' };
|
|
243
|
+
if (!statuses[action]) this.die("Unknown Ticket update action: " + action + '. Choose: close, open, draft');
|
|
244
|
+
if ('status' in this.args) this.die("Specify either a Ticket update action or --status, not both.");
|
|
245
|
+
this.args.status = statuses[action];
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
await this.getMultiple();
|
|
249
|
+
var needs_ticket = ('assign' in this.args) || ('tag' in this.args);
|
|
250
|
+
var resolved = await this.resolveTicketMutationSelector(selector, needs_ticket);
|
|
251
|
+
this.printTicketMutationSummary('Update Ticket', resolved.ticket, resolved.id);
|
|
252
|
+
|
|
253
|
+
var params = this.prepareTicketParams({}, this.args, false, resolved.ticket);
|
|
254
|
+
if (!Tools.numKeys(params)) return this.die("No updates specified for Ticket.");
|
|
255
|
+
this.printUpdateData(params);
|
|
256
|
+
params.id = resolved.id;
|
|
257
|
+
|
|
258
|
+
var data = await this.callStandardAPI('updateTicket', params, { text: 'Updating Ticket...' });
|
|
259
|
+
if (this.dry) return;
|
|
260
|
+
if (this.format.match(/json/)) return this.jsonOutput(data.ticket);
|
|
261
|
+
this.toast('✅', 'green', "Successfully updated Ticket #" + data.ticket.num + ': ' + data.ticket.subject);
|
|
262
|
+
},
|
|
263
|
+
|
|
264
|
+
async cmd_add_ticket_comment() {
|
|
265
|
+
var selector = this.consumeTicketSelector();
|
|
266
|
+
var body = this.args.comment;
|
|
267
|
+
if (body === undefined) body = this.args.body;
|
|
268
|
+
delete this.args.comment;
|
|
269
|
+
delete this.args.body;
|
|
270
|
+
if (Tools.numKeys(this.args)) return this.die("Unsupported Ticket comment option: --" + Tools.firstKey(this.args));
|
|
271
|
+
if ((typeof(body) != 'string') || !body.trim()) return this.dieUsage('ticket comment');
|
|
272
|
+
|
|
273
|
+
var resolved = await this.resolveTicketMutationSelector(selector, false);
|
|
274
|
+
this.printTicketMutationSummary('Add Ticket Comment', resolved.ticket, resolved.id);
|
|
275
|
+
var data = await this.callStandardAPI('addTicketChange', {
|
|
276
|
+
id: resolved.id,
|
|
277
|
+
change: { type: 'comment', body: body }
|
|
278
|
+
}, { text: 'Adding Ticket comment...' });
|
|
279
|
+
if (this.dry) return;
|
|
280
|
+
if (this.format.match(/json/)) return this.jsonOutput(data.ticket);
|
|
281
|
+
this.toast('✅', 'green', "Successfully added a comment to Ticket #" + data.ticket.num + '.');
|
|
282
|
+
},
|
|
283
|
+
|
|
284
|
+
async cmd_upload_ticket_files() {
|
|
285
|
+
var selector = this.consumeTicketSelector();
|
|
286
|
+
var files = this.consumeTicketFileArgs();
|
|
287
|
+
if (Tools.numKeys(this.args)) return this.die("Unsupported Ticket upload option: --" + Tools.firstKey(this.args));
|
|
288
|
+
if (!files.length) return this.dieUsage('ticket upload');
|
|
289
|
+
this.validateTicketUploadFiles(files);
|
|
290
|
+
|
|
291
|
+
var resolved = await this.resolveTicketMutationSelector(selector, false);
|
|
292
|
+
this.printTicketMutationSummary('Upload Ticket Files', resolved.ticket, resolved.id);
|
|
293
|
+
var data = await this.callStandardAPI('uploadUserTicketFiles', {
|
|
294
|
+
ticket: resolved.id,
|
|
295
|
+
save: true
|
|
296
|
+
}, {
|
|
297
|
+
text: 'Uploading Ticket files...',
|
|
298
|
+
files: files
|
|
299
|
+
});
|
|
300
|
+
if (this.dry) return;
|
|
301
|
+
if (this.format.match(/json/)) return this.jsonOutput(data.files || []);
|
|
302
|
+
this.toast('✅', 'green', "Successfully uploaded " + Tools.commify(files.length) + " file(s) to Ticket.");
|
|
303
|
+
this.printTicketFiles(data.files || []);
|
|
304
|
+
},
|
|
305
|
+
|
|
306
|
+
async cmd_download_ticket_file() {
|
|
307
|
+
// Ticket attachments are served from their storage paths through the same
|
|
308
|
+
// authenticated binary endpoint used by Bucket files. Always load the
|
|
309
|
+
// Ticket because even exact Ticket IDs do not contain file metadata.
|
|
310
|
+
var ticket_selector = this.consumeTicketSelector(true);
|
|
311
|
+
var file_selector = this.args.other.shift() || this.args.filename || this.args.file;
|
|
312
|
+
if (!file_selector) return this.dieUsage('ticket download');
|
|
313
|
+
var output = this.args.other.shift() || this.args.output || this.args.download;
|
|
314
|
+
if (this.args.other.length) return this.die("Unexpected argument after output filename: " + this.args.other[0]);
|
|
315
|
+
|
|
316
|
+
delete this.args.other;
|
|
317
|
+
delete this.args.filename;
|
|
318
|
+
delete this.args.file;
|
|
319
|
+
delete this.args.output;
|
|
320
|
+
delete this.args.download;
|
|
321
|
+
if (Tools.numKeys(this.args)) return this.die("Unsupported Ticket download option: --" + Tools.firstKey(this.args));
|
|
322
|
+
|
|
323
|
+
var ticket = await this.fetchTicket(ticket_selector);
|
|
324
|
+
var file = this.findTicketFile(ticket.files, file_selector);
|
|
325
|
+
if (!file) return this.die("Could not find Ticket file from exact filename or File ID: " + file_selector);
|
|
326
|
+
if (!output || (output === true)) output = file.filename;
|
|
327
|
+
|
|
328
|
+
var dest_file = Path.resolve(String(output));
|
|
329
|
+
if (fs.existsSync(dest_file)) return this.die("Output file already exists: " + dest_file);
|
|
330
|
+
var request = {
|
|
331
|
+
// The file API prepends its own `files/` storage prefix.
|
|
332
|
+
path: file.path.replace(/^files\//, ''),
|
|
333
|
+
download: file.filename
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
if (this.dry || this.verbose) {
|
|
337
|
+
println("\n " + this.color('theme').bold('TICKET FILE DOWNLOAD:'));
|
|
338
|
+
this.jsonOutput({
|
|
339
|
+
ticket: ticket.id,
|
|
340
|
+
number: ticket.num,
|
|
341
|
+
file: file.filename,
|
|
342
|
+
request: request,
|
|
343
|
+
output: dest_file
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
if (this.dry) {
|
|
347
|
+
this.toast('⚠️', 'orange', bold("DRY RUN: ") + "Exiting without downloading file.");
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
println("\n " + this.color('theme').bold('TICKET FILE: ' + file.filename));
|
|
352
|
+
println(" " + gray("Downloading to file: ") + dest_file);
|
|
353
|
+
cli.progress.start({ amount: 1, pct: false, text: gray('→ Downloading Ticket file...') });
|
|
354
|
+
try {
|
|
355
|
+
var { err } = await this.api.file(request, { download: dest_file });
|
|
356
|
+
if (err) throw err;
|
|
357
|
+
}
|
|
358
|
+
catch (err) {
|
|
359
|
+
cli.progress.end();
|
|
360
|
+
// The destination did not exist before this command, so any partial file
|
|
361
|
+
// belongs to this failed request and is safe to remove.
|
|
362
|
+
if (fs.existsSync(dest_file)) fs.unlinkSync(dest_file);
|
|
363
|
+
return this.die(err);
|
|
364
|
+
}
|
|
365
|
+
cli.progress.end();
|
|
366
|
+
this.toast('✅', 'green', "Successfully downloaded Ticket file: " + file.filename);
|
|
367
|
+
},
|
|
368
|
+
|
|
369
|
+
async cmd_delete_ticket() {
|
|
370
|
+
var selector = this.consumeTicketSelector();
|
|
371
|
+
var resolved = await this.resolveTicketMutationSelector(selector, false);
|
|
372
|
+
this.printTicketMutationSummary('Delete Ticket', resolved.ticket, resolved.id);
|
|
373
|
+
|
|
374
|
+
if (('confirm' in this.args) && (typeof(this.args.confirm) != 'boolean')) {
|
|
375
|
+
return this.die("Ticket delete --confirm must be true or false.");
|
|
376
|
+
}
|
|
377
|
+
if (this.args.confirm !== true) {
|
|
378
|
+
this.toast('⚠️', 'orange', "Please confirm the Ticket delete by adding '--confirm'.");
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
delete this.args.confirm;
|
|
382
|
+
if (Tools.numKeys(this.args)) return this.die("Unsupported Ticket delete option: --" + Tools.firstKey(this.args));
|
|
383
|
+
|
|
384
|
+
var data = await this.callStandardAPI('deleteTicket', { id: resolved.id }, { text: 'Deleting Ticket...' });
|
|
385
|
+
if (this.dry) return;
|
|
386
|
+
if (this.format.match(/json/)) return this.jsonOutput(data);
|
|
387
|
+
this.toast('✅', 'green', "Successfully deleted Ticket" + (resolved.ticket ? ' #' + resolved.ticket.num : '') + ': #' + resolved.id);
|
|
388
|
+
},
|
|
389
|
+
|
|
390
|
+
async searchTickets(query, sort_by, sort_dir) {
|
|
391
|
+
cli.progress.start({ amount: 1, pct: false, text: gray('→ Searching Tickets...') });
|
|
392
|
+
var { err, data } = await this.api.searchTickets({
|
|
393
|
+
query: query,
|
|
394
|
+
offset: this.offset,
|
|
395
|
+
limit: this.limit,
|
|
396
|
+
sort_by: sort_by,
|
|
397
|
+
sort_dir: sort_dir,
|
|
398
|
+
compact: true
|
|
399
|
+
});
|
|
400
|
+
cli.progress.end();
|
|
401
|
+
if (err) this.die(err);
|
|
402
|
+
return { rows: data.rows || [], list: data.list || { length: 0 } };
|
|
403
|
+
},
|
|
404
|
+
|
|
405
|
+
async fetchTicket(selector) {
|
|
406
|
+
var request = this.parseTicketSelector(selector);
|
|
407
|
+
cli.progress.start({ amount: 1, pct: false, text: gray('→ Loading Ticket...') });
|
|
408
|
+
var { err, data } = await this.api.getTicket(request);
|
|
409
|
+
cli.progress.end();
|
|
410
|
+
if (err) this.die(err);
|
|
411
|
+
return data.ticket;
|
|
412
|
+
},
|
|
413
|
+
|
|
414
|
+
async resolveTicketMutationSelector(selector, load_ticket) {
|
|
415
|
+
// Numbers must be translated through get_ticket because mutating APIs accept
|
|
416
|
+
// only IDs. Exact IDs skip that lookup unless append sugar needs old arrays.
|
|
417
|
+
var request = this.parseTicketSelector(selector);
|
|
418
|
+
if ('num' in request) {
|
|
419
|
+
var ticket = await this.fetchTicket(selector);
|
|
420
|
+
return { id: ticket.id, ticket: ticket };
|
|
421
|
+
}
|
|
422
|
+
if (load_ticket) {
|
|
423
|
+
var ticket = await this.fetchTicket(selector);
|
|
424
|
+
return { id: ticket.id, ticket: ticket };
|
|
425
|
+
}
|
|
426
|
+
return { id: request.id, ticket: null };
|
|
427
|
+
},
|
|
428
|
+
|
|
429
|
+
consumeTicketSelector(keep_other) {
|
|
430
|
+
var named_values = [this.args.num, this.args.number, this.args.id].filter( value => value !== undefined );
|
|
431
|
+
if ((new Set(named_values.map(String))).size > 1) this.die("Conflicting Ticket selector arguments.");
|
|
432
|
+
var named = named_values[0];
|
|
433
|
+
var positional = (keep_other && (named !== undefined)) ? undefined : this.args.other.shift();
|
|
434
|
+
if ((positional !== undefined) && (named !== undefined) && (String(positional) != String(named))) {
|
|
435
|
+
this.die("Conflicting Ticket selector arguments.");
|
|
436
|
+
}
|
|
437
|
+
var selector = positional !== undefined ? positional : named;
|
|
438
|
+
if (selector === undefined) this.die("Missing required Ticket number or ID argument.");
|
|
439
|
+
if (!keep_other && this.args.other.length) this.die("Unexpected argument after Ticket selector: " + this.args.other[0]);
|
|
440
|
+
delete this.args.id;
|
|
441
|
+
delete this.args.num;
|
|
442
|
+
delete this.args.number;
|
|
443
|
+
if (!keep_other) delete this.args.other;
|
|
444
|
+
return selector;
|
|
445
|
+
},
|
|
446
|
+
|
|
447
|
+
parseTicketSelector(selector) {
|
|
448
|
+
selector = String(selector).trim();
|
|
449
|
+
if (selector.match(/^#?(\d+)$/)) {
|
|
450
|
+
var num = Number(RegExp.$1);
|
|
451
|
+
if (!Number.isSafeInteger(num) || (num < 1)) this.die("Invalid Ticket number: " + selector);
|
|
452
|
+
return { num: num };
|
|
453
|
+
}
|
|
454
|
+
if (!selector.match(/^t[a-z0-9_]*$/)) this.die("Invalid Ticket number or ID: " + selector);
|
|
455
|
+
return { id: selector };
|
|
456
|
+
},
|
|
457
|
+
|
|
458
|
+
prepareTicketParams(defaults, input, creating, ticket) {
|
|
459
|
+
// Updates are sparse. Whole arrays replace saved values, while singular
|
|
460
|
+
// --assign and --tag options append to copies of the current arrays.
|
|
461
|
+
var params = creating ? Tools.copyHash(defaults, true) : {};
|
|
462
|
+
var allowed = TICKET_EDIT_FIELDS.concat(creating ? ['id', 'template', 'job', 'alert'] : []);
|
|
463
|
+
Object.keys(input).forEach( key => {
|
|
464
|
+
if (key.includes('.')) this.die("Ticket options do not support dotted paths: --" + key);
|
|
465
|
+
if (!allowed.includes(key)) this.die("Unsupported Ticket option: --" + key);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
TICKET_EDIT_FIELDS.filter( key => !['assign', 'tag'].includes(key) ).forEach( key => {
|
|
469
|
+
if (key in input) params[key] = input[key];
|
|
470
|
+
});
|
|
471
|
+
['id', 'template', 'job', 'alert'].forEach( key => {
|
|
472
|
+
if (creating && (key in input)) params[key] = input[key];
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
TICKET_LIST_FIELDS.forEach( key => {
|
|
476
|
+
if (key in params) params[key] = this.parseTicketList(params[key], key);
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
if ('assign' in input) {
|
|
480
|
+
var assignees = ('assignees' in params) ? params.assignees : (ticket ? (ticket.assignees || []).slice(0) : []);
|
|
481
|
+
params.assignees = this.uniqueTicketList(assignees.concat(this.parseTicketList(input.assign, 'assign')));
|
|
482
|
+
}
|
|
483
|
+
if ('tag' in input) {
|
|
484
|
+
var tags = ('tags' in params) ? params.tags : (ticket ? (ticket.tags || []).slice(0) : []);
|
|
485
|
+
params.tags = this.uniqueTicketList(tags.concat(this.parseTicketList(input.tag, 'tag').map( value => this.resolveTicketTag(value) )));
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if ('subject' in params) {
|
|
489
|
+
if ((typeof(params.subject) != 'string') || !params.subject.trim()) this.die("Ticket subject cannot be empty and must be a string.");
|
|
490
|
+
params.subject = params.subject.trim();
|
|
491
|
+
}
|
|
492
|
+
if (('body' in params) && (typeof(params.body) != 'string')) this.die("Ticket body must be a string.");
|
|
493
|
+
if ('type' in params) this.validateTicketChoice(params.type, 'type', this.getTicketTypes());
|
|
494
|
+
if ('status' in params) this.validateTicketChoice(params.status, 'status', this.getTicketStatuses());
|
|
495
|
+
if ('category' in params) params.category = params.category ? this.resolveTicketCategory(params.category) : '';
|
|
496
|
+
if ('server' in params) params.server = params.server ? this.resolveTicketServer(params.server) : '';
|
|
497
|
+
if ('tags' in params) params.tags = this.uniqueTicketList(params.tags.map( value => this.resolveTicketTag(value) ));
|
|
498
|
+
['assignees', 'cc', 'notify'].forEach( key => {
|
|
499
|
+
if (key in params) params[key] = this.uniqueTicketList(params[key]);
|
|
500
|
+
});
|
|
501
|
+
if ('notify' in params) params.notify.forEach( email => {
|
|
502
|
+
if (!email.match(/^[\w\-.]+@[\w\-.]+$/)) this.die("Invalid Ticket notification email: " + email);
|
|
503
|
+
});
|
|
504
|
+
if ('due' in params) {
|
|
505
|
+
if ((typeof(params.due) == 'string') && params.due.match(/^\d+$/)) params.due = Number(params.due);
|
|
506
|
+
if (!['string', 'number'].includes(typeof(params.due)) || (typeof(params.due) == 'number' && (!Number.isSafeInteger(params.due) || params.due < 0))) {
|
|
507
|
+
this.die("Ticket due must be a non-negative Unix timestamp or relative duration.");
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
if ('template' in params) this.validateTicketChoice(params.template, 'template', ['job', 'alert']);
|
|
511
|
+
if (params.template == 'job' && !params.job) this.die("Ticket template 'job' requires --job JOB_ID.");
|
|
512
|
+
if (params.template == 'alert' && !params.alert) this.die("Ticket template 'alert' requires --alert ALERT_ID.");
|
|
513
|
+
if (('id' in params) && ((typeof(params.id) != 'string') || !params.id.match(/^t[a-z0-9_]*$/))) this.die("Invalid Ticket ID: " + params.id);
|
|
514
|
+
return params;
|
|
515
|
+
},
|
|
516
|
+
|
|
517
|
+
parseTicketList(value, name) {
|
|
518
|
+
var list = [];
|
|
519
|
+
Tools.alwaysArray(value).forEach( item => {
|
|
520
|
+
if ((typeof(item) != 'string') && (typeof(item) != 'number')) this.die("Ticket " + name + " must be a comma-separated string or JSON array of strings.");
|
|
521
|
+
list = list.concat(String(item).split(/\s*,\s*/));
|
|
522
|
+
});
|
|
523
|
+
return list.map( item => item.trim() ).filter( item => !!item );
|
|
524
|
+
},
|
|
525
|
+
|
|
526
|
+
uniqueTicketList(list) {
|
|
527
|
+
return Array.from(new Set(list));
|
|
528
|
+
},
|
|
529
|
+
|
|
530
|
+
getTicketTypes() {
|
|
531
|
+
return ((this.config.ui && this.config.ui.ticket_types) || []).map( item => item.id ).concat(TICKET_TYPES).filter( (id, idx, list) => list.indexOf(id) == idx );
|
|
532
|
+
},
|
|
533
|
+
|
|
534
|
+
getTicketStatuses() {
|
|
535
|
+
return ((this.config.ui && this.config.ui.ticket_statuses) || []).map( item => item.id ).concat(TICKET_STATUSES).filter( (id, idx, list) => list.indexOf(id) == idx );
|
|
536
|
+
},
|
|
537
|
+
|
|
538
|
+
validateTicketChoice(value, name, choices) {
|
|
539
|
+
if ((typeof(value) != 'string') || !choices.includes(value)) this.die("Invalid Ticket " + name + ': ' + value + '. Choose: ' + choices.join(', '));
|
|
540
|
+
return value;
|
|
541
|
+
},
|
|
542
|
+
|
|
543
|
+
resolveTicketCategory(selector) {
|
|
544
|
+
selector = String(selector);
|
|
545
|
+
var category = Tools.findObject(this.categories, { id: selector }) || this.findObjectFuzzy(this.categories, { title: selector });
|
|
546
|
+
if (!category) this.die("Could not find Ticket category: " + selector);
|
|
547
|
+
return category.id;
|
|
548
|
+
},
|
|
549
|
+
|
|
550
|
+
resolveTicketTag(selector) {
|
|
551
|
+
selector = String(selector);
|
|
552
|
+
var tag = Tools.findObject(this.tags, { id: selector }) || this.findObjectFuzzy(this.tags, { title: selector });
|
|
553
|
+
if (!tag) this.die("Could not find Ticket Tag: " + selector);
|
|
554
|
+
return tag.id;
|
|
555
|
+
},
|
|
556
|
+
|
|
557
|
+
resolveTicketServer(selector) {
|
|
558
|
+
selector = String(selector);
|
|
559
|
+
var servers = Object.values(this.servers);
|
|
560
|
+
var server = Tools.findObject(servers, { id: selector }) || this.findObjectFuzzy(servers, { title: selector, hostname: selector });
|
|
561
|
+
if (!server) this.die("Could not find Ticket server: " + selector);
|
|
562
|
+
return server.id;
|
|
563
|
+
},
|
|
564
|
+
|
|
565
|
+
parseTicketSortDirection(value) {
|
|
566
|
+
if (value === undefined) return -1;
|
|
567
|
+
if ([1, '1', 'asc', 'ascending'].includes(value)) return 1;
|
|
568
|
+
if ([-1, '-1', 'desc', 'descending'].includes(value)) return -1;
|
|
569
|
+
this.die("Ticket sort direction must be asc, desc, 1, or -1.");
|
|
570
|
+
},
|
|
571
|
+
|
|
572
|
+
quoteTicketQueryValue(value) {
|
|
573
|
+
value = String(value);
|
|
574
|
+
return value.match(/\s/) ? '"' + value.replace(/"/g, '\\"') + '"' : value;
|
|
575
|
+
},
|
|
576
|
+
|
|
577
|
+
consumeTicketFileArgs() {
|
|
578
|
+
var files = [];
|
|
579
|
+
if ('file' in this.args) files = files.concat(Tools.alwaysArray(this.args.file));
|
|
580
|
+
if ('files' in this.args) files = files.concat(Tools.alwaysArray(this.args.files));
|
|
581
|
+
delete this.args.file;
|
|
582
|
+
delete this.args.files;
|
|
583
|
+
return files.map( file => String(file) ).filter( file => !!file );
|
|
584
|
+
},
|
|
585
|
+
|
|
586
|
+
validateTicketUploadFiles(files) {
|
|
587
|
+
files.forEach( file => {
|
|
588
|
+
if (!fs.existsSync(file)) this.die("Upload file not found: " + file);
|
|
589
|
+
if (!fs.statSync(file).isFile()) this.die("Upload path is not a file: " + file);
|
|
590
|
+
});
|
|
591
|
+
},
|
|
592
|
+
|
|
593
|
+
findTicketFile(files, selector) {
|
|
594
|
+
// File IDs are unique. Filenames are convenient, but Ticket uploads may
|
|
595
|
+
// contain duplicates, so require the File ID when a name is ambiguous.
|
|
596
|
+
selector = String(selector);
|
|
597
|
+
var id_match = Tools.findObject(files || [], { id: selector });
|
|
598
|
+
if (id_match) return id_match;
|
|
599
|
+
|
|
600
|
+
var name_matches = (files || []).filter( file => file.filename == selector );
|
|
601
|
+
if (name_matches.length > 1) this.die("Multiple Ticket files have this filename. Please use a File ID: " + selector);
|
|
602
|
+
return name_matches[0] || null;
|
|
603
|
+
},
|
|
604
|
+
|
|
605
|
+
getNiceTicketType(type) {
|
|
606
|
+
var def = Tools.findObject((this.config.ui && this.config.ui.ticket_types) || [], { id: type });
|
|
607
|
+
return def ? def.title : Tools.ucfirst(type || 'Other');
|
|
608
|
+
},
|
|
609
|
+
|
|
610
|
+
getNiceTicketStatus(status) {
|
|
611
|
+
var def = Tools.findObject((this.config.ui && this.config.ui.ticket_statuses) || [], { id: status }) || {
|
|
612
|
+
title: Tools.ucfirst(status || 'Unknown'),
|
|
613
|
+
color: 'gray'
|
|
614
|
+
};
|
|
615
|
+
return this.color(def.color || 'gray').bold(def.title);
|
|
616
|
+
},
|
|
617
|
+
|
|
618
|
+
getNiceTicketList(list) {
|
|
619
|
+
return (list || []).join(', ') || gray('(None)');
|
|
620
|
+
},
|
|
621
|
+
|
|
622
|
+
printTicketMutationSummary(title, ticket, id) {
|
|
623
|
+
this.printMutationSummary({
|
|
624
|
+
title: title,
|
|
625
|
+
rows: [
|
|
626
|
+
ticket ? [ 'Number', this.color('theme').bold('#' + ticket.num) ] : null,
|
|
627
|
+
[ 'Ticket ID', gray(ticket ? ticket.id : id) ],
|
|
628
|
+
ticket ? [ 'Subject', this.color('theme').bold(ticket.subject) ] : null
|
|
629
|
+
]
|
|
630
|
+
});
|
|
631
|
+
},
|
|
632
|
+
|
|
633
|
+
printTicketBody(body) {
|
|
634
|
+
println("\n " + this.color('theme').bold('TICKET BODY'));
|
|
635
|
+
if (!body) return println(' ' + gray('(Empty)'));
|
|
636
|
+
println("\n" + this.markdown(body).trimEnd());
|
|
637
|
+
},
|
|
638
|
+
|
|
639
|
+
printTicketEvents(events) {
|
|
640
|
+
this.printBoxTable({
|
|
641
|
+
title: 'Ticket Events',
|
|
642
|
+
header: ['Event ID', 'Title', 'Category', 'Plugin', 'Targets', 'Tags'],
|
|
643
|
+
rows: events.map( stub => {
|
|
644
|
+
var event = Tools.findObject(this.events, { id: stub.id }) || { id: stub.id, title: '(Event Not Found)' };
|
|
645
|
+
return [
|
|
646
|
+
this.color('theme').bold(event.id),
|
|
647
|
+
event.title,
|
|
648
|
+
this.getNiceCategory(event.category),
|
|
649
|
+
this.getNicePlugin(event.plugin),
|
|
650
|
+
this.getNiceTargets((stub.targets && stub.targets.length) ? stub.targets : event.targets),
|
|
651
|
+
this.getNiceTagList((stub.tags && stub.tags.length) ? stub.tags : event.tags)
|
|
652
|
+
];
|
|
653
|
+
})
|
|
654
|
+
});
|
|
655
|
+
},
|
|
656
|
+
|
|
657
|
+
printTicketFiles(files) {
|
|
658
|
+
this.printBoxTable({
|
|
659
|
+
title: 'Ticket Files',
|
|
660
|
+
header: ['File ID', 'Filename', 'Size', 'Uploader', 'Uploaded'],
|
|
661
|
+
rows: files.map( file => [
|
|
662
|
+
this.color('theme').bold(file.id),
|
|
663
|
+
bold(file.filename),
|
|
664
|
+
Tools.getTextFromBytes(file.size || 0),
|
|
665
|
+
file.username || gray('(Unknown)'),
|
|
666
|
+
this.getRelativeDateTime(file.date, true)
|
|
667
|
+
])
|
|
668
|
+
});
|
|
669
|
+
},
|
|
670
|
+
|
|
671
|
+
printTicketComments(comments) {
|
|
672
|
+
if (!comments.length) return;
|
|
673
|
+
comments.forEach( change => {
|
|
674
|
+
var date = this.getNiceDateTime(change.edited || change.date, true, true);
|
|
675
|
+
var title = 'Comment by ' + (change.username || '(Unknown)') + ' -- ' + date + (change.edited ? ' (Edited)' : '');
|
|
676
|
+
println("\n " + this.color('theme').bold(title));
|
|
677
|
+
println("\n" + this.markdown(change.body || '').trimEnd());
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
}; // module.exports
|