jira-pilot 2.0.4 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +216 -173
  2. package/bin/{jira.js → jira.ts} +10 -1
  3. package/dist/bin/jira.js +64 -0
  4. package/package.json +21 -15
  5. package/src/commands/ai-actions/{plan.js → plan.ts} +9 -9
  6. package/src/commands/ai-actions/{review.js → review.ts} +2 -2
  7. package/src/commands/ai-actions/{standup.js → standup.ts} +4 -4
  8. package/src/commands/{ai.js → ai.ts} +11 -11
  9. package/src/commands/{board.js → board.ts} +11 -11
  10. package/src/commands/bulk.ts +230 -0
  11. package/src/commands/{config.js → config.ts} +57 -8
  12. package/src/commands/dashboard.ts +222 -0
  13. package/src/commands/filter.ts +84 -0
  14. package/src/commands/{git.js → git.ts} +4 -4
  15. package/src/commands/issue-attach.ts +44 -0
  16. package/src/commands/issue-pr.ts +87 -0
  17. package/src/commands/issue-worklog.ts +90 -0
  18. package/src/commands/{issue.js → issue.ts} +359 -68
  19. package/src/commands/{mcp.js → mcp.ts} +2 -2
  20. package/src/commands/{project.js → project.ts} +11 -11
  21. package/src/commands/sprint.ts +269 -0
  22. package/src/server/{mcp-server.js → mcp-server.ts} +235 -8
  23. package/src/services/{ai-service.js → ai-service.ts} +16 -16
  24. package/src/services/{api-service.js → api-service.ts} +33 -9
  25. package/src/services/config-service.ts +21 -0
  26. package/src/types.ts +68 -0
  27. package/src/utils/{adf-parser.js → adf-parser.ts} +12 -12
  28. package/src/utils/config-store.ts +109 -0
  29. package/src/utils/{config.js → config.ts} +14 -41
  30. package/src/utils/{error-handler.js → error-handler.ts} +2 -1
  31. package/src/utils/{text-to-adf.js → text-to-adf.ts} +1 -1
  32. package/src/utils/{validators.js → validators.ts} +4 -4
  33. package/src/commands/bulk.js +0 -108
  34. package/src/commands/dashboard.js +0 -89
  35. package/src/commands/sprint.js +0 -153
package/package.json CHANGED
@@ -1,23 +1,27 @@
1
1
  {
2
2
  "name": "jira-pilot",
3
- "version": "2.0.4",
3
+ "version": "2.1.0",
4
4
  "description": "AI powered Jira CLI and MCP server for humans and agents — manage issues, sprints, boards with interactive wizards, multi-provider AI (OpenAI/Gemini/Anthropic), and an 8-tool MCP server for AI assistants",
5
- "main": "src/index.js",
5
+ "main": "dist/src/index.js",
6
6
  "type": "module",
7
7
  "bin": {
8
- "jira": "./bin/jira.js",
9
- "jira-pilot": "./bin/jira.js"
8
+ "jira": "./dist/bin/jira.js",
9
+ "jira-pilot": "./dist/bin/jira.js"
10
10
  },
11
11
  "scripts": {
12
- "start": "node bin/jira.js",
13
- "mcp": "node bin/jira.js mcp",
12
+ "build": "tsc",
13
+ "start": "node dist/bin/jira.js",
14
+ "start:dev": "tsx bin/jira.ts",
15
+ "mcp": "node dist/bin/jira.js mcp",
16
+ "mcp:dev": "tsx bin/jira.ts mcp",
14
17
  "test": "vitest run",
15
18
  "test:watch": "vitest",
16
19
  "test:coverage": "vitest run --coverage",
17
- "link": "npm link"
20
+ "link": "npm link",
21
+ "typecheck": "tsc --noEmit"
18
22
  },
19
23
  "engines": {
20
- "node": ">=18.0.0"
24
+ "node": ">=20.0.0"
21
25
  },
22
26
  "files": [
23
27
  "bin/",
@@ -74,18 +78,20 @@
74
78
  "homepage": "https://github.com/Aarul5/jira-pilot#readme",
75
79
  "license": "ISC",
76
80
  "dependencies": {
77
- "@modelcontextprotocol/sdk": "^0.6.0",
78
- "axios": "^1.6.0",
81
+ "@modelcontextprotocol/sdk": "^1.26.0",
82
+ "axios": "^1.13.5",
79
83
  "chalk": "^5.3.0",
80
- "commander": "^11.1.0",
81
- "conf": "^12.0.0",
84
+ "cli-table3": "^0.6.5",
85
+ "commander": "^14.0.3",
82
86
  "enquirer": "^2.4.1",
83
- "open": "^10.0.0",
84
- "ora": "^8.0.0",
85
- "table": "^6.8.0"
87
+ "open": "^11.0.0",
88
+ "ora": "^9.3.0"
86
89
  },
87
90
  "devDependencies": {
91
+ "@types/node": "^25.2.3",
88
92
  "@vitest/coverage-v8": "^4.0.18",
93
+ "tsx": "^4.21.0",
94
+ "typescript": "^5.9.3",
89
95
  "vitest": "^4.0.18"
90
96
  }
91
97
  }
@@ -7,7 +7,7 @@ import { validateIssueKey } from '../../utils/validators.js';
7
7
  import { parseADF } from '../../utils/adf-parser.js';
8
8
  import { handleCommandError } from '../../utils/error-handler.js';
9
9
 
10
- export async function planAction(epicKey, options) {
10
+ export async function planAction(epicKey: string, options: any) {
11
11
  const check = validateIssueKey(epicKey);
12
12
  if (!check.valid) { console.error(chalk.red(check.message)); return; }
13
13
 
@@ -33,7 +33,7 @@ export async function planAction(epicKey, options) {
33
33
  console.log(chalk.cyan(`\nProposed Breakdown for ${epicKey} (${summary}):\n`));
34
34
 
35
35
  // Let user select items to create
36
- const choices = plan.map((item, index) => ({
36
+ const choices = plan.map((item: any, index: number) => ({
37
37
  name: `${item.type}: ${item.summary}`,
38
38
  value: index, // store index to retrieve item
39
39
  checked: true
@@ -43,16 +43,16 @@ export async function planAction(epicKey, options) {
43
43
  type: 'multiselect',
44
44
  name: 'selectedIndices',
45
45
  message: 'Select issues to create:',
46
- choices: choices.map((c, i) => ({ ...c, value: i })), // ensure value is index
47
- result(names) {
46
+ choices: choices.map((c: any, i: number) => ({ ...c, value: i })), // ensure value is index
47
+ result(names: any) {
48
48
  // map names back to indices
49
- return names.map(name => this.map(name)); // 'this.map' returns value (index)
49
+ return names.map((name: any) => (this as any).map(name)); // 'this.map' returns value (index)
50
50
  }
51
- });
51
+ }) as any;
52
52
 
53
53
  // Loop through selected and create
54
54
  // Convert map result (object/array) to array of indices
55
- const indicesToCreate = Object.values(selectedIndices);
55
+ const indicesToCreate = Object.values(selectedIndices) as number[];
56
56
 
57
57
  if (indicesToCreate.length === 0) {
58
58
  console.log(chalk.yellow('No items selected.'));
@@ -71,7 +71,7 @@ export async function planAction(epicKey, options) {
71
71
  fields: {
72
72
  project: { key: projectKey },
73
73
  summary: item.summary,
74
- description: item.description, // Simple string, Jira converts to ADF or accepts text depending on config.
74
+ // description: item.description, // Simple string, Jira converts to ADF or accepts text depending on config.
75
75
  // Note: v3 API often needs ADF. If description is simple text, it might fail.
76
76
  // We should construct a basic paragraph ADF document.
77
77
  description: {
@@ -113,7 +113,7 @@ export async function planAction(epicKey, options) {
113
113
 
114
114
  console.log(chalk.green(`\nDone! Created ${results.length} issues linked to ${epicKey}.`));
115
115
 
116
- } catch (e) {
116
+ } catch (e: any) {
117
117
  handleCommandError(spinner, e, `Failed to plan ${epicKey}`);
118
118
  }
119
119
  }
@@ -9,7 +9,7 @@ import { getCredentials } from '../../utils/config.js';
9
9
  import { parseADF } from '../../utils/adf-parser.js';
10
10
  import { handleCommandError } from '../../utils/error-handler.js';
11
11
 
12
- export async function reviewAction(issueKey, options) {
12
+ export async function reviewAction(issueKey: string, options: any) {
13
13
  const check = validateIssueKey(issueKey);
14
14
  if (!check.valid) { console.error(chalk.red(check.message)); return; }
15
15
 
@@ -96,7 +96,7 @@ export async function reviewAction(issueKey, options) {
96
96
  console.log(review);
97
97
  console.log(chalk.dim(`\nPR Link: ${pr.html_url}`));
98
98
 
99
- } catch (e) {
99
+ } catch (e: any) {
100
100
  handleCommandError(spinner, e, `Failed to review ${issueKey}`);
101
101
  }
102
102
  }
@@ -5,7 +5,7 @@ import { aiService } from '../../services/ai-service.js';
5
5
  import { parseADF } from '../../utils/adf-parser.js';
6
6
  import { handleCommandError } from '../../utils/error-handler.js';
7
7
 
8
- export async function standupAction(options) {
8
+ export async function standupAction(options: any) {
9
9
  const spinner = ora('Analyzing your recent activity...').start();
10
10
 
11
11
  try {
@@ -20,12 +20,12 @@ export async function standupAction(options) {
20
20
  const yesterdayJql = `assignee = currentUser() AND updated >= -1d ORDER BY updated DESC`;
21
21
 
22
22
  const yesterdayIssuesRes = await api.get(`/search?jql=${encodeURIComponent(yesterdayJql)}&fields=summary,status,comment,worklog`);
23
- const yesterdayIssues = yesterdayIssuesRes.issues.map(i => `- ${i.key} ${i.fields.summary} (${i.fields.status.name})`).join('\n');
23
+ const yesterdayIssues = yesterdayIssuesRes.issues.map((i: any) => `- ${i.key} ${i.fields.summary} (${i.fields.status.name})`).join('\n');
24
24
 
25
25
  // 3. Fetch Issues Assigned for Today (In Progress or To Do)
26
26
  const todayJql = `assignee = currentUser() AND statusCategory IN ("To Do", "In Progress") ORDER BY priority DESC`;
27
27
  const todayIssuesRes = await api.get(`/search?jql=${encodeURIComponent(todayJql)}&fields=summary,status,priority`);
28
- const todayIssues = todayIssuesRes.issues.map(i => `- ${i.key} ${i.fields.summary} [${i.fields.priority.name}]`).join('\n');
28
+ const todayIssues = todayIssuesRes.issues.map((i: any) => `- ${i.key} ${i.fields.summary} [${i.fields.priority.name}]`).join('\n');
29
29
 
30
30
  spinner.text = 'Generating standup report...';
31
31
 
@@ -36,7 +36,7 @@ export async function standupAction(options) {
36
36
  console.log(chalk.green(`\n📢 Standup Report for ${displayName}:\n`));
37
37
  console.log(report);
38
38
 
39
- } catch (e) {
39
+ } catch (e: any) {
40
40
  handleCommandError(spinner, e, 'Failed to generate standup');
41
41
  }
42
42
  }
@@ -11,7 +11,7 @@ import { reviewAction } from './ai-actions/review.js';
11
11
  import { planAction } from './ai-actions/plan.js';
12
12
  import { standupAction } from './ai-actions/standup.js';
13
13
 
14
- export function registerAiCommand(program) {
14
+ export function registerAiCommand(program: Command) {
15
15
  const aiCmd = new Command('ai')
16
16
  .description('AI Helper commands')
17
17
  .addHelpText('after', `
@@ -26,7 +26,7 @@ Common Actions:
26
26
  .command('summarize')
27
27
  .description('Summarize an issue using AI')
28
28
  .argument('<issueKey>', 'Jira Issue Key')
29
- .action(async (issueKey) => {
29
+ .action(async (issueKey: string) => {
30
30
  const check = validateIssueKey(issueKey);
31
31
  if (!check.valid) { console.error(chalk.red(check.message)); return; }
32
32
  const spinner = ora(`Fetching issue ${issueKey}...`).start();
@@ -39,7 +39,7 @@ Common Actions:
39
39
  ? parseADF(issue.fields.description)
40
40
  : 'No description';
41
41
  const comments = (issue.fields.comment?.comments || [])
42
- .map(c => `${c.author.displayName}: ${typeof c.body === 'object' ? parseADF(c.body) : c.body}`)
42
+ .map((c: any) => `${c.author.displayName}: ${typeof c.body === 'object' ? parseADF(c.body) : c.body}`)
43
43
  .join('\n');
44
44
 
45
45
  const prompt = `
@@ -60,7 +60,7 @@ Provide a concise summary of the current status, key discussion points, and next
60
60
  console.log(chalk.green(`\n🤖 AI Summary for ${issueKey}:\n`));
61
61
  console.log(aiResponse);
62
62
 
63
- } catch (e) {
63
+ } catch (e: any) {
64
64
  handleCommandError(spinner, e, `Failed to summarize ${issueKey}`);
65
65
  }
66
66
  });
@@ -77,7 +77,7 @@ Examples:
77
77
  $ jira ai draft -i "login fails, returns 500, only on mobile"
78
78
  $ jira ai draft -i "add dark mode toggle" -t story
79
79
  `)
80
- .action(async (options) => {
80
+ .action(async (options: any) => {
81
81
  try {
82
82
  let bulletPoints = options.input;
83
83
 
@@ -86,8 +86,8 @@ Examples:
86
86
  type: 'input',
87
87
  name: 'inputNotes',
88
88
  message: 'Enter your bullet points or rough notes:',
89
- validate: (val) => val.trim().length > 0 || 'Input cannot be empty'
90
- });
89
+ validate: (val: any) => val.trim().length > 0 || 'Input cannot be empty'
90
+ }) as any;
91
91
  bulletPoints = inputNotes;
92
92
  }
93
93
 
@@ -122,7 +122,7 @@ Keep it professional and concise. Output in plain text (not markdown headers, us
122
122
  console.log(aiResponse);
123
123
  console.log(chalk.grey('\nTip: Copy this into "jira issue create" or use it as a starting point.'));
124
124
 
125
- } catch (e) {
125
+ } catch (e: any) {
126
126
  handleCommandError(null, e, 'Failed to generate draft');
127
127
  }
128
128
  });
@@ -132,7 +132,7 @@ Keep it professional and concise. Output in plain text (not markdown headers, us
132
132
  .command('suggest')
133
133
  .description('Suggest next actions for an issue based on its context')
134
134
  .argument('<issueKey>', 'Jira Issue Key')
135
- .action(async (issueKey) => {
135
+ .action(async (issueKey: string) => {
136
136
  const check = validateIssueKey(issueKey);
137
137
  if (!check.valid) { console.error(chalk.red(check.message)); return; }
138
138
  const spinner = ora(`Analyzing issue ${issueKey}...`).start();
@@ -149,7 +149,7 @@ Keep it professional and concise. Output in plain text (not markdown headers, us
149
149
  const assignee = issue.fields.assignee?.displayName || 'Unassigned';
150
150
  const comments = (issue.fields.comment?.comments || [])
151
151
  .slice(-5) // Last 5 comments for context
152
- .map(c => `${c.author.displayName}: ${typeof c.body === 'object' ? parseADF(c.body) : c.body}`)
152
+ .map((c: any) => `${c.author.displayName}: ${typeof c.body === 'object' ? parseADF(c.body) : c.body}`)
153
153
  .join('\n');
154
154
 
155
155
  spinner.text = 'Generating suggestions...';
@@ -183,7 +183,7 @@ Keep suggestions actionable and concise.
183
183
  console.log(chalk.green(`\n💡 AI Suggestions for ${issueKey}:\n`));
184
184
  console.log(aiResponse);
185
185
 
186
- } catch (e) {
186
+ } catch (e: any) {
187
187
  handleCommandError(spinner, e, `Failed to suggest for ${issueKey}`);
188
188
  }
189
189
  });
@@ -1,11 +1,11 @@
1
1
  import { Command } from 'commander';
2
2
  import chalk from 'chalk';
3
- import { table } from 'table';
3
+ import Table from 'cli-table3';
4
4
  import { api } from '../services/api-service.js';
5
5
  import ora from 'ora';
6
6
  import { handleCommandError } from '../utils/error-handler.js';
7
7
 
8
- export function registerBoardCommand(program) {
8
+ export function registerBoardCommand(program: Command) {
9
9
  const boardCmd = new Command('board')
10
10
  .description('Manage Jira boards')
11
11
  .addHelpText('after', `
@@ -21,7 +21,7 @@ Common Actions:
21
21
  .option('-t, --type <type>', 'Filter by board type (scrum, kanban, simple)')
22
22
  .option('-l, --limit <n>', 'Max results', '50')
23
23
  .option('-o, --output <format>', 'Output format (json)')
24
- .action(async (options) => {
24
+ .action(async (options: any) => {
25
25
  const spinner = ora('Fetching boards...').start();
26
26
  try {
27
27
  const params = new URLSearchParams();
@@ -43,19 +43,19 @@ Common Actions:
43
43
  }
44
44
 
45
45
  if (options.output === 'json') {
46
- console.log(JSON.stringify(data.values.map(b => ({
46
+ console.log(JSON.stringify(data.values.map((b: any) => ({
47
47
  id: b.id, name: b.name,
48
48
  type: b.type, project: b.location?.projectKey || null
49
49
  })), null, 2));
50
50
  return;
51
51
  }
52
52
 
53
- const tableData = [
54
- [chalk.bold('ID'), chalk.bold('Name'), chalk.bold('Type'), chalk.bold('Project')]
55
- ];
53
+ const table = new Table({
54
+ head: [chalk.bold('ID'), chalk.bold('Name'), chalk.bold('Type'), chalk.bold('Project')]
55
+ });
56
56
 
57
- data.values.forEach(b => {
58
- tableData.push([
57
+ data.values.forEach((b: any) => {
58
+ table.push([
59
59
  b.id,
60
60
  b.name,
61
61
  b.type,
@@ -63,10 +63,10 @@ Common Actions:
63
63
  ]);
64
64
  });
65
65
 
66
- console.log(table(tableData));
66
+ console.log(table.toString());
67
67
  console.log(chalk.grey(`Showing ${data.values.length} board(s)`));
68
68
 
69
- } catch (e) {
69
+ } catch (e: any) {
70
70
  handleCommandError(spinner, e, 'Failed to list boards');
71
71
  }
72
72
  });
@@ -0,0 +1,230 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import ora from 'ora';
4
+ import enquirer from 'enquirer';
5
+ import { api } from '../services/api-service.js';
6
+ import { handleCommandError } from '../utils/error-handler.js';
7
+
8
+ export function registerBulkCommand(program: Command) {
9
+ const bulkCmd = new Command('bulk')
10
+ .description('Bulk operations on Jira issues')
11
+ .addHelpText('after', `
12
+ Common Actions:
13
+ $ jira bulk transition -j "project = PROJ AND status = 'To Do'" -s "In Progress"
14
+ `);
15
+
16
+ // ── BULK TRANSITION ──────────────────────────────────────────────
17
+ bulkCmd
18
+ .command('transition')
19
+ .description('Transition multiple issues matching a JQL filter')
20
+ .requiredOption('-j, --jql <query>', 'JQL query to select issues')
21
+ .option('-s, --status <name>', 'Target status name')
22
+ .option('-y, --yes', 'Skip confirmation prompt')
23
+ .option('-l, --limit <n>', 'Max issues to process', '50')
24
+ .addHelpText('after', `
25
+ Examples:
26
+ $ jira bulk transition -j "project = PROJ AND status = 'To Do'" -s "In Progress"
27
+ $ jira bulk transition -j "assignee = currentUser() AND status = Review" -s Done -y
28
+ `)
29
+ .action(async (options: any) => {
30
+ const spinner = ora('Finding matching issues...').start();
31
+ try {
32
+ const data = await api.post('/search/jql', {
33
+ jql: options.jql,
34
+ maxResults: parseInt(options.limit),
35
+ fields: ['summary', 'status']
36
+ });
37
+ spinner.stop();
38
+
39
+ if (!data.issues || data.issues.length === 0) {
40
+ console.log(chalk.yellow('No issues match the query.'));
41
+ return;
42
+ }
43
+
44
+ console.log(chalk.bold(`\nFound ${data.issues.length} issue(s):\n`));
45
+ data.issues.forEach((i: any) => {
46
+ console.log(` ${chalk.cyan(i.key)} ${i.fields.summary} [${i.fields.status.name}]`);
47
+ });
48
+
49
+ let targetStatus = options.status;
50
+
51
+ if (!targetStatus) {
52
+ // Get transitions from the first issue to show available statuses
53
+ const transData = await api.get(`/issue/${data.issues[0].key}/transitions`);
54
+ const { Select } = enquirer as any;
55
+ const statusSelect = new Select({
56
+ name: 'status',
57
+ message: 'Target status',
58
+ choices: transData.transitions.map((t: any) => ({ name: t.name, message: t.name }))
59
+ });
60
+ targetStatus = await statusSelect.run();
61
+ }
62
+
63
+ if (!options.yes) {
64
+ const { Confirm } = enquirer as any;
65
+ const confirm = new Confirm({
66
+ name: 'proceed',
67
+ message: `Transition ${data.issues.length} issue(s) to "${targetStatus}"?`
68
+ });
69
+ if (!await confirm.run()) {
70
+ console.log(chalk.yellow('Cancelled.'));
71
+ return;
72
+ }
73
+ }
74
+
75
+ const transSpinner = ora(`Transitioning ${data.issues.length} issue(s)...`).start();
76
+ let success = 0;
77
+ let failed = 0;
78
+
79
+ for (const issue of data.issues) {
80
+ try {
81
+ const transData = await api.get(`/issue/${issue.key}/transitions`);
82
+ const transition = transData.transitions.find(
83
+ (t: any) => t.name.toLowerCase() === targetStatus.toLowerCase()
84
+ );
85
+
86
+ if (transition) {
87
+ await api.post(`/issue/${issue.key}/transitions`, {
88
+ transition: { id: transition.id }
89
+ });
90
+ success++;
91
+ } else {
92
+ failed++;
93
+ }
94
+ } catch {
95
+ failed++;
96
+ }
97
+ transSpinner.text = `Transitioning... (${success + failed}/${data.issues.length})`;
98
+ }
99
+
100
+ transSpinner.succeed(`Done: ${chalk.green(`${success} succeeded`)}, ${failed > 0 ? chalk.red(`${failed} failed`) : '0 failed'}`);
101
+
102
+ } catch (e: any) {
103
+ handleCommandError(spinner, e, 'Bulk transition failed');
104
+ }
105
+ });
106
+
107
+ // ── BULK ASSIGN ──────────────────────────────────────────────────
108
+ bulkCmd
109
+ .command('assign')
110
+ .description('Assign multiple issues')
111
+ .requiredOption('-j, --jql <query>', 'JQL query')
112
+ .option('-a, --assignee <id>', 'AccountId or "me"')
113
+ .option('-y, --yes', 'Skip confirmation')
114
+ .action(async (options: any) => {
115
+ const spinner = ora('Finding issues...').start();
116
+ try {
117
+ const data = await api.post('/search/jql', {
118
+ jql: options.jql,
119
+ maxResults: 50,
120
+ fields: ['summary', 'assignee']
121
+ });
122
+ spinner.stop();
123
+
124
+ if (!data.issues?.length) {
125
+ console.log(chalk.yellow('No issues found.'));
126
+ return;
127
+ }
128
+
129
+ console.log(chalk.bold(`Found ${data.issues.length} issue(s):`));
130
+ data.issues.forEach((i: any) => console.log(` ${i.key}: ${i.fields.summary} (${i.fields.assignee?.displayName || 'Unassigned'})`));
131
+
132
+ let assigneeId = options.assignee;
133
+ if (!assigneeId) {
134
+ const { userId } = await enquirer.prompt({
135
+ type: 'input',
136
+ name: 'userId',
137
+ message: 'Enter Account ID (or "me"):',
138
+ validate: (val: string) => val.length > 0
139
+ }) as any;
140
+ assigneeId = userId;
141
+ }
142
+
143
+ if (assigneeId === 'me') {
144
+ const me = await api.get('/myself');
145
+ assigneeId = me.accountId;
146
+ }
147
+
148
+ if (!options.yes) {
149
+ const { confirm } = await enquirer.prompt({
150
+ type: 'confirm',
151
+ name: 'confirm',
152
+ message: `Assign ${data.issues.length} issues to ${assigneeId}?`
153
+ }) as any;
154
+ if (!confirm) return;
155
+ }
156
+
157
+ const processSpinner = ora('Assigning...').start();
158
+ for (const issue of data.issues) {
159
+ await api.put(`/issue/${issue.key}/assignee`, { accountId: assigneeId });
160
+ }
161
+ processSpinner.succeed('Bulk assign complete.');
162
+
163
+ } catch (e: any) {
164
+ handleCommandError(spinner, e, 'Bulk assign failed');
165
+ }
166
+ });
167
+
168
+ // ── BULK LABEL ───────────────────────────────────────────────────
169
+ bulkCmd
170
+ .command('label')
171
+ .description('Add or remove labels from multiple issues')
172
+ .requiredOption('-j, --jql <query>', 'JQL query')
173
+ .option('--add <labels>', 'Comma-separated labels to add')
174
+ .option('--remove <labels>', 'Comma-separated labels to remove')
175
+ .option('-y, --yes', 'Skip confirmation')
176
+ .action(async (options: any) => {
177
+ if (!options.add && !options.remove) {
178
+ console.log(chalk.red('Must specify --add or --remove'));
179
+ return;
180
+ }
181
+
182
+ const spinner = ora('Finding issues...').start();
183
+ try {
184
+ const data = await api.post('/search/jql', {
185
+ jql: options.jql,
186
+ maxResults: 50,
187
+ fields: ['summary', 'labels']
188
+ });
189
+ spinner.stop();
190
+
191
+ if (!data.issues?.length) {
192
+ console.log(chalk.yellow('No issues found.'));
193
+ return;
194
+ }
195
+
196
+ console.log(chalk.bold(`Found ${data.issues.length} issue(s).`));
197
+
198
+ if (!options.yes) {
199
+ const { confirm } = await enquirer.prompt({
200
+ type: 'confirm',
201
+ name: 'confirm',
202
+ message: `Update labels for ${data.issues.length} issues?`
203
+ }) as any;
204
+ if (!confirm) return;
205
+ }
206
+
207
+ const processSpinner = ora('Updating labels...').start();
208
+ const addList = options.add ? options.add.split(',').map((l: string) => l.trim()) : [];
209
+ const removeList = options.remove ? options.remove.split(',').map((l: string) => l.trim()) : [];
210
+
211
+ for (const issue of data.issues) {
212
+ const currentLabels = issue.fields.labels || [];
213
+ let newLabels = new Set(currentLabels);
214
+
215
+ addList.forEach((l: string) => newLabels.add(l));
216
+ removeList.forEach((l: string) => newLabels.delete(l));
217
+
218
+ await api.put(`/issue/${issue.key}`, {
219
+ fields: { labels: Array.from(newLabels) }
220
+ });
221
+ }
222
+ processSpinner.succeed('Bulk labels updated.');
223
+
224
+ } catch (e: any) {
225
+ handleCommandError(spinner, e, 'Bulk label failed');
226
+ }
227
+ });
228
+
229
+ program.addCommand(bulkCmd);
230
+ }