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