jira-pilot 2.0.1 → 2.0.2

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.
@@ -1,707 +1,985 @@
1
- import { Command } from 'commander';
2
- import chalk from 'chalk';
3
- import { table } from 'table';
4
- import { api } from '../services/api-service.js';
5
- import ora from 'ora';
6
- import enquirer from 'enquirer';
7
- import { parseADF } from '../utils/adf-parser.js';
8
- import { textToADF } from '../utils/text-to-adf.js';
9
- import { validateIssueKey } from '../utils/validators.js';
10
-
11
- export function registerIssueCommand(program) {
12
- const issueCmd = new Command('issue')
13
- .description('Manage Jira issues')
14
- .addHelpText('after', `
15
- Common Actions:
16
- $ jira issue list # List assigned issues
17
- $ jira issue view <KEY> # View issue details
18
- $ jira issue create # Create new issue (interactive)
19
- $ jira issue transition <KEY> # Move issue status
20
- `);
21
-
22
- issueCmd
23
- .command('list')
24
- .description('List issues')
25
- .option('-j, --jql <query>', 'JQL query to filter issues')
26
- .option('-l, --limit <number>', 'Limit results', '20')
27
- .option('-p, --project <key>', 'Filter by project')
28
- .option('-a, --assignee <id>', 'Filter by assignee (use "currentUser" for self)')
29
- .option('-s, --status <status>', 'Filter by status')
30
- .option('-e, --export <format>', 'Export output (json, md)')
31
- .addHelpText('after', `
32
- Examples:
33
- $ jira issue list --project PROJ --status "In Progress"
34
- $ jira issue list --assignee currentUser --limit 10
35
- $ jira issue list --jql "created >= -7d"
36
- $ jira issue list --export json
37
- `)
38
- .action(async (options) => {
39
- const spinner = ora('Fetching issues...').start();
40
- try {
41
- const jqlParts = [];
42
- if (options.project) jqlParts.push(`project = "${options.project}"`);
43
- if (options.assignee) jqlParts.push(`assignee = ${options.assignee === 'currentUser' ? 'currentUser()' : `"${options.assignee}"`}`);
44
- if (options.status) jqlParts.push(`status = "${options.status}"`);
45
- if (options.jql) jqlParts.push(options.jql);
46
-
47
- // Order by updated desc by default if no JQL
48
- if (!options.jql && jqlParts.length === 0) {
49
- jqlParts.push('order by updated DESC');
50
- } else if (jqlParts.length > 0 && !options.jql) {
51
- // Add order if not custom jql
52
- // jqlParts.push('order by updated DESC');
53
- }
54
-
55
- const jql = jqlParts.join(' AND ');
56
-
57
- const searchApi = '/search/jql';
58
- const body = {
59
- jql: jql || 'created is not empty',
60
- maxResults: parseInt(options.limit),
61
- fields: ['summary', 'status', 'assignee', 'created', 'updated', 'description']
62
- };
63
-
64
- const data = await api.post(searchApi, body);
65
- spinner.stop();
66
-
67
- if (!data.issues || data.issues.length === 0) {
68
- console.log(chalk.yellow('No issues found.'));
69
- return;
70
- }
71
-
72
- // Handling Export
73
- if (options.export) {
74
- const fs = await import('fs');
75
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
76
-
77
- if (options.export === 'json') {
78
- const filename = `issues-${timestamp}.json`;
79
- fs.writeFileSync(filename, JSON.stringify(data.issues, null, 2));
80
- console.log(chalk.green(`\nExported ${data.issues.length} issues to ${chalk.bold(filename)}`));
81
- return;
82
- }
83
-
84
- if (options.export === 'md') {
85
- const filename = `issues-${timestamp}.md`;
86
- let mdContent = `# Jira Issues Export\nGenerated: ${new Date().toLocaleString()}\n\n`;
87
- mdContent += `| Key | Summary | Status | Assignee |\n`;
88
- mdContent += `|---|---|---|---|\n`;
89
-
90
- data.issues.forEach(i => {
91
- const key = i.key;
92
- const summary = i.fields.summary || '';
93
- const status = i.fields.status?.name || '';
94
- const assignee = i.fields.assignee?.displayName || 'Unassigned';
95
- mdContent += `| ${key} | ${summary} | ${status} | ${assignee} |\n`;
96
- });
97
-
98
- fs.writeFileSync(filename, mdContent);
99
- console.log(chalk.green(`\nExported ${data.issues.length} issues to ${chalk.bold(filename)}`));
100
- return;
101
- }
102
- }
103
-
104
- const tableData = [
105
- [chalk.bold('Key'), chalk.bold('Summary'), chalk.bold('Status'), chalk.bold('Assignee'), chalk.bold('Created'), chalk.bold('Updated')]
106
- ];
107
-
108
- data.issues.forEach(i => {
109
- tableData.push([
110
- chalk.cyan(i.key),
111
- i.fields.summary ? (i.fields.summary.length > 50 ? i.fields.summary.substring(0, 47) + '...' : i.fields.summary) : '',
112
- i.fields.status ? i.fields.status.name : '',
113
- i.fields.assignee ? i.fields.assignee.displayName : 'Unassigned',
114
- i.fields.created ? i.fields.created.split('T')[0] : '',
115
- i.fields.updated ? i.fields.updated.split('T')[0] : ''
116
- ]);
117
- });
118
-
119
- console.log(table(tableData));
120
-
121
- } catch (e) {
122
- spinner.fail('Failed to list issues');
123
- if (e.response) {
124
- console.error(chalk.red(`Error ${e.response.status}: `), e.response.data);
125
- } else {
126
- console.error(chalk.red(e.message));
127
- }
128
- }
129
- });
130
-
131
- issueCmd
132
- .command('view')
133
- .description('View issue details')
134
- .argument('<issueKey>', 'Issue Key')
135
- .addHelpText('after', `
136
- Examples:
137
- $ jira issue view PROJ-123
138
- `)
139
- .action(async (issueKey) => {
140
- const check = validateIssueKey(issueKey);
141
- if (!check.valid) { console.error(chalk.red(check.message)); return; }
142
- const spinner = ora(`Fetching issue ${issueKey}...`).start();
143
- try {
144
- const issue = await api.get(`/issue/${issueKey}`);
145
- spinner.stop();
146
-
147
- console.log(chalk.bold(`\n${issue.key}: ${issue.fields.summary}`));
148
- console.log(chalk.grey(`${issue.fields.issuetype.name} - ${issue.fields.status.name} - ${issue.fields.priority ? issue.fields.priority.name : 'No Priority'}`));
149
- console.log(chalk.bold('\nDescription:'));
150
- console.log(parseADF(issue.fields.description) || 'No description provided.');
151
-
152
- if (issue.fields.assignee) {
153
- console.log(chalk.bold('\nAssignee: ') + issue.fields.assignee.displayName);
154
- }
155
-
156
- if (issue.fields.comment && issue.fields.comment.comments.length > 0) {
157
- console.log(chalk.bold('\nComments:'));
158
- issue.fields.comment.comments.forEach(c => {
159
- console.log(chalk.cyan(c.author.displayName) + ': ' + c.body);
160
- });
161
- }
162
- console.log('');
163
- } catch (e) {
164
- spinner.fail('Failed to fetch issue');
165
- if (e.response) {
166
- if (e.response.status === 404) {
167
- console.error(chalk.red(`Issue "${issueKey}" not found.`));
168
- } else {
169
- console.error(chalk.red(`Error ${e.response.status}: `), e.response.data);
170
- }
171
- } else {
172
- console.error(chalk.red(e.message));
173
- }
174
- }
175
- });
176
-
177
- // ── CREATE ────────────────────────────────────────────────────────
178
- issueCmd
179
- .command('create')
180
- .description('Create a new Jira issue')
181
- .option('-p, --project <key>', 'Project key')
182
- .option('-t, --type <type>', 'Issue type (e.g., Bug, Story, Task)')
183
- .option('-s, --summary <text>', 'Issue summary')
184
- .option('-d, --description <text>', 'Issue description')
185
- .option('--priority <name>', 'Priority name (e.g., High, Medium, Low)')
186
- .option('-a, --assignee <id>', 'Assignee account ID (use "me" for self)')
187
- .addHelpText('after', `
188
- Examples:
189
- $ jira issue create # Interactive wizard
190
- $ jira issue create -p PROJ -s "Fix login bug" # Quick create
191
- $ jira issue create -p PROJ -t Bug -s "Crash on save" --priority High
192
- $ jira issue create -p PROJ -s "New feature" -a me
193
- `)
194
- .action(async (options) => {
195
- try {
196
- // ── Step 1: Select Project ──────────────────────────
197
- let projectKey = options.project;
198
- if (!projectKey) {
199
- const spinner = ora('Fetching projects...').start();
200
- const projectData = await api.get('/project/search');
201
- spinner.stop();
202
-
203
- if (!projectData.values || projectData.values.length === 0) {
204
- console.error(chalk.red('No projects found. Check your permissions.'));
205
- return;
206
- }
207
-
208
- const projectChoices = projectData.values.map(p => ({
209
- name: p.key,
210
- message: `${p.key} ${p.name}`
211
- }));
212
-
213
- const { selectedProject } = await enquirer.prompt({
214
- type: 'select',
215
- name: 'selectedProject',
216
- message: 'Select Project:',
217
- choices: projectChoices
218
- });
219
- projectKey = selectedProject;
220
- }
221
-
222
- // ── Step 2: Select Issue Type ───────────────────────
223
- let issueTypeName = options.type;
224
- if (!issueTypeName) {
225
- const spinner = ora('Fetching issue types...').start();
226
- let issueTypes = [];
227
- try {
228
- // Jira Cloud v3 - createmeta endpoint
229
- const metaData = await api.get(`/issue/createmeta/${projectKey}/issuetypes`);
230
- issueTypes = metaData.issueTypes || metaData.values || [];
231
- } catch (metaErr) {
232
- // Fallback: use project-level issue types
233
- try {
234
- const projectInfo = await api.get(`/project/${projectKey}`);
235
- issueTypes = projectInfo.issueTypes || [];
236
- } catch {
237
- issueTypes = [
238
- { name: 'Task' }, { name: 'Bug' },
239
- { name: 'Story' }, { name: 'Epic' }
240
- ];
241
- }
242
- }
243
- spinner.stop();
244
-
245
- if (issueTypes.length === 0) {
246
- issueTypes = [
247
- { name: 'Task' }, { name: 'Bug' },
248
- { name: 'Story' }, { name: 'Epic' }
249
- ];
250
- }
251
-
252
- // Filter out sub-tasks if present
253
- const filteredTypes = issueTypes.filter(t => !t.subtask);
254
- const typeChoices = (filteredTypes.length > 0 ? filteredTypes : issueTypes)
255
- .map(t => ({ name: t.name, message: t.name }));
256
-
257
- const { selectedType } = await enquirer.prompt({
258
- type: 'select',
259
- name: 'selectedType',
260
- message: 'Select Issue Type:',
261
- choices: typeChoices
262
- });
263
- issueTypeName = selectedType;
264
- }
265
-
266
- // ── Step 3: Summary (required) ──────────────────────
267
- let summary = options.summary;
268
- if (!summary) {
269
- const { inputSummary } = await enquirer.prompt({
270
- type: 'input',
271
- name: 'inputSummary',
272
- message: 'Summary (required):',
273
- validate: (val) => val.trim().length > 0 || 'Summary cannot be empty'
274
- });
275
- summary = inputSummary;
276
- }
277
-
278
- // ── Step 4: Description (optional) ──────────────────
279
- let description = options.description;
280
- if (description === undefined) {
281
- const { inputDescription } = await enquirer.prompt({
282
- type: 'input',
283
- name: 'inputDescription',
284
- message: 'Description (optional, press Enter to skip):'
285
- });
286
- description = inputDescription || null;
287
- }
288
-
289
- // ── Step 5: Priority ────────────────────────────────
290
- let priorityName = options.priority;
291
- if (!priorityName) {
292
- const spinner = ora('Fetching priorities...').start();
293
- try {
294
- const priorities = await api.get('/priority');
295
- spinner.stop();
296
-
297
- if (Array.isArray(priorities) && priorities.length > 0) {
298
- const priorityChoices = priorities.map(p => ({
299
- name: p.name,
300
- message: p.name
301
- }));
302
-
303
- const { selectedPriority } = await enquirer.prompt({
304
- type: 'select',
305
- name: 'selectedPriority',
306
- message: 'Select Priority:',
307
- choices: priorityChoices
308
- });
309
- priorityName = selectedPriority;
310
- }
311
- } catch {
312
- spinner.stop();
313
- // Priority endpoint may not be available; skip
314
- }
315
- }
316
-
317
- // ── Step 6: Assignee ────────────────────────────────
318
- let assigneeId = options.assignee;
319
- if (!assigneeId) {
320
- const { assigneeChoice } = await enquirer.prompt({
321
- type: 'select',
322
- name: 'assigneeChoice',
323
- message: 'Assign to:',
324
- choices: [
325
- { name: 'me', message: 'Myself' },
326
- { name: 'unassigned', message: 'Leave Unassigned' },
327
- { name: 'search', message: 'Search for a user...' }
328
- ]
329
- });
330
-
331
- if (assigneeChoice === 'me') {
332
- const spinner = ora('Fetching your account...').start();
333
- try {
334
- const myself = await api.get('/myself');
335
- assigneeId = myself.accountId;
336
- spinner.stop();
337
- } catch {
338
- spinner.fail('Could not fetch your account. Leaving unassigned.');
339
- assigneeId = null;
340
- }
341
- } else if (assigneeChoice === 'search') {
342
- const { searchQuery } = await enquirer.prompt({
343
- type: 'input',
344
- name: 'searchQuery',
345
- message: 'Search user by name or email:'
346
- });
347
-
348
- if (searchQuery.trim()) {
349
- const spinner = ora('Searching users...').start();
350
- try {
351
- const users = await api.get(`/user/search?query=${encodeURIComponent(searchQuery)}`);
352
- spinner.stop();
353
-
354
- if (Array.isArray(users) && users.length > 0) {
355
- const userChoices = users.map(u => ({
356
- name: u.accountId,
357
- message: `${u.displayName} (${u.emailAddress || u.accountId})`
358
- }));
359
-
360
- const { selectedUser } = await enquirer.prompt({
361
- type: 'select',
362
- name: 'selectedUser',
363
- message: 'Select User:',
364
- choices: userChoices
365
- });
366
- assigneeId = selectedUser;
367
- } else {
368
- console.log(chalk.yellow('No users found. Leaving unassigned.'));
369
- assigneeId = null;
370
- }
371
- } catch {
372
- spinner.fail('User search failed. Leaving unassigned.');
373
- assigneeId = null;
374
- }
375
- }
376
- } else {
377
- assigneeId = null;
378
- }
379
- } else if (assigneeId === 'me') {
380
- // --assignee me flag: resolve to account ID
381
- const spinner = ora('Fetching your account...').start();
382
- try {
383
- const myself = await api.get('/myself');
384
- assigneeId = myself.accountId;
385
- spinner.stop();
386
- } catch {
387
- spinner.fail('Could not fetch your account. Leaving unassigned.');
388
- assigneeId = null;
389
- }
390
- }
391
-
392
- // ── Confirmation ────────────────────────────────────
393
- console.log(chalk.blue('\n── Issue Summary ──────────────────'));
394
- console.log(` Project: ${chalk.cyan(projectKey)}`);
395
- console.log(` Type: ${issueTypeName}`);
396
- console.log(` Summary: ${summary}`);
397
- console.log(` Description: ${description || chalk.grey('(none)')}`);
398
- console.log(` Priority: ${priorityName || chalk.grey('(default)')}`);
399
- console.log(` Assignee: ${assigneeId || chalk.grey('Unassigned')}`);
400
- console.log(chalk.blue('──────────────────────────────────\n'));
401
-
402
- const { confirmed } = await enquirer.prompt({
403
- type: 'confirm',
404
- name: 'confirmed',
405
- message: 'Create this issue?',
406
- initial: true
407
- });
408
-
409
- if (!confirmed) {
410
- console.log(chalk.yellow('Issue creation cancelled.'));
411
- return;
412
- }
413
-
414
- // ── Build Request Body ──────────────────────────────
415
- const issueBody = {
416
- fields: {
417
- project: { key: projectKey },
418
- issuetype: { name: issueTypeName },
419
- summary: summary
420
- }
421
- };
422
-
423
- if (description) {
424
- issueBody.fields.description = textToADF(description);
425
- }
426
-
427
- if (priorityName) {
428
- issueBody.fields.priority = { name: priorityName };
429
- }
430
-
431
- if (assigneeId) {
432
- issueBody.fields.assignee = { accountId: assigneeId };
433
- }
434
-
435
- // ── Create Issue ────────────────────────────────────
436
- const spinner = ora('Creating issue...').start();
437
- const result = await api.post('/issue', issueBody);
438
- spinner.succeed(chalk.green(`Issue created: ${chalk.bold(result.key)}`));
439
-
440
- console.log(chalk.grey(`View it: jira issue view ${result.key}`));
441
-
442
- } catch (e) {
443
- if (e === '' || e.message === '') {
444
- // User cancelled prompt (Ctrl+C)
445
- console.log(chalk.yellow('\nCancelled.'));
446
- return;
447
- }
448
- console.error(chalk.red('\nFailed to create issue:'));
449
- if (e.response) {
450
- console.error(chalk.red(`Error ${e.response.status}: `), JSON.stringify(e.response.data, null, 2));
451
- } else {
452
- console.error(chalk.red(e.message));
453
- }
454
- }
455
- });
456
-
457
- // ── TRANSITION ────────────────────────────────────────────────────
458
- issueCmd
459
- .command('transition')
460
- .description('Transition an issue to a new status')
461
- .argument('<issueKey>', 'Issue Key (e.g., PROJ-123)')
462
- .option('-s, --status <name>', 'Target status name (skips interactive selection)')
463
- .addHelpText('after', `
464
- Examples:
465
- $ jira issue transition PROJ-123 # Interactive
466
- $ jira issue transition PROJ-123 --status "In Progress"
467
- $ jira issue transition PROJ-123 -s Done
468
- `)
469
- .action(async (issueKey, options) => {
470
- const check = validateIssueKey(issueKey);
471
- if (!check.valid) { console.error(chalk.red(check.message)); return; }
472
- const spinner = ora(`Fetching transitions for ${issueKey}...`).start();
473
- try {
474
- // Fetch current issue to show context
475
- const issue = await api.get(`/issue/${issueKey}?fields=summary,status`);
476
- const currentStatus = issue.fields.status.name;
477
-
478
- // Fetch available transitions
479
- const transData = await api.get(`/issue/${issueKey}/transitions`);
480
- spinner.stop();
481
-
482
- if (!transData.transitions || transData.transitions.length === 0) {
483
- console.log(chalk.yellow(`No transitions available for ${issueKey} (current status: ${currentStatus}).`));
484
- return;
485
- }
486
-
487
- console.log(chalk.bold(`\n${issue.key}: ${issue.fields.summary}`));
488
- console.log(chalk.grey(`Current Status: ${currentStatus}\n`));
489
-
490
- let targetTransition;
491
-
492
- if (options.status) {
493
- // Non-interactive: find matching transition
494
- targetTransition = transData.transitions.find(
495
- t => t.name.toLowerCase() === options.status.toLowerCase() ||
496
- t.to.name.toLowerCase() === options.status.toLowerCase()
497
- );
498
-
499
- if (!targetTransition) {
500
- console.error(chalk.red(`Status "${options.status}" is not a valid transition from "${currentStatus}".`));
501
- console.log(chalk.grey('Available transitions:'));
502
- transData.transitions.forEach(t => {
503
- console.log(chalk.grey(` • ${t.name} ${t.to.name}`));
504
- });
505
- return;
506
- }
507
- } else {
508
- // Interactive: show selection
509
- const transitionChoices = transData.transitions.map(t => ({
510
- name: t.id,
511
- message: `${t.name} ${chalk.cyan(t.to.name)}`
512
- }));
513
-
514
- const { selectedTransition } = await enquirer.prompt({
515
- type: 'select',
516
- name: 'selectedTransition',
517
- message: 'Select transition:',
518
- choices: transitionChoices
519
- });
520
-
521
- targetTransition = transData.transitions.find(t => t.id === selectedTransition);
522
- }
523
-
524
- // Execute transition
525
- const execSpinner = ora(`Transitioning to "${targetTransition.to.name}"...`).start();
526
- await api.post(`/issue/${issueKey}/transitions`, {
527
- transition: { id: targetTransition.id }
528
- });
529
- execSpinner.succeed(chalk.green(`${issueKey} transitioned: ${currentStatus} → ${chalk.bold(targetTransition.to.name)}`));
530
-
531
- } catch (e) {
532
- spinner.stop();
533
- if (e === '' || e.message === '') {
534
- console.log(chalk.yellow('\nCancelled.'));
535
- return;
536
- }
537
- console.error(chalk.red('\nFailed to transition issue:'));
538
- if (e.response) {
539
- if (e.response.status === 404) {
540
- console.error(chalk.red(`Issue "${issueKey}" not found.`));
541
- } else {
542
- console.error(chalk.red(`Error ${e.response.status}: `), e.response.data);
543
- }
544
- } else {
545
- console.error(chalk.red(e.message));
546
- }
547
- }
548
- });
549
- // ── ASSIGN ────────────────────────────────────────────────────────
550
- issueCmd
551
- .command('assign')
552
- .description('Assign or reassign an issue')
553
- .argument('<issueKey>', 'Issue Key (e.g., PROJ-123)')
554
- .option('-a, --assignee <id>', 'Assignee account ID (use "me" for self, "none" to unassign)')
555
- .addHelpText('after', `
556
- Examples:
557
- $ jira issue assign PROJ-123 # Interactive
558
- $ jira issue assign PROJ-123 -a me # Assign to yourself
559
- $ jira issue assign PROJ-123 -a none # Unassign
560
- `)
561
- .action(async (issueKey, options) => {
562
- const check = validateIssueKey(issueKey);
563
- if (!check.valid) { console.error(chalk.red(check.message)); return; }
564
- try {
565
- let assigneeId = options.assignee;
566
-
567
- if (!assigneeId) {
568
- // Interactive selection
569
- const spinner = ora(`Fetching issue ${issueKey}...`).start();
570
- const issue = await api.get(`/issue/${issueKey}?fields=summary,assignee`);
571
- spinner.stop();
572
-
573
- const currentAssignee = issue.fields.assignee?.displayName || 'Unassigned';
574
- console.log(chalk.bold(`\n${issue.key}: ${issue.fields.summary}`));
575
- console.log(chalk.grey(`Current Assignee: ${currentAssignee}\n`));
576
-
577
- const { assignChoice } = await enquirer.prompt({
578
- type: 'select',
579
- name: 'assignChoice',
580
- message: 'Assign to:',
581
- choices: [
582
- { name: 'me', message: 'Myself' },
583
- { name: 'none', message: 'Unassign' },
584
- { name: 'search', message: 'Search for a user...' }
585
- ]
586
- });
587
- assigneeId = assignChoice;
588
- }
589
-
590
- if (assigneeId === 'me') {
591
- const spinner = ora('Fetching your account...').start();
592
- const myself = await api.get('/myself');
593
- assigneeId = myself.accountId;
594
- spinner.stop();
595
- }
596
-
597
- if (assigneeId === 'search') {
598
- const { searchQuery } = await enquirer.prompt({
599
- type: 'input',
600
- name: 'searchQuery',
601
- message: 'Search user by name or email:'
602
- });
603
-
604
- const spinner = ora('Searching users...').start();
605
- const users = await api.get(`/user/search?query=${encodeURIComponent(searchQuery)}`);
606
- spinner.stop();
607
-
608
- if (!Array.isArray(users) || users.length === 0) {
609
- console.log(chalk.yellow('No users found.'));
610
- return;
611
- }
612
-
613
- const { selectedUser } = await enquirer.prompt({
614
- type: 'select',
615
- name: 'selectedUser',
616
- message: 'Select User:',
617
- choices: users.map(u => ({
618
- name: u.accountId,
619
- message: `${u.displayName} (${u.emailAddress || u.accountId})`
620
- }))
621
- });
622
- assigneeId = selectedUser;
623
- }
624
-
625
- const spinner = ora('Updating assignee...').start();
626
- const body = assigneeId === 'none'
627
- ? { accountId: null }
628
- : { accountId: assigneeId };
629
-
630
- await api.put(`/issue/${issueKey}/assignee`, body);
631
- spinner.succeed(chalk.green(`${issueKey} ${assigneeId === 'none' ? 'unassigned' : 'assigned'} successfully.`));
632
-
633
- } catch (e) {
634
- if (e === '' || e.message === '') {
635
- console.log(chalk.yellow('\nCancelled.'));
636
- return;
637
- }
638
- console.error(chalk.red('\nFailed to assign issue:'));
639
- if (e.response) {
640
- console.error(chalk.red(`Error ${e.response.status}: `), e.response.data);
641
- } else {
642
- console.error(chalk.red(e.message));
643
- }
644
- }
645
- });
646
-
647
- // ── COMMENT ───────────────────────────────────────────────────────
648
- issueCmd
649
- .command('comment')
650
- .description('Add a comment to an issue')
651
- .argument('<issueKey>', 'Issue Key (e.g., PROJ-123)')
652
- .option('-m, --message <text>', 'Comment text (skips interactive prompt)')
653
- .addHelpText('after', `
654
- Examples:
655
- $ jira issue comment PROJ-123 # Interactive
656
- $ jira issue comment PROJ-123 -m "Fixed in latest build"
657
- `)
658
- .action(async (issueKey, options) => {
659
- const check = validateIssueKey(issueKey);
660
- if (!check.valid) { console.error(chalk.red(check.message)); return; }
661
- try {
662
- let commentText = options.message;
663
-
664
- if (!commentText) {
665
- // Show issue context first
666
- const spinner = ora(`Fetching issue ${issueKey}...`).start();
667
- const issue = await api.get(`/issue/${issueKey}?fields=summary,status`);
668
- spinner.stop();
669
-
670
- console.log(chalk.bold(`\n${issue.key}: ${issue.fields.summary}`));
671
- console.log(chalk.grey(`Status: ${issue.fields.status.name}\n`));
672
-
673
- const { inputComment } = await enquirer.prompt({
674
- type: 'input',
675
- name: 'inputComment',
676
- message: 'Enter your comment:',
677
- validate: (val) => val.trim().length > 0 || 'Comment cannot be empty'
678
- });
679
- commentText = inputComment;
680
- }
681
-
682
- const spinner = ora('Adding comment...').start();
683
- await api.post(`/issue/${issueKey}/comment`, {
684
- body: textToADF(commentText)
685
- });
686
- spinner.succeed(chalk.green(`Comment added to ${issueKey}.`));
687
-
688
- } catch (e) {
689
- if (e === '' || e.message === '') {
690
- console.log(chalk.yellow('\nCancelled.'));
691
- return;
692
- }
693
- console.error(chalk.red('\nFailed to add comment:'));
694
- if (e.response) {
695
- if (e.response.status === 404) {
696
- console.error(chalk.red(`Issue "${issueKey}" not found.`));
697
- } else {
698
- console.error(chalk.red(`Error ${e.response.status}: `), e.response.data);
699
- }
700
- } else {
701
- console.error(chalk.red(e.message));
702
- }
703
- }
704
- });
705
-
706
- program.addCommand(issueCmd);
707
- }
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import { table } from 'table';
4
+ import { api } from '../services/api-service.js';
5
+ import { aiService } from '../services/ai-service.js';
6
+ import ora from 'ora';
7
+ import enquirer from 'enquirer';
8
+ import { parseADF } from '../utils/adf-parser.js';
9
+ import { textToADF } from '../utils/text-to-adf.js';
10
+ import { validateIssueKey } from '../utils/validators.js';
11
+ import { handleCommandError } from '../utils/error-handler.js';
12
+
13
+ export function registerIssueCommand(program) {
14
+ const issueCmd = new Command('issue')
15
+ .description('Manage Jira issues')
16
+ .addHelpText('after', `
17
+ Common Actions:
18
+ $ jira issue list # List assigned issues
19
+ $ jira issue view <KEY> # View issue details
20
+ $ jira issue create # Create new issue (interactive)
21
+ $ jira issue transition <KEY> # Move issue status
22
+ `);
23
+
24
+ issueCmd
25
+ .command('list')
26
+ .description('List issues')
27
+ .option('-j, --jql <query>', 'JQL query to filter issues')
28
+ .option('--ask <query>', 'Filter issues using natural language query (AI)')
29
+ .option('-l, --limit <number>', 'Limit results', '20')
30
+ .option('-p, --project <key>', 'Filter by project')
31
+ .option('-a, --assignee <id>', 'Filter by assignee (use "currentUser" for self)')
32
+ .option('-s, --status <status>', 'Filter by status')
33
+ .option('-e, --export <format>', 'Export output (json, md)')
34
+ .option('-o, --output <format>', 'Output format (json)')
35
+ .addHelpText('after', `
36
+ Examples:
37
+ $ jira issue list --project PROJ --status "In Progress"
38
+ $ jira issue list --assignee currentUser --limit 10
39
+ $ jira issue list --jql "created >= -7d"
40
+ $ jira issue list --export json
41
+ `)
42
+ .action(async (options) => {
43
+ const spinner = ora('Fetching issues...').start();
44
+ try {
45
+ // Natural Language JQL
46
+ if (options.ask) {
47
+ const aiSpinner = ora(`Translating query: "${options.ask}"...`).start();
48
+ try {
49
+ const generatedJql = await aiService.generateJql(options.ask);
50
+ aiSpinner.succeed(`JQL: ${chalk.cyan(generatedJql)}`);
51
+ options.jql = generatedJql; // Override/Set JQL
52
+ } catch (e) {
53
+ aiSpinner.fail('Failed to translate query.');
54
+ console.error(chalk.red(e.message));
55
+ return;
56
+ }
57
+ }
58
+
59
+ const jqlParts = [];
60
+ if (options.project) jqlParts.push(`project = "${options.project}"`);
61
+ if (options.assignee) jqlParts.push(`assignee = ${options.assignee === 'currentUser' ? 'currentUser()' : `"${options.assignee}"`}`);
62
+ if (options.status) jqlParts.push(`status = "${options.status}"`);
63
+ if (options.jql) jqlParts.push(options.jql);
64
+
65
+ // Order by updated desc by default if no JQL
66
+ if (!options.jql && jqlParts.length === 0) {
67
+ jqlParts.push('order by updated DESC');
68
+ } else if (jqlParts.length > 0 && !options.jql) {
69
+ // Add order if not custom jql
70
+ // jqlParts.push('order by updated DESC');
71
+ }
72
+
73
+ const jql = jqlParts.join(' AND ');
74
+
75
+ const searchApi = '/search/jql';
76
+ const body = {
77
+ jql: jql || 'created is not empty',
78
+ maxResults: parseInt(options.limit),
79
+ fields: ['summary', 'status', 'assignee', 'created', 'updated', 'description']
80
+ };
81
+
82
+ const data = await api.post(searchApi, body);
83
+ spinner.stop();
84
+
85
+ if (!data.issues || data.issues.length === 0) {
86
+ console.log(chalk.yellow('No issues found.'));
87
+ return;
88
+ }
89
+
90
+ // Handling Export
91
+ if (options.export) {
92
+ const fs = await import('fs');
93
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
94
+
95
+ if (options.export === 'json') {
96
+ const filename = `issues-${timestamp}.json`;
97
+ fs.writeFileSync(filename, JSON.stringify(data.issues, null, 2));
98
+ console.log(chalk.green(`\nExported ${data.issues.length} issues to ${chalk.bold(filename)}`));
99
+ return;
100
+ }
101
+
102
+ if (options.export === 'md') {
103
+ const filename = `issues-${timestamp}.md`;
104
+ let mdContent = `# Jira Issues Export\nGenerated: ${new Date().toLocaleString()}\n\n`;
105
+ mdContent += `| Key | Summary | Status | Assignee |\n`;
106
+ mdContent += `|---|---|---|---|\n`;
107
+
108
+ data.issues.forEach(i => {
109
+ const key = i.key;
110
+ const summary = i.fields.summary || '';
111
+ const status = i.fields.status?.name || '';
112
+ const assignee = i.fields.assignee?.displayName || 'Unassigned';
113
+ mdContent += `| ${key} | ${summary} | ${status} | ${assignee} |\n`;
114
+ });
115
+
116
+ fs.writeFileSync(filename, mdContent);
117
+ console.log(chalk.green(`\nExported ${data.issues.length} issues to ${chalk.bold(filename)}`));
118
+ return;
119
+ }
120
+ }
121
+
122
+ if (options.output === 'json') {
123
+ console.log(JSON.stringify(data.issues.map(i => ({
124
+ key: i.key, summary: i.fields.summary,
125
+ status: i.fields.status?.name, assignee: i.fields.assignee?.displayName || null,
126
+ created: i.fields.created, updated: i.fields.updated
127
+ })), null, 2));
128
+ return;
129
+ }
130
+
131
+ const tableData = [
132
+ [chalk.bold('Key'), chalk.bold('Summary'), chalk.bold('Status'), chalk.bold('Assignee'), chalk.bold('Created'), chalk.bold('Updated')]
133
+ ];
134
+
135
+ data.issues.forEach(i => {
136
+ tableData.push([
137
+ chalk.cyan(i.key),
138
+ i.fields.summary ? (i.fields.summary.length > 50 ? i.fields.summary.substring(0, 47) + '...' : i.fields.summary) : '',
139
+ i.fields.status ? i.fields.status.name : '',
140
+ i.fields.assignee ? i.fields.assignee.displayName : 'Unassigned',
141
+ i.fields.created ? i.fields.created.split('T')[0] : '',
142
+ i.fields.updated ? i.fields.updated.split('T')[0] : ''
143
+ ]);
144
+ });
145
+
146
+ console.log(table(tableData));
147
+
148
+ } catch (e) {
149
+ spinner.fail('Failed to list issues');
150
+ if (e.response) {
151
+ console.error(chalk.red(`Error ${e.response.status}: `), e.response.data);
152
+ } else {
153
+ console.error(chalk.red(e.message));
154
+ }
155
+ }
156
+ });
157
+
158
+ issueCmd
159
+ .command('view')
160
+ .description('View issue details')
161
+ .argument('<issueKey>', 'Issue Key')
162
+ .option('-o, --output <format>', 'Output format (json)')
163
+ .addHelpText('after', `
164
+ Examples:
165
+ $ jira issue view PROJ-123
166
+ $ jira issue view PROJ-123 --output json
167
+ `)
168
+ .action(async (issueKey, options) => {
169
+ const check = validateIssueKey(issueKey);
170
+ if (!check.valid) { console.error(chalk.red(check.message)); return; }
171
+ const spinner = ora(`Fetching issue ${issueKey}...`).start();
172
+ try {
173
+ const issue = await api.get(`/issue/${issueKey}`);
174
+ spinner.stop();
175
+
176
+ if (options.output === 'json') {
177
+ console.log(JSON.stringify({
178
+ key: issue.key, summary: issue.fields.summary,
179
+ status: issue.fields.status?.name, priority: issue.fields.priority?.name,
180
+ assignee: issue.fields.assignee?.displayName || null,
181
+ type: issue.fields.issuetype?.name,
182
+ description: parseADF(issue.fields.description) || null,
183
+ created: issue.fields.created, updated: issue.fields.updated
184
+ }, null, 2));
185
+ return;
186
+ }
187
+
188
+ console.log(chalk.bold(`\n${issue.key}: ${issue.fields.summary}`));
189
+ console.log(chalk.grey(`${issue.fields.issuetype.name} - ${issue.fields.status.name} - ${issue.fields.priority ? issue.fields.priority.name : 'No Priority'}`));
190
+ console.log(chalk.bold('\nDescription:'));
191
+ console.log(parseADF(issue.fields.description) || 'No description provided.');
192
+
193
+ if (issue.fields.assignee) {
194
+ console.log(chalk.bold('\nAssignee: ') + issue.fields.assignee.displayName);
195
+ }
196
+
197
+ if (issue.fields.comment && issue.fields.comment.comments.length > 0) {
198
+ console.log(chalk.bold('\nComments:'));
199
+ issue.fields.comment.comments.forEach(c => {
200
+ console.log(chalk.cyan(c.author.displayName) + ': ' + c.body);
201
+ });
202
+ }
203
+ console.log('');
204
+ } catch (e) {
205
+ spinner.fail('Failed to fetch issue');
206
+ if (e.response) {
207
+ if (e.response.status === 404) {
208
+ console.error(chalk.red(`Issue "${issueKey}" not found.`));
209
+ } else {
210
+ console.error(chalk.red(`Error ${e.response.status}: `), e.response.data);
211
+ }
212
+ } else {
213
+ console.error(chalk.red(e.message));
214
+ }
215
+ }
216
+ });
217
+
218
+ // ── CREATE ────────────────────────────────────────────────────────
219
+ issueCmd
220
+ .command('create')
221
+ .description('Create a new Jira issue')
222
+ .option('-p, --project <key>', 'Project key')
223
+ .option('-t, --type <type>', 'Issue type (e.g., Bug, Story, Task)')
224
+ .option('-s, --summary <text>', 'Issue summary')
225
+ .option('-d, --description <text>', 'Issue description')
226
+ .option('--priority <name>', 'Priority name (e.g., High, Medium, Low)')
227
+ .option('-a, --assignee <id>', 'Assignee account ID (use "me" for self)')
228
+ .addHelpText('after', `
229
+ Examples:
230
+ $ jira issue create # Interactive wizard
231
+ $ jira issue create -p PROJ -s "Fix login bug" # Quick create
232
+ $ jira issue create -p PROJ -t Bug -s "Crash on save" --priority High
233
+ $ jira issue create -p PROJ -s "New feature" -a me
234
+ `)
235
+ .action(async (options) => {
236
+ try {
237
+ // ── Step 1: Select Project ──────────────────────────
238
+ let projectKey = options.project;
239
+ if (!projectKey) {
240
+ const spinner = ora('Fetching projects...').start();
241
+ const projectData = await api.get('/project/search');
242
+ spinner.stop();
243
+
244
+ if (!projectData.values || projectData.values.length === 0) {
245
+ console.error(chalk.red('No projects found. Check your permissions.'));
246
+ return;
247
+ }
248
+
249
+ const projectChoices = projectData.values.map(p => ({
250
+ name: p.key,
251
+ message: `${p.key} — ${p.name}`
252
+ }));
253
+
254
+ const { selectedProject } = await enquirer.prompt({
255
+ type: 'select',
256
+ name: 'selectedProject',
257
+ message: 'Select Project:',
258
+ choices: projectChoices
259
+ });
260
+ projectKey = selectedProject;
261
+ }
262
+
263
+ // ── Step 2: Select Issue Type ───────────────────────
264
+ let issueTypeName = options.type;
265
+ if (!issueTypeName) {
266
+ const spinner = ora('Fetching issue types...').start();
267
+ let issueTypes = [];
268
+ try {
269
+ // Jira Cloud v3 - createmeta endpoint
270
+ const metaData = await api.get(`/issue/createmeta/${projectKey}/issuetypes`);
271
+ issueTypes = metaData.issueTypes || metaData.values || [];
272
+ } catch (metaErr) {
273
+ // Fallback: use project-level issue types
274
+ try {
275
+ const projectInfo = await api.get(`/project/${projectKey}`);
276
+ issueTypes = projectInfo.issueTypes || [];
277
+ } catch {
278
+ issueTypes = [
279
+ { name: 'Task' }, { name: 'Bug' },
280
+ { name: 'Story' }, { name: 'Epic' }
281
+ ];
282
+ }
283
+ }
284
+ spinner.stop();
285
+
286
+ if (issueTypes.length === 0) {
287
+ issueTypes = [
288
+ { name: 'Task' }, { name: 'Bug' },
289
+ { name: 'Story' }, { name: 'Epic' }
290
+ ];
291
+ }
292
+
293
+ // Filter out sub-tasks if present
294
+ const filteredTypes = issueTypes.filter(t => !t.subtask);
295
+ const typeChoices = (filteredTypes.length > 0 ? filteredTypes : issueTypes)
296
+ .map(t => ({ name: t.name, message: t.name }));
297
+
298
+ const { selectedType } = await enquirer.prompt({
299
+ type: 'select',
300
+ name: 'selectedType',
301
+ message: 'Select Issue Type:',
302
+ choices: typeChoices
303
+ });
304
+ issueTypeName = selectedType;
305
+ }
306
+
307
+ // ── Step 3: Summary (required) ──────────────────────
308
+ let summary = options.summary;
309
+ if (!summary) {
310
+ const { inputSummary } = await enquirer.prompt({
311
+ type: 'input',
312
+ name: 'inputSummary',
313
+ message: 'Summary (required):',
314
+ validate: (val) => val.trim().length > 0 || 'Summary cannot be empty'
315
+ });
316
+ summary = inputSummary;
317
+ }
318
+
319
+ // ── Step 4: Description (optional) ──────────────────
320
+ let description = options.description;
321
+ if (description === undefined) {
322
+ const { inputDescription } = await enquirer.prompt({
323
+ type: 'input',
324
+ name: 'inputDescription',
325
+ message: 'Description (optional, press Enter to skip):'
326
+ });
327
+ description = inputDescription || null;
328
+ }
329
+
330
+ // ── Step 5: Priority ────────────────────────────────
331
+ let priorityName = options.priority;
332
+ if (!priorityName) {
333
+ const spinner = ora('Fetching priorities...').start();
334
+ try {
335
+ const priorities = await api.get('/priority');
336
+ spinner.stop();
337
+
338
+ if (Array.isArray(priorities) && priorities.length > 0) {
339
+ const priorityChoices = priorities.map(p => ({
340
+ name: p.name,
341
+ message: p.name
342
+ }));
343
+
344
+ const { selectedPriority } = await enquirer.prompt({
345
+ type: 'select',
346
+ name: 'selectedPriority',
347
+ message: 'Select Priority:',
348
+ choices: priorityChoices
349
+ });
350
+ priorityName = selectedPriority;
351
+ }
352
+ } catch {
353
+ spinner.stop();
354
+ // Priority endpoint may not be available; skip
355
+ }
356
+ }
357
+
358
+ // ── Step 6: Assignee ────────────────────────────────
359
+ let assigneeId = options.assignee;
360
+ if (!assigneeId) {
361
+ const { assigneeChoice } = await enquirer.prompt({
362
+ type: 'select',
363
+ name: 'assigneeChoice',
364
+ message: 'Assign to:',
365
+ choices: [
366
+ { name: 'me', message: 'Myself' },
367
+ { name: 'unassigned', message: 'Leave Unassigned' },
368
+ { name: 'search', message: 'Search for a user...' }
369
+ ]
370
+ });
371
+
372
+ if (assigneeChoice === 'me') {
373
+ const spinner = ora('Fetching your account...').start();
374
+ try {
375
+ const myself = await api.get('/myself');
376
+ assigneeId = myself.accountId;
377
+ spinner.stop();
378
+ } catch {
379
+ spinner.fail('Could not fetch your account. Leaving unassigned.');
380
+ assigneeId = null;
381
+ }
382
+ } else if (assigneeChoice === 'search') {
383
+ const { searchQuery } = await enquirer.prompt({
384
+ type: 'input',
385
+ name: 'searchQuery',
386
+ message: 'Search user by name or email:'
387
+ });
388
+
389
+ if (searchQuery.trim()) {
390
+ const spinner = ora('Searching users...').start();
391
+ try {
392
+ const users = await api.get(`/user/search?query=${encodeURIComponent(searchQuery)}`);
393
+ spinner.stop();
394
+
395
+ if (Array.isArray(users) && users.length > 0) {
396
+ const userChoices = users.map(u => ({
397
+ name: u.accountId,
398
+ message: `${u.displayName} (${u.emailAddress || u.accountId})`
399
+ }));
400
+
401
+ const { selectedUser } = await enquirer.prompt({
402
+ type: 'select',
403
+ name: 'selectedUser',
404
+ message: 'Select User:',
405
+ choices: userChoices
406
+ });
407
+ assigneeId = selectedUser;
408
+ } else {
409
+ console.log(chalk.yellow('No users found. Leaving unassigned.'));
410
+ assigneeId = null;
411
+ }
412
+ } catch {
413
+ spinner.fail('User search failed. Leaving unassigned.');
414
+ assigneeId = null;
415
+ }
416
+ }
417
+ } else {
418
+ assigneeId = null;
419
+ }
420
+ } else if (assigneeId === 'me') {
421
+ // --assignee me flag: resolve to account ID
422
+ const spinner = ora('Fetching your account...').start();
423
+ try {
424
+ const myself = await api.get('/myself');
425
+ assigneeId = myself.accountId;
426
+ spinner.stop();
427
+ } catch {
428
+ spinner.fail('Could not fetch your account. Leaving unassigned.');
429
+ assigneeId = null;
430
+ }
431
+ }
432
+
433
+ // ── Confirmation ────────────────────────────────────
434
+ console.log(chalk.blue('\n── Issue Summary ──────────────────'));
435
+ console.log(` Project: ${chalk.cyan(projectKey)}`);
436
+ console.log(` Type: ${issueTypeName}`);
437
+ console.log(` Summary: ${summary}`);
438
+ console.log(` Description: ${description || chalk.grey('(none)')}`);
439
+ console.log(` Priority: ${priorityName || chalk.grey('(default)')}`);
440
+ console.log(` Assignee: ${assigneeId || chalk.grey('Unassigned')}`);
441
+ console.log(chalk.blue('──────────────────────────────────\n'));
442
+
443
+ const { confirmed } = await enquirer.prompt({
444
+ type: 'confirm',
445
+ name: 'confirmed',
446
+ message: 'Create this issue?',
447
+ initial: true
448
+ });
449
+
450
+ if (!confirmed) {
451
+ console.log(chalk.yellow('Issue creation cancelled.'));
452
+ return;
453
+ }
454
+
455
+ // ── Build Request Body ──────────────────────────────
456
+ const issueBody = {
457
+ fields: {
458
+ project: { key: projectKey },
459
+ issuetype: { name: issueTypeName },
460
+ summary: summary
461
+ }
462
+ };
463
+
464
+ if (description) {
465
+ issueBody.fields.description = textToADF(description);
466
+ }
467
+
468
+ if (priorityName) {
469
+ issueBody.fields.priority = { name: priorityName };
470
+ }
471
+
472
+ if (assigneeId) {
473
+ issueBody.fields.assignee = { accountId: assigneeId };
474
+ }
475
+
476
+ // ── Create Issue ────────────────────────────────────
477
+ const spinner = ora('Creating issue...').start();
478
+ const result = await api.post('/issue', issueBody);
479
+ spinner.succeed(chalk.green(`Issue created: ${chalk.bold(result.key)}`));
480
+
481
+ console.log(chalk.grey(`View it: jira issue view ${result.key}`));
482
+
483
+ } catch (e) {
484
+ if (e === '' || e.message === '') {
485
+ // User cancelled prompt (Ctrl+C)
486
+ console.log(chalk.yellow('\nCancelled.'));
487
+ return;
488
+ }
489
+ console.error(chalk.red('\nFailed to create issue:'));
490
+ if (e.response) {
491
+ console.error(chalk.red(`Error ${e.response.status}: `), JSON.stringify(e.response.data, null, 2));
492
+ } else {
493
+ console.error(chalk.red(e.message));
494
+ }
495
+ }
496
+ });
497
+
498
+ // ── TRANSITION ────────────────────────────────────────────────────
499
+ issueCmd
500
+ .command('transition')
501
+ .description('Transition an issue to a new status')
502
+ .argument('<issueKey>', 'Issue Key (e.g., PROJ-123)')
503
+ .option('-s, --status <name>', 'Target status name (skips interactive selection)')
504
+ .addHelpText('after', `
505
+ Examples:
506
+ $ jira issue transition PROJ-123 # Interactive
507
+ $ jira issue transition PROJ-123 --status "In Progress"
508
+ $ jira issue transition PROJ-123 -s Done
509
+ `)
510
+ .action(async (issueKey, options) => {
511
+ const check = validateIssueKey(issueKey);
512
+ if (!check.valid) { console.error(chalk.red(check.message)); return; }
513
+ const spinner = ora(`Fetching transitions for ${issueKey}...`).start();
514
+ try {
515
+ // Fetch current issue to show context
516
+ const issue = await api.get(`/issue/${issueKey}?fields=summary,status`);
517
+ const currentStatus = issue.fields.status.name;
518
+
519
+ // Fetch available transitions
520
+ const transData = await api.get(`/issue/${issueKey}/transitions`);
521
+ spinner.stop();
522
+
523
+ if (!transData.transitions || transData.transitions.length === 0) {
524
+ console.log(chalk.yellow(`No transitions available for ${issueKey} (current status: ${currentStatus}).`));
525
+ return;
526
+ }
527
+
528
+ console.log(chalk.bold(`\n${issue.key}: ${issue.fields.summary}`));
529
+ console.log(chalk.grey(`Current Status: ${currentStatus}\n`));
530
+
531
+ let targetTransition;
532
+
533
+ if (options.status) {
534
+ // Non-interactive: find matching transition
535
+ targetTransition = transData.transitions.find(
536
+ t => t.name.toLowerCase() === options.status.toLowerCase() ||
537
+ t.to.name.toLowerCase() === options.status.toLowerCase()
538
+ );
539
+
540
+ if (!targetTransition) {
541
+ console.error(chalk.red(`Status "${options.status}" is not a valid transition from "${currentStatus}".`));
542
+ console.log(chalk.grey('Available transitions:'));
543
+ transData.transitions.forEach(t => {
544
+ console.log(chalk.grey(` • ${t.name} ${t.to.name}`));
545
+ });
546
+ return;
547
+ }
548
+ } else {
549
+ // Interactive: show selection
550
+ const transitionChoices = transData.transitions.map(t => ({
551
+ name: t.id,
552
+ message: `${t.name} ${chalk.cyan(t.to.name)}`
553
+ }));
554
+
555
+ const { selectedTransition } = await enquirer.prompt({
556
+ type: 'select',
557
+ name: 'selectedTransition',
558
+ message: 'Select transition:',
559
+ choices: transitionChoices
560
+ });
561
+
562
+ targetTransition = transData.transitions.find(t => t.id === selectedTransition);
563
+ }
564
+
565
+ // Execute transition
566
+ const execSpinner = ora(`Transitioning to "${targetTransition.to.name}"...`).start();
567
+ await api.post(`/issue/${issueKey}/transitions`, {
568
+ transition: { id: targetTransition.id }
569
+ });
570
+ execSpinner.succeed(chalk.green(`${issueKey} transitioned: ${currentStatus} ${chalk.bold(targetTransition.to.name)}`));
571
+
572
+ } catch (e) {
573
+ spinner.stop();
574
+ if (e === '' || e.message === '') {
575
+ console.log(chalk.yellow('\nCancelled.'));
576
+ return;
577
+ }
578
+ console.error(chalk.red('\nFailed to transition issue:'));
579
+ if (e.response) {
580
+ if (e.response.status === 404) {
581
+ console.error(chalk.red(`Issue "${issueKey}" not found.`));
582
+ } else {
583
+ console.error(chalk.red(`Error ${e.response.status}: `), e.response.data);
584
+ }
585
+ } else {
586
+ console.error(chalk.red(e.message));
587
+ }
588
+ }
589
+ });
590
+ // ── ASSIGN ────────────────────────────────────────────────────────
591
+ issueCmd
592
+ .command('assign')
593
+ .description('Assign or reassign an issue')
594
+ .argument('<issueKey>', 'Issue Key (e.g., PROJ-123)')
595
+ .option('-a, --assignee <id>', 'Assignee account ID (use "me" for self, "none" to unassign)')
596
+ .addHelpText('after', `
597
+ Examples:
598
+ $ jira issue assign PROJ-123 # Interactive
599
+ $ jira issue assign PROJ-123 -a me # Assign to yourself
600
+ $ jira issue assign PROJ-123 -a none # Unassign
601
+ `)
602
+ .action(async (issueKey, options) => {
603
+ const check = validateIssueKey(issueKey);
604
+ if (!check.valid) { console.error(chalk.red(check.message)); return; }
605
+ try {
606
+ let assigneeId = options.assignee;
607
+
608
+ if (!assigneeId) {
609
+ // Interactive selection
610
+ const spinner = ora(`Fetching issue ${issueKey}...`).start();
611
+ const issue = await api.get(`/issue/${issueKey}?fields=summary,assignee`);
612
+ spinner.stop();
613
+
614
+ const currentAssignee = issue.fields.assignee?.displayName || 'Unassigned';
615
+ console.log(chalk.bold(`\n${issue.key}: ${issue.fields.summary}`));
616
+ console.log(chalk.grey(`Current Assignee: ${currentAssignee}\n`));
617
+
618
+ const { assignChoice } = await enquirer.prompt({
619
+ type: 'select',
620
+ name: 'assignChoice',
621
+ message: 'Assign to:',
622
+ choices: [
623
+ { name: 'me', message: 'Myself' },
624
+ { name: 'none', message: 'Unassign' },
625
+ { name: 'search', message: 'Search for a user...' }
626
+ ]
627
+ });
628
+ assigneeId = assignChoice;
629
+ }
630
+
631
+ if (assigneeId === 'me') {
632
+ const spinner = ora('Fetching your account...').start();
633
+ const myself = await api.get('/myself');
634
+ assigneeId = myself.accountId;
635
+ spinner.stop();
636
+ }
637
+
638
+ if (assigneeId === 'search') {
639
+ const { searchQuery } = await enquirer.prompt({
640
+ type: 'input',
641
+ name: 'searchQuery',
642
+ message: 'Search user by name or email:'
643
+ });
644
+
645
+ const spinner = ora('Searching users...').start();
646
+ const users = await api.get(`/user/search?query=${encodeURIComponent(searchQuery)}`);
647
+ spinner.stop();
648
+
649
+ if (!Array.isArray(users) || users.length === 0) {
650
+ console.log(chalk.yellow('No users found.'));
651
+ return;
652
+ }
653
+
654
+ const { selectedUser } = await enquirer.prompt({
655
+ type: 'select',
656
+ name: 'selectedUser',
657
+ message: 'Select User:',
658
+ choices: users.map(u => ({
659
+ name: u.accountId,
660
+ message: `${u.displayName} (${u.emailAddress || u.accountId})`
661
+ }))
662
+ });
663
+ assigneeId = selectedUser;
664
+ }
665
+
666
+ const spinner = ora('Updating assignee...').start();
667
+ const body = assigneeId === 'none'
668
+ ? { accountId: null }
669
+ : { accountId: assigneeId };
670
+
671
+ await api.put(`/issue/${issueKey}/assignee`, body);
672
+ spinner.succeed(chalk.green(`${issueKey} ${assigneeId === 'none' ? 'unassigned' : 'assigned'} successfully.`));
673
+
674
+ } catch (e) {
675
+ if (e === '' || e.message === '') {
676
+ console.log(chalk.yellow('\nCancelled.'));
677
+ return;
678
+ }
679
+ console.error(chalk.red('\nFailed to assign issue:'));
680
+ if (e.response) {
681
+ console.error(chalk.red(`Error ${e.response.status}: `), e.response.data);
682
+ } else {
683
+ console.error(chalk.red(e.message));
684
+ }
685
+ }
686
+ });
687
+
688
+ // ── COMMENT ───────────────────────────────────────────────────────
689
+ issueCmd
690
+ .command('comment')
691
+ .description('Add a comment to an issue')
692
+ .argument('<issueKey>', 'Issue Key (e.g., PROJ-123)')
693
+ .option('-m, --message <text>', 'Comment text (skips interactive prompt)')
694
+ .addHelpText('after', `
695
+ Examples:
696
+ $ jira issue comment PROJ-123 # Interactive
697
+ $ jira issue comment PROJ-123 -m "Fixed in latest build"
698
+ `)
699
+ .action(async (issueKey, options) => {
700
+ const check = validateIssueKey(issueKey);
701
+ if (!check.valid) { console.error(chalk.red(check.message)); return; }
702
+ try {
703
+ let commentText = options.message;
704
+
705
+ if (!commentText) {
706
+ // Show issue context first
707
+ const spinner = ora(`Fetching issue ${issueKey}...`).start();
708
+ const issue = await api.get(`/issue/${issueKey}?fields=summary,status`);
709
+ spinner.stop();
710
+
711
+ console.log(chalk.bold(`\n${issue.key}: ${issue.fields.summary}`));
712
+ console.log(chalk.grey(`Status: ${issue.fields.status.name}\n`));
713
+
714
+ const { inputComment } = await enquirer.prompt({
715
+ type: 'input',
716
+ name: 'inputComment',
717
+ message: 'Enter your comment:',
718
+ validate: (val) => val.trim().length > 0 || 'Comment cannot be empty'
719
+ });
720
+ commentText = inputComment;
721
+ }
722
+
723
+ const spinner = ora('Adding comment...').start();
724
+ await api.post(`/issue/${issueKey}/comment`, {
725
+ body: textToADF(commentText)
726
+ });
727
+ spinner.succeed(chalk.green(`Comment added to ${issueKey}.`));
728
+
729
+ } catch (e) {
730
+ if (e === '' || e.message === '') {
731
+ console.log(chalk.yellow('\nCancelled.'));
732
+ return;
733
+ }
734
+ console.error(chalk.red('\nFailed to add comment:'));
735
+ if (e.response) {
736
+ if (e.response.status === 404) {
737
+ console.error(chalk.red(`Issue "${issueKey}" not found.`));
738
+ } else {
739
+ console.error(chalk.red(`Error ${e.response.status}: `), e.response.data);
740
+ }
741
+ } else {
742
+ console.error(chalk.red(e.message));
743
+ }
744
+ }
745
+ });
746
+
747
+ // ── EDIT ──────────────────────────────────────────────────────────
748
+ issueCmd
749
+ .command('edit')
750
+ .description('Edit issue fields')
751
+ .argument('<issueKey>', 'Issue Key (e.g., PROJ-123)')
752
+ .option('-s, --summary <text>', 'New summary')
753
+ .option('-d, --description <text>', 'New description')
754
+ .option('--priority <name>', 'New priority')
755
+ .addHelpText('after', `
756
+ Examples:
757
+ $ jira issue edit PROJ-123 # Interactive field picker
758
+ $ jira issue edit PROJ-123 -s "Updated title"
759
+ $ jira issue edit PROJ-123 --priority High
760
+ $ jira issue edit PROJ-123 -d "New description"
761
+ `)
762
+ .action(async (issueKey, options) => {
763
+ const check = validateIssueKey(issueKey);
764
+ if (!check.valid) { console.error(chalk.red(check.message)); return; }
765
+ const spinner = ora(`Fetching issue ${issueKey}...`).start();
766
+ try {
767
+ const issue = await api.get(`/issue/${issueKey}?fields=summary,description,priority`);
768
+ spinner.stop();
769
+
770
+ const updateBody = { fields: {} };
771
+ const hasFlags = options.summary || options.description || options.priority;
772
+
773
+ if (hasFlags) {
774
+ if (options.summary) updateBody.fields.summary = options.summary;
775
+ if (options.description) updateBody.fields.description = textToADF(options.description);
776
+ if (options.priority) updateBody.fields.priority = { name: options.priority };
777
+ } else {
778
+ // Interactive: pick which fields to edit
779
+ console.log(chalk.bold(`\nEditing ${chalk.cyan(issueKey)}: ${issue.fields.summary}\n`));
780
+
781
+ const { Select, Input } = enquirer;
782
+
783
+ const fieldSelect = new Select({
784
+ name: 'fields',
785
+ message: 'Select fields to edit',
786
+ choices: [
787
+ { name: 'summary', message: `Summary: ${issue.fields.summary}` },
788
+ { name: 'description', message: 'Description' },
789
+ { name: 'priority', message: `Priority: ${issue.fields.priority?.name || 'None'}` }
790
+ ],
791
+ multiple: true
792
+ });
793
+ const selectedFields = await fieldSelect.run();
794
+
795
+ if (!selectedFields || selectedFields.length === 0) {
796
+ console.log(chalk.yellow('No fields selected.'));
797
+ return;
798
+ }
799
+
800
+ for (const field of selectedFields) {
801
+ if (field === 'summary') {
802
+ const prompt = new Input({ message: 'New summary', initial: issue.fields.summary });
803
+ updateBody.fields.summary = await prompt.run();
804
+ }
805
+ if (field === 'description') {
806
+ const prompt = new Input({ message: 'New description' });
807
+ const desc = await prompt.run();
808
+ if (desc) updateBody.fields.description = textToADF(desc);
809
+ }
810
+ if (field === 'priority') {
811
+ const priorities = await api.get('/priority');
812
+ const prioSelect = new Select({
813
+ name: 'priority',
814
+ message: 'Select priority',
815
+ choices: priorities.map(p => ({ name: p.name, message: p.name }))
816
+ });
817
+ updateBody.fields.priority = { name: await prioSelect.run() };
818
+ }
819
+ }
820
+ }
821
+
822
+ if (Object.keys(updateBody.fields).length === 0) {
823
+ console.log(chalk.yellow('No changes specified.'));
824
+ return;
825
+ }
826
+
827
+ const updateSpinner = ora('Updating issue...').start();
828
+ await api.put(`/issue/${issueKey}`, updateBody);
829
+ updateSpinner.succeed(`${chalk.cyan(issueKey)} updated successfully`);
830
+
831
+ } catch (e) {
832
+ handleCommandError(spinner, e, `Failed to edit ${issueKey}`);
833
+ }
834
+ });
835
+
836
+ // ── SEARCH ────────────────────────────────────────────────────────
837
+ issueCmd
838
+ .command('search')
839
+ .description('Quick text search across issues')
840
+ .argument('<query>', 'Search text')
841
+ .option('-p, --project <key>', 'Filter by project')
842
+ .option('-l, --limit <n>', 'Max results', '15')
843
+ .option('-o, --output <format>', 'Output format (json)')
844
+ .addHelpText('after', `
845
+ Examples:
846
+ $ jira issue search "login bug"
847
+ $ jira issue search "payment" -p PROJ
848
+ $ jira issue search "crash" --output json
849
+ `)
850
+ .action(async (query, options) => {
851
+ const spinner = ora(`Searching for "${query}"...`).start();
852
+ try {
853
+ const jqlParts = [`text ~ "${query.replace(/"/g, '\\"')}"`];
854
+ if (options.project) jqlParts.push(`project = "${options.project}"`);
855
+ const jql = jqlParts.join(' AND ') + ' ORDER BY updated DESC';
856
+
857
+ const data = await api.post('/search/jql', {
858
+ jql,
859
+ maxResults: parseInt(options.limit),
860
+ fields: ['summary', 'status', 'assignee', 'updated']
861
+ });
862
+ spinner.stop();
863
+
864
+ if (!data.issues || data.issues.length === 0) {
865
+ console.log(chalk.yellow('No issues found.'));
866
+ return;
867
+ }
868
+
869
+ if (options.output === 'json') {
870
+ console.log(JSON.stringify(data.issues.map(i => ({
871
+ key: i.key, summary: i.fields.summary,
872
+ status: i.fields.status?.name, assignee: i.fields.assignee?.displayName || null,
873
+ updated: i.fields.updated
874
+ })), null, 2));
875
+ return;
876
+ }
877
+
878
+ const tableData = [
879
+ [chalk.bold('Key'), chalk.bold('Summary'), chalk.bold('Status'), chalk.bold('Assignee')]
880
+ ];
881
+ data.issues.forEach(i => {
882
+ tableData.push([
883
+ chalk.cyan(i.key),
884
+ i.fields.summary ? (i.fields.summary.length > 55 ? i.fields.summary.substring(0, 52) + '...' : i.fields.summary) : '',
885
+ i.fields.status?.name || '',
886
+ i.fields.assignee?.displayName || 'Unassigned'
887
+ ]);
888
+ });
889
+ console.log(table(tableData));
890
+ console.log(chalk.grey(`Found ${data.issues.length} result(s)`));
891
+
892
+ } catch (e) {
893
+ handleCommandError(spinner, e, 'Search failed');
894
+ }
895
+ });
896
+
897
+ // ── LINK ──────────────────────────────────────────────────────────
898
+ issueCmd
899
+ .command('link')
900
+ .description('Link two issues together')
901
+ .argument('<sourceKey>', 'Source issue key')
902
+ .argument('<targetKey>', 'Target issue key')
903
+ .option('-t, --type <name>', 'Link type (e.g., "Blocks", "Relates")')
904
+ .addHelpText('after', `
905
+ Examples:
906
+ $ jira issue link PROJ-1 PROJ-2 # Interactive type selection
907
+ $ jira issue link PROJ-1 PROJ-2 -t "Blocks"
908
+ $ jira issue link PROJ-1 PROJ-2 -t "Relates"
909
+ `)
910
+ .action(async (sourceKey, targetKey, options) => {
911
+ const srcCheck = validateIssueKey(sourceKey);
912
+ if (!srcCheck.valid) { console.error(chalk.red(srcCheck.message)); return; }
913
+ const tgtCheck = validateIssueKey(targetKey);
914
+ if (!tgtCheck.valid) { console.error(chalk.red(tgtCheck.message)); return; }
915
+
916
+ try {
917
+ let linkType = options.type;
918
+
919
+ if (!linkType) {
920
+ const spinner = ora('Fetching link types...').start();
921
+ const linkTypes = await api.get('/issueLinkType');
922
+ spinner.stop();
923
+
924
+ const { Select } = enquirer;
925
+ const typeSelect = new Select({
926
+ name: 'linkType',
927
+ message: `Link type: ${chalk.cyan(sourceKey)} → ${chalk.cyan(targetKey)}`,
928
+ choices: linkTypes.issueLinkTypes.map(lt => ({
929
+ name: lt.name,
930
+ message: `${lt.name} (${lt.inward} / ${lt.outward})`
931
+ }))
932
+ });
933
+ linkType = await typeSelect.run();
934
+ }
935
+
936
+ const spinner = ora(`Linking ${sourceKey} → ${targetKey}...`).start();
937
+ await api.post('/issueLink', {
938
+ type: { name: linkType },
939
+ inwardIssue: { key: sourceKey },
940
+ outwardIssue: { key: targetKey }
941
+ });
942
+ spinner.succeed(`Linked ${chalk.cyan(sourceKey)} ${chalk.grey(`—[${linkType}]→`)} ${chalk.cyan(targetKey)}`);
943
+
944
+ } catch (e) {
945
+ handleCommandError(null, e, `Failed to link issues`);
946
+ }
947
+ });
948
+
949
+ // ── WATCH ─────────────────────────────────────────────────────────
950
+ issueCmd
951
+ .command('watch')
952
+ .description('Start watching an issue')
953
+ .argument('<issueKey>', 'Issue Key')
954
+ .action(async (issueKey) => {
955
+ const check = validateIssueKey(issueKey);
956
+ if (!check.valid) { console.error(chalk.red(check.message)); return; }
957
+ const spinner = ora(`Watching ${issueKey}...`).start();
958
+ try {
959
+ await api.post(`/issue/${issueKey}/watchers`, null);
960
+ spinner.succeed(`Now watching ${chalk.cyan(issueKey)}`);
961
+ } catch (e) {
962
+ handleCommandError(spinner, e, `Failed to watch ${issueKey}`);
963
+ }
964
+ });
965
+
966
+ // ── UNWATCH ───────────────────────────────────────────────────────
967
+ issueCmd
968
+ .command('unwatch')
969
+ .description('Stop watching an issue')
970
+ .argument('<issueKey>', 'Issue Key')
971
+ .action(async (issueKey) => {
972
+ const check = validateIssueKey(issueKey);
973
+ if (!check.valid) { console.error(chalk.red(check.message)); return; }
974
+ const spinner = ora(`Unwatching ${issueKey}...`).start();
975
+ try {
976
+ const me = await api.get('/myself');
977
+ await api.delete(`/issue/${issueKey}/watchers?accountId=${me.accountId}`);
978
+ spinner.succeed(`Stopped watching ${chalk.cyan(issueKey)}`);
979
+ } catch (e) {
980
+ handleCommandError(spinner, e, `Failed to unwatch ${issueKey}`);
981
+ }
982
+ });
983
+
984
+ program.addCommand(issueCmd);
985
+ }