jira-ai 0.9.97 → 0.9.99

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -112,6 +112,50 @@ Discover available fields for a project (including custom fields):
112
112
  jira-ai project fields PROJ --type Task
113
113
  ```
114
114
 
115
+ ### Transition Issues
116
+
117
+ Change the status of an issue:
118
+ ```bash
119
+ jira-ai issue transition PROJ-123 "In Progress"
120
+ ```
121
+
122
+ Add a comment and resolution during transition:
123
+ ```bash
124
+ jira-ai issue transition PROJ-123 Done --resolution Done --comment "Completed the feature."
125
+ ```
126
+
127
+ Change assignee and fix version during transition:
128
+ ```bash
129
+ jira-ai issue transition PROJ-123 "In Review" --assignee "Jane Smith" --fix-version "v2.0"
130
+ ```
131
+
132
+ Set a custom field during transition:
133
+ ```bash
134
+ jira-ai issue transition PROJ-123 Done --custom-field "Story Points=5"
135
+ ```
136
+
137
+ Pass a comment from a file (useful for long comments):
138
+ ```bash
139
+ jira-ai issue transition PROJ-123 Done --comment-file ./release-notes.md
140
+ ```
141
+
142
+ Discover which transitions are available and what fields they require:
143
+ ```bash
144
+ jira-ai issue transitions PROJ-123
145
+ ```
146
+
147
+ Only show transitions that have required fields:
148
+ ```bash
149
+ jira-ai issue transitions PROJ-123 --required-only
150
+ ```
151
+
152
+ Get structured output for scripting:
153
+ ```bash
154
+ jira-ai --json issue transitions PROJ-123
155
+ ```
156
+
157
+ When a transition fails due to missing required fields, the error message lists what is needed and suggests running `issue transitions <key>` to discover them.
158
+
115
159
  ## Service Account Authentication
116
160
 
117
161
  Atlassian service accounts use scoped API tokens that must authenticate through the `api.atlassian.com` gateway rather than direct site URLs.
package/dist/cli.js CHANGED
@@ -19,13 +19,16 @@ import { createIssueLinkCommand } from './commands/create-issue-link.js';
19
19
  import { deleteIssueLinkCommand } from './commands/delete-issue-link.js';
20
20
  import { listLinkTypesCommand } from './commands/list-link-types.js';
21
21
  import { createTaskCommand } from './commands/create-task.js';
22
- import { transitionCommand } from './commands/transition.js';
22
+ import { transitionCommand, listTransitionsCommand } from './commands/transition.js';
23
23
  import { issueAssignCommand } from './commands/issue.js';
24
24
  import { getIssueStatisticsCommand } from './commands/get-issue-statistics.js';
25
25
  import { getPersonWorklogCommand } from './commands/get-person-worklog.js';
26
26
  import { isCommandAllowed, getAllowedCommands } from './lib/settings.js';
27
27
  import { confluenceGetPageCommand, confluenceListSpacesCommand, confluenceGetSpacePagesHierarchyCommand, confluenceAddCommentCommand, confluenceCreatePageCommand, confluenceUpdateDescriptionCommand, confluenceSearchCommand } from './commands/confluence.js';
28
28
  import { epicListCommand, epicGetCommand, epicCreateCommand, epicUpdateCommand, epicIssuesCommand, epicLinkCommand, epicUnlinkCommand, epicProgressCommand, } from './commands/epic.js';
29
+ import { boardListCommand, boardGetCommand, boardConfigCommand, boardIssuesCommand, boardRankCommand, } from './commands/board.js';
30
+ import { sprintListCommand, sprintGetCommand, sprintCreateCommand, sprintStartCommand, sprintCompleteCommand, sprintUpdateCommand, sprintDeleteCommand, sprintIssuesCommand, sprintMoveCommand, } from './commands/sprint.js';
31
+ import { backlogMoveCommand } from './commands/backlog.js';
29
32
  import { aboutCommand } from './commands/about.js';
30
33
  import { authCommand } from './commands/auth.js';
31
34
  import { settingsCommand } from './commands/settings.js';
@@ -35,7 +38,7 @@ import { CliError } from './types/errors.js';
35
38
  import { CommandError } from './lib/errors.js';
36
39
  import { ui } from './lib/ui.js';
37
40
  import { initJsonMode, outputError } from './lib/json-mode.js';
38
- import { CreateTaskSchema, UpdateDescriptionSchema, ProjectFieldsSchema, ConfluenceAddCommentSchema, RunJqlSchema, GetPersonWorklogSchema, GetIssueStatisticsSchema, EpicListSchema, EpicCreateSchema, EpicUpdateSchema, EpicLinkSchema, EpicMaxSchema, validateOptions, IssueKeySchema, ProjectKeySchema, TimeframeSchema } from './lib/validation.js';
41
+ import { CreateTaskSchema, UpdateDescriptionSchema, ProjectFieldsSchema, ConfluenceAddCommentSchema, RunJqlSchema, GetPersonWorklogSchema, GetIssueStatisticsSchema, EpicListSchema, EpicCreateSchema, EpicUpdateSchema, EpicLinkSchema, EpicMaxSchema, validateOptions, IssueKeySchema, ProjectKeySchema, TimeframeSchema, BoardListSchema, BoardIssuesSchema, BoardRankSchema, SprintListSchema, SprintCreateSchema, SprintUpdateSchema, SprintIssuesSchema, SprintMoveSchema, BacklogMoveSchema, } from './lib/validation.js';
39
42
  import { realpathSync } from 'fs';
40
43
  // Create CLI program
41
44
  const program = new Command();
@@ -144,7 +147,29 @@ issue
144
147
  issue
145
148
  .command('transition <issue-id> <to-status>')
146
149
  .description('Change the status of a Jira issue. The <to-status> can be either the status name or ID.')
147
- .action(withPermission('issue.transition', transitionCommand, {
150
+ .option('--resolution <name>', 'Resolution name (e.g., "Done", "Won\'t Do")')
151
+ .option('--comment <text>', 'Add a comment (markdown) during transition')
152
+ .option('--comment-file <path>', 'Read comment from a markdown file (mutually exclusive with --comment)')
153
+ .option('--assignee <email-or-name>', 'Assignee (accountid:<id> or display name)')
154
+ .option('--fix-version <name>', 'Fix version name')
155
+ .option('--custom-field <entry>', 'Custom field as "Field Name=value" (repeatable)', (v, acc) => { acc.push(v); return acc; }, [])
156
+ .action(withPermission('issue.transition', (taskId, toStatus, opts) => transitionCommand(taskId, toStatus, {
157
+ resolution: opts.resolution,
158
+ comment: opts.comment,
159
+ commentFile: opts.commentFile,
160
+ assignee: opts.assignee,
161
+ fixVersion: opts.fixVersion,
162
+ customFields: opts.customField,
163
+ }), {
164
+ validateArgs: (args) => validateOptions(IssueKeySchema, args[0])
165
+ }));
166
+ issue
167
+ .command('transitions <issue-id>')
168
+ .description('List available transitions for a Jira issue, including required fields.')
169
+ .option('--required-only', 'Only show transitions that have required fields')
170
+ .action(withPermission('issue.transition', (issueId, opts) => listTransitionsCommand(issueId, {
171
+ requiredOnly: opts.requiredOnly,
172
+ }), {
148
173
  validateArgs: (args) => validateOptions(IssueKeySchema, args[0])
149
174
  }));
150
175
  issue
@@ -437,6 +462,106 @@ epic
437
462
  .action(withPermission('epic.progress', epicProgressCommand, {
438
463
  validateArgs: (args) => validateOptions(IssueKeySchema, args[0])
439
464
  }));
465
+ // =============================================================================
466
+ // BOARD COMMANDS
467
+ // =============================================================================
468
+ const board = program
469
+ .command('board')
470
+ .description('Manage Jira agile boards');
471
+ board
472
+ .command('list')
473
+ .description('List all boards, optionally filtered by project or type.')
474
+ .option('--project <key>', 'Filter by project key')
475
+ .option('--type <type>', 'Filter by board type (scrum, kanban)')
476
+ .action(withPermission('board.list', boardListCommand, { schema: BoardListSchema }));
477
+ board
478
+ .command('get <board-id>')
479
+ .description('Get details of a specific board.')
480
+ .action(withPermission('board.get', (boardId) => boardGetCommand(Number(boardId))));
481
+ board
482
+ .command('config <board-id>')
483
+ .description('Get configuration for a board including columns and filter.')
484
+ .action(withPermission('board.config', (boardId) => boardConfigCommand(Number(boardId))));
485
+ board
486
+ .command('issues <board-id>')
487
+ .description('List issues on a board.')
488
+ .option('--jql <jql>', 'Additional JQL filter')
489
+ .option('--max <n>', 'Maximum results')
490
+ .action(withPermission('board.issues', (boardId, options) => boardIssuesCommand(Number(boardId), { jql: options.jql, max: options.max ? Number(options.max) : undefined }), { schema: BoardIssuesSchema }));
491
+ board
492
+ .command('rank')
493
+ .description('Rank issues on a board before or after another issue.')
494
+ .requiredOption('--issues <keys...>', 'Issue keys to rank')
495
+ .option('--before <key>', 'Rank before this issue')
496
+ .option('--after <key>', 'Rank after this issue')
497
+ .action(withPermission('board.rank', boardRankCommand, { schema: BoardRankSchema }));
498
+ // =============================================================================
499
+ // SPRINT COMMANDS
500
+ // =============================================================================
501
+ const sprint = program
502
+ .command('sprint')
503
+ .description('Manage Jira sprints');
504
+ sprint
505
+ .command('list <board-id>')
506
+ .description('List sprints for a board.')
507
+ .option('--state <state>', 'Filter by state (future, active, closed)')
508
+ .action(withPermission('sprint.list', (boardId, options) => sprintListCommand(Number(boardId), options), { schema: SprintListSchema }));
509
+ sprint
510
+ .command('get <sprint-id>')
511
+ .description('Get details of a specific sprint.')
512
+ .action(withPermission('sprint.get', (sprintId) => sprintGetCommand(Number(sprintId))));
513
+ sprint
514
+ .command('create <board-id>')
515
+ .description('Create a new sprint on a board.')
516
+ .requiredOption('--name <name>', 'Sprint name')
517
+ .option('--goal <goal>', 'Sprint goal')
518
+ .option('--start <date>', 'Start date (ISO format)')
519
+ .option('--end <date>', 'End date (ISO format)')
520
+ .action(withPermission('sprint.create', (boardId, options) => sprintCreateCommand(Number(boardId), options.name, options), { schema: SprintCreateSchema }));
521
+ sprint
522
+ .command('start <sprint-id>')
523
+ .description('Start a sprint (must be in future state).')
524
+ .action(withPermission('sprint.start', (sprintId) => sprintStartCommand(Number(sprintId))));
525
+ sprint
526
+ .command('complete <sprint-id>')
527
+ .description('Complete a sprint (must be in active state).')
528
+ .action(withPermission('sprint.complete', (sprintId) => sprintCompleteCommand(Number(sprintId))));
529
+ sprint
530
+ .command('update <sprint-id>')
531
+ .description('Update sprint name, goal, or dates.')
532
+ .option('--name <name>', 'New sprint name')
533
+ .option('--goal <goal>', 'New sprint goal')
534
+ .option('--start <date>', 'New start date (ISO format)')
535
+ .option('--end <date>', 'New end date (ISO format)')
536
+ .action(withPermission('sprint.update', (sprintId, options) => sprintUpdateCommand(Number(sprintId), options), { schema: SprintUpdateSchema }));
537
+ sprint
538
+ .command('delete <sprint-id>')
539
+ .description('Delete a sprint.')
540
+ .action(withPermission('sprint.delete', (sprintId) => sprintDeleteCommand(Number(sprintId))));
541
+ sprint
542
+ .command('issues <sprint-id>')
543
+ .description('List issues in a sprint.')
544
+ .option('--jql <jql>', 'Additional JQL filter')
545
+ .option('--max <n>', 'Maximum results')
546
+ .action(withPermission('sprint.issues', (sprintId, options) => sprintIssuesCommand(Number(sprintId), { jql: options.jql, max: options.max ? Number(options.max) : undefined }), { schema: SprintIssuesSchema }));
547
+ sprint
548
+ .command('move <sprint-id>')
549
+ .description('Move issues into a sprint.')
550
+ .requiredOption('--issues <keys...>', 'Issue keys to move')
551
+ .option('--before <key>', 'Rank before this issue')
552
+ .option('--after <key>', 'Rank after this issue')
553
+ .action(withPermission('sprint.move', (sprintId, options) => sprintMoveCommand(Number(sprintId), options), { schema: SprintMoveSchema }));
554
+ // =============================================================================
555
+ // BACKLOG COMMANDS
556
+ // =============================================================================
557
+ const backlog = program
558
+ .command('backlog')
559
+ .description('Manage Jira backlog');
560
+ backlog
561
+ .command('move')
562
+ .description('Move issues to the backlog.')
563
+ .requiredOption('--issues <keys...>', 'Issue keys to move to backlog')
564
+ .action(withPermission('board.backlog', backlogMoveCommand, { schema: BacklogMoveSchema }));
440
565
  // About command (always allowed)
441
566
  program
442
567
  .command('about')
@@ -471,6 +596,9 @@ Command Groups (use in allowed-commands):
471
596
  user - me, search, worklog
472
597
  epic - list, get, create, update, issues, link, unlink, progress
473
598
  confl - get, spaces, pages, create, comment, update
599
+ board - list, get, config, issues, rank
600
+ sprint - list, get, create, start, complete, update, delete, issues, move
601
+ backlog - move
474
602
 
475
603
  Examples:
476
604
  - "issue" → allows all issue subcommands
@@ -0,0 +1,21 @@
1
+ import chalk from 'chalk';
2
+ import { moveIssuesToBacklog } from '../lib/agile-client.js';
3
+ import { requirePermission } from '../lib/permissions.js';
4
+ import { CommandError } from '../lib/errors.js';
5
+ import { ui } from '../lib/ui.js';
6
+ // backlog move --issues <keys>
7
+ export async function backlogMoveCommand(options) {
8
+ requirePermission('board.backlog');
9
+ if (!options.issues || options.issues.length === 0) {
10
+ throw new CommandError('At least one issue key is required.', { hints: ['Provide issue keys with --issues'] });
11
+ }
12
+ if (options.issues.length > 50) {
13
+ throw new CommandError('Cannot move more than 50 issues at once.', {
14
+ hints: ['Split your issue list into batches of 50 or fewer'],
15
+ });
16
+ }
17
+ ui.startSpinner(`Moving ${options.issues.length} issue(s) to backlog...`);
18
+ await moveIssuesToBacklog(options.issues);
19
+ ui.succeedSpinner(chalk.green(`Moved ${options.issues.length} issue(s) to backlog.`));
20
+ console.log();
21
+ }
@@ -0,0 +1,120 @@
1
+ import chalk from 'chalk';
2
+ import { getBoards, getBoard, getBoardConfig, getBoardIssues, rankIssues } from '../lib/agile-client.js';
3
+ import { requirePermission } from '../lib/permissions.js';
4
+ import { CommandError } from '../lib/errors.js';
5
+ import { ui } from '../lib/ui.js';
6
+ // board list [--project <key>] [--type <type>]
7
+ export async function boardListCommand(options) {
8
+ requirePermission('board.list');
9
+ ui.startSpinner('Fetching boards...');
10
+ const params = {};
11
+ if (options?.projectKey)
12
+ params.projectKeyOrId = options.projectKey;
13
+ if (options?.type)
14
+ params.type = options.type;
15
+ const result = await getBoards(Object.keys(params).length ? params : undefined);
16
+ ui.stopSpinner();
17
+ if (!result.values || result.values.length === 0) {
18
+ console.log(chalk.yellow('No boards found.'));
19
+ return;
20
+ }
21
+ console.log(chalk.bold(`\nBoards (${result.values.length} total)\n`));
22
+ result.values.forEach(board => {
23
+ const location = board.location?.displayName ? chalk.gray(` — ${board.location.displayName}`) : '';
24
+ console.log(` ${chalk.cyan(String(board.id))} ${board.name}${location} ${chalk.gray(`[${board.type}]`)}`);
25
+ });
26
+ if (!result.isLast) {
27
+ console.log(chalk.gray('\n (More results available — use --max to fetch more)'));
28
+ }
29
+ console.log();
30
+ }
31
+ // board get <board-id>
32
+ export async function boardGetCommand(boardId) {
33
+ requirePermission('board.get');
34
+ if (boardId == null) {
35
+ throw new CommandError('Board ID is required.', { hints: ['Provide a numeric board ID'] });
36
+ }
37
+ ui.startSpinner(`Fetching board ${boardId}...`);
38
+ const board = await getBoard(boardId);
39
+ ui.stopSpinner();
40
+ console.log(chalk.bold(`\nBoard: ${board.name}`) + '\n');
41
+ console.log(` ID: ${chalk.cyan(String(board.id))}`);
42
+ console.log(` Type: ${board.type}`);
43
+ if (board.location?.projectKey)
44
+ console.log(` Project: ${board.location.projectKey}`);
45
+ if (board.location?.displayName)
46
+ console.log(` Location: ${board.location.displayName}`);
47
+ if (board.isPrivate !== undefined)
48
+ console.log(` Private: ${board.isPrivate ? 'Yes' : 'No'}`);
49
+ console.log();
50
+ }
51
+ // board config <board-id>
52
+ export async function boardConfigCommand(boardId) {
53
+ requirePermission('board.config');
54
+ if (boardId == null) {
55
+ throw new CommandError('Board ID is required.', { hints: ['Provide a numeric board ID'] });
56
+ }
57
+ ui.startSpinner(`Fetching board config for ${boardId}...`);
58
+ const config = await getBoardConfig(boardId);
59
+ ui.stopSpinner();
60
+ console.log(chalk.bold(`\nBoard Config: ${config.name}`) + '\n');
61
+ console.log(` ID: ${chalk.cyan(String(config.id))}`);
62
+ if (config.type)
63
+ console.log(` Type: ${config.type}`);
64
+ if (config.filter)
65
+ console.log(` Filter ID: ${config.filter.id}`);
66
+ if (config.ranking)
67
+ console.log(` Rank Field: ${config.ranking.rankCustomFieldId}`);
68
+ if (config.columnConfig?.columns?.length) {
69
+ console.log(`\n ${chalk.bold('Columns:')}`);
70
+ config.columnConfig.columns.forEach(col => {
71
+ const statuses = col.statuses?.map(s => s.id).join(', ') || 'none';
72
+ console.log(` ${chalk.cyan(col.name)} [statuses: ${statuses}]`);
73
+ });
74
+ }
75
+ console.log();
76
+ }
77
+ // board issues <board-id> [--jql <jql>] [--max <n>]
78
+ export async function boardIssuesCommand(boardId, options) {
79
+ requirePermission('board.issues');
80
+ ui.startSpinner(`Fetching issues for board ${boardId}...`);
81
+ const params = {};
82
+ if (options?.jql)
83
+ params.jql = options.jql;
84
+ if (options?.max)
85
+ params.maxResults = options.max;
86
+ const result = await getBoardIssues(boardId, params);
87
+ ui.stopSpinner();
88
+ if (!result.issues || result.issues.length === 0) {
89
+ console.log(chalk.yellow('No issues found on this board.'));
90
+ return;
91
+ }
92
+ console.log(chalk.bold(`\nBoard Issues (${result.total} total)\n`));
93
+ result.issues.forEach(issue => {
94
+ const status = chalk.gray(`[${issue.fields.status.name}]`);
95
+ const assignee = issue.fields.assignee ? chalk.gray(` @${issue.fields.assignee.displayName}`) : '';
96
+ console.log(` ${chalk.cyan(issue.key)} ${issue.fields.summary} ${status}${assignee}`);
97
+ });
98
+ console.log();
99
+ }
100
+ // board rank --issues <keys> --before <key> | --after <key>
101
+ export async function boardRankCommand(options) {
102
+ requirePermission('board.rank');
103
+ if (!options.issues || options.issues.length === 0) {
104
+ throw new CommandError('At least one issue key is required.', { hints: ['Provide issue keys with --issues'] });
105
+ }
106
+ if (!options.before && !options.after) {
107
+ throw new CommandError('Either --before or --after must be specified.', {
108
+ hints: ['Use --before <issue-key> or --after <issue-key>'],
109
+ });
110
+ }
111
+ ui.startSpinner('Ranking issues...');
112
+ const rankOptions = {};
113
+ if (options.before)
114
+ rankOptions.rankBeforeIssue = options.before;
115
+ if (options.after)
116
+ rankOptions.rankAfterIssue = options.after;
117
+ await rankIssues(options.issues, rankOptions);
118
+ ui.succeedSpinner(chalk.green(`Ranked ${options.issues.length} issue(s) successfully.`));
119
+ console.log();
120
+ }
@@ -0,0 +1,187 @@
1
+ import chalk from 'chalk';
2
+ import { getSprints, getSprint, createSprint, startSprint, completeSprint, updateSprint, deleteSprint, getSprintIssues, moveIssuesToSprint, } from '../lib/agile-client.js';
3
+ import { requirePermission } from '../lib/permissions.js';
4
+ import { CommandError } from '../lib/errors.js';
5
+ import { ui } from '../lib/ui.js';
6
+ // sprint list <board-id> [--state <state>]
7
+ export async function sprintListCommand(boardId, options) {
8
+ requirePermission('sprint.list');
9
+ ui.startSpinner(`Fetching sprints for board ${boardId}...`);
10
+ const params = {};
11
+ if (options?.state)
12
+ params.state = options.state;
13
+ const result = await getSprints(boardId, params);
14
+ ui.stopSpinner();
15
+ if (!result.values || result.values.length === 0) {
16
+ console.log(chalk.yellow('No sprints found for this board.'));
17
+ return;
18
+ }
19
+ console.log(chalk.bold(`\nSprints (${result.values.length} total)\n`));
20
+ result.values.forEach(sprint => {
21
+ const stateColor = sprint.state === 'active' ? chalk.green : sprint.state === 'closed' ? chalk.gray : chalk.yellow;
22
+ const dates = sprint.startDate ? ` ${chalk.gray(`${sprint.startDate} → ${sprint.endDate || '?'}`)}` : '';
23
+ console.log(` ${chalk.cyan(String(sprint.id))} ${sprint.name} ${stateColor(`[${sprint.state}]`)}${dates}`);
24
+ });
25
+ console.log();
26
+ }
27
+ // sprint get <sprint-id>
28
+ export async function sprintGetCommand(sprintId) {
29
+ requirePermission('sprint.get');
30
+ if (sprintId == null) {
31
+ throw new CommandError('Sprint ID is required.', { hints: ['Provide a numeric sprint ID'] });
32
+ }
33
+ ui.startSpinner(`Fetching sprint ${sprintId}...`);
34
+ const sprint = await getSprint(sprintId);
35
+ ui.stopSpinner();
36
+ const stateColor = sprint.state === 'active' ? chalk.green : sprint.state === 'closed' ? chalk.gray : chalk.yellow;
37
+ console.log(chalk.bold(`\nSprint: ${sprint.name}`) + '\n');
38
+ console.log(` ID: ${chalk.cyan(String(sprint.id))}`);
39
+ console.log(` State: ${stateColor(sprint.state)}`);
40
+ if (sprint.startDate)
41
+ console.log(` Start: ${sprint.startDate}`);
42
+ if (sprint.endDate)
43
+ console.log(` End: ${sprint.endDate}`);
44
+ if (sprint.goal)
45
+ console.log(` Goal: ${sprint.goal}`);
46
+ if (sprint.originBoardId)
47
+ console.log(` Board: ${sprint.originBoardId}`);
48
+ console.log();
49
+ }
50
+ // sprint create <board-id> --name <name> [--goal <goal>] [--start <date>] [--end <date>]
51
+ export async function sprintCreateCommand(boardId, name, options) {
52
+ requirePermission('sprint.create');
53
+ if (boardId == null) {
54
+ throw new CommandError('Board ID is required.', { hints: ['Provide a numeric board ID'] });
55
+ }
56
+ if (!name) {
57
+ throw new CommandError('Sprint name is required.', { hints: ['Provide a sprint name with --name'] });
58
+ }
59
+ ui.startSpinner(`Creating sprint "${name}"...`);
60
+ const params = {};
61
+ if (options?.goal)
62
+ params.goal = options.goal;
63
+ if (options?.start)
64
+ params.startDate = options.start;
65
+ if (options?.end)
66
+ params.endDate = options.end;
67
+ const sprint = await createSprint(boardId, name, params);
68
+ ui.succeedSpinner(chalk.green(`Sprint created: ${sprint.name} (ID: ${sprint.id})`));
69
+ console.log();
70
+ }
71
+ // sprint start <sprint-id>
72
+ export async function sprintStartCommand(sprintId) {
73
+ requirePermission('sprint.start');
74
+ if (sprintId == null) {
75
+ throw new CommandError('Sprint ID is required.', { hints: ['Provide a numeric sprint ID'] });
76
+ }
77
+ ui.startSpinner(`Validating sprint ${sprintId}...`);
78
+ const sprintToStart = await getSprint(sprintId);
79
+ if (sprintToStart.state !== 'future') {
80
+ ui.failSpinner();
81
+ throw new CommandError(`Cannot start sprint in state '${sprintToStart.state}'. Only 'future' sprints can be started.`, { hints: ['Use sprint list to see available sprints and their states'] });
82
+ }
83
+ if (!sprintToStart.startDate || !sprintToStart.endDate) {
84
+ ui.failSpinner();
85
+ throw new CommandError('Cannot start sprint: start and end dates are required. Use sprint update <id> --start <date> --end <date> first.', { hints: ['Example: jira-ai sprint update <id> --start 2024-01-01 --end 2024-01-14'] });
86
+ }
87
+ ui.startSpinner(`Starting sprint ${sprintId}...`);
88
+ await startSprint(sprintId);
89
+ ui.succeedSpinner(chalk.green(`Sprint ${sprintId} started successfully.`));
90
+ console.log();
91
+ }
92
+ // sprint complete <sprint-id>
93
+ export async function sprintCompleteCommand(sprintId) {
94
+ requirePermission('sprint.complete');
95
+ if (sprintId == null) {
96
+ throw new CommandError('Sprint ID is required.', { hints: ['Provide a numeric sprint ID'] });
97
+ }
98
+ ui.startSpinner(`Validating sprint ${sprintId}...`);
99
+ const sprintToComplete = await getSprint(sprintId);
100
+ if (sprintToComplete.state !== 'active') {
101
+ ui.failSpinner();
102
+ throw new CommandError(`Cannot complete sprint in state '${sprintToComplete.state}'. Only 'active' sprints can be completed.`, { hints: ['Use sprint list to see available sprints and their states'] });
103
+ }
104
+ ui.startSpinner(`Completing sprint ${sprintId}...`);
105
+ await completeSprint(sprintId);
106
+ ui.succeedSpinner(chalk.green(`Sprint ${sprintId} completed successfully.`));
107
+ console.log();
108
+ }
109
+ // sprint update <sprint-id> [--name <name>] [--goal <goal>]
110
+ export async function sprintUpdateCommand(sprintId, options) {
111
+ requirePermission('sprint.update');
112
+ if (sprintId == null) {
113
+ throw new CommandError('Sprint ID is required.', { hints: ['Provide a numeric sprint ID'] });
114
+ }
115
+ if (!options?.name && !options?.goal && !options?.start && !options?.end) {
116
+ throw new CommandError('At least one field is required.', {
117
+ hints: ['Provide one or more of: --name, --goal, --start, --end'],
118
+ });
119
+ }
120
+ ui.startSpinner(`Updating sprint ${sprintId}...`);
121
+ const updates = {};
122
+ if (options?.name)
123
+ updates.name = options.name;
124
+ if (options?.goal)
125
+ updates.goal = options.goal;
126
+ if (options?.start)
127
+ updates.startDate = options.start;
128
+ if (options?.end)
129
+ updates.endDate = options.end;
130
+ await updateSprint(sprintId, updates);
131
+ ui.succeedSpinner(chalk.green(`Sprint ${sprintId} updated successfully.`));
132
+ console.log();
133
+ }
134
+ // sprint delete <sprint-id>
135
+ export async function sprintDeleteCommand(sprintId) {
136
+ requirePermission('sprint.delete');
137
+ if (sprintId == null) {
138
+ throw new CommandError('Sprint ID is required.', { hints: ['Provide a numeric sprint ID'] });
139
+ }
140
+ ui.startSpinner(`Deleting sprint ${sprintId}...`);
141
+ await deleteSprint(sprintId);
142
+ ui.succeedSpinner(chalk.green(`Sprint ${sprintId} deleted successfully.`));
143
+ console.log();
144
+ }
145
+ // sprint issues <sprint-id> [--jql <jql>] [--max <n>]
146
+ export async function sprintIssuesCommand(sprintId, options) {
147
+ requirePermission('sprint.issues');
148
+ ui.startSpinner(`Fetching issues for sprint ${sprintId}...`);
149
+ const params = {};
150
+ if (options?.jql)
151
+ params.jql = options.jql;
152
+ if (options?.max)
153
+ params.maxResults = options.max;
154
+ const result = await getSprintIssues(sprintId, params);
155
+ ui.stopSpinner();
156
+ if (!result.issues || result.issues.length === 0) {
157
+ console.log(chalk.yellow('No issues found in this sprint.'));
158
+ return;
159
+ }
160
+ console.log(chalk.bold(`\nSprint Issues (${result.total} total)\n`));
161
+ result.issues.forEach(issue => {
162
+ const status = chalk.gray(`[${issue.fields.status.name}]`);
163
+ console.log(` ${chalk.cyan(issue.key)} ${issue.fields.summary} ${status}`);
164
+ });
165
+ console.log();
166
+ }
167
+ // sprint move <sprint-id> --issues <keys> [--before <key>] [--after <key>]
168
+ export async function sprintMoveCommand(sprintId, options) {
169
+ requirePermission('sprint.move');
170
+ if (!options.issues || options.issues.length === 0) {
171
+ throw new CommandError('At least one issue key is required.', { hints: ['Provide issue keys with --issues'] });
172
+ }
173
+ if (options.issues.length > 50) {
174
+ throw new CommandError('Cannot move more than 50 issues at once.', {
175
+ hints: ['Split your issue list into batches of 50 or fewer'],
176
+ });
177
+ }
178
+ ui.startSpinner(`Moving ${options.issues.length} issue(s) to sprint ${sprintId}...`);
179
+ const rankOptions = {};
180
+ if (options.before)
181
+ rankOptions.rankBeforeIssue = options.before;
182
+ if (options.after)
183
+ rankOptions.rankAfterIssue = options.after;
184
+ await moveIssuesToSprint(sprintId, options.issues, rankOptions);
185
+ ui.succeedSpinner(chalk.green(`Moved ${options.issues.length} issue(s) to sprint ${sprintId}.`));
186
+ console.log();
187
+ }
@@ -1,9 +1,17 @@
1
1
  import chalk from 'chalk';
2
- import { getIssueTransitions, transitionIssue, validateIssuePermissions } from '../lib/jira-client.js';
2
+ import * as fs from 'fs';
3
+ import { getIssueTransitions, transitionIssue, validateIssuePermissions, resolveUserByName } from '../lib/jira-client.js';
3
4
  import { CommandError } from '../lib/errors.js';
4
5
  import { ui } from '../lib/ui.js';
5
6
  import { outputResult } from '../lib/json-mode.js';
6
- export async function transitionCommand(taskId, toStatus) {
7
+ import { markdownToAdf } from 'marklassian';
8
+ import { FieldResolver } from '../lib/field-resolver.js';
9
+ import { processMentionsInADF } from '../lib/adf-mentions.js';
10
+ export async function transitionCommand(taskId, toStatus, options) {
11
+ // Validate mutual exclusivity of --comment and --comment-file
12
+ if (options?.comment && options?.commentFile) {
13
+ throw new CommandError('Cannot use both --comment and --comment-file flags simultaneously.');
14
+ }
7
15
  // Check permissions and filters
8
16
  ui.startSpinner(`Validating permissions for ${taskId}...`);
9
17
  await validateIssuePermissions(taskId, 'transition');
@@ -31,8 +39,66 @@ export async function transitionCommand(taskId, toStatus) {
31
39
  });
32
40
  }
33
41
  const transition = matchingTransitions[0];
42
+ // Build optional payload if any options were provided
43
+ let payload;
44
+ if (options && Object.keys(options).some(k => options[k] !== undefined)) {
45
+ const fields = {};
46
+ const update = {};
47
+ if (options.resolution) {
48
+ fields['resolution'] = { name: options.resolution };
49
+ }
50
+ if (options.comment) {
51
+ let adf = markdownToAdf(options.comment);
52
+ adf = await processMentionsInADF(adf, resolveUserByName);
53
+ update['comment'] = [{ add: { body: adf } }];
54
+ }
55
+ else if (options.commentFile) {
56
+ const content = fs.readFileSync(options.commentFile, 'utf-8');
57
+ let adf = markdownToAdf(content);
58
+ adf = await processMentionsInADF(adf, resolveUserByName);
59
+ update['comment'] = [{ add: { body: adf } }];
60
+ }
61
+ if (options.assignee) {
62
+ if (options.assignee.startsWith('accountid:')) {
63
+ fields['assignee'] = { accountId: options.assignee.slice('accountid:'.length) };
64
+ }
65
+ else {
66
+ const accountId = await resolveUserByName(options.assignee);
67
+ if (!accountId) {
68
+ throw new CommandError(`Could not resolve user: "${options.assignee}". Check the display name and try again.`);
69
+ }
70
+ fields['assignee'] = { accountId };
71
+ }
72
+ }
73
+ if (options.fixVersion) {
74
+ fields['fixVersions'] = options.fixVersion.split(',').map((v) => ({ name: v.trim() }));
75
+ }
76
+ if (options.customFields && options.customFields.length > 0) {
77
+ const resolver = new FieldResolver();
78
+ for (const entry of options.customFields) {
79
+ const eqIdx = entry.indexOf('=');
80
+ if (eqIdx === -1)
81
+ continue;
82
+ const fieldId = entry.slice(0, eqIdx).trim();
83
+ const value = entry.slice(eqIdx + 1).trim();
84
+ try {
85
+ fields[fieldId] = await resolver.coerceValue(fieldId, value);
86
+ }
87
+ catch {
88
+ // Fall back to raw string if field schema is not accessible
89
+ fields[fieldId] = value;
90
+ }
91
+ }
92
+ }
93
+ payload = {
94
+ ...(Object.keys(fields).length > 0 && { fields }),
95
+ ...(Object.keys(update).length > 0 && { update }),
96
+ };
97
+ if (Object.keys(payload).length === 0)
98
+ payload = undefined;
99
+ }
34
100
  ui.startSpinner(`Transitioning ${taskId} to ${transition.to.name}...`);
35
- await transitionIssue(taskId, transition.id);
101
+ await transitionIssue(taskId, transition.id, payload);
36
102
  ui.succeedSpinner(chalk.green(`Issue ${taskId} successfully transitioned to ${transition.to.name}.`));
37
103
  outputResult({ success: true, issueKey: taskId, status: transition.to.name }, (data) => chalk.green(`Issue ${data.issueKey} successfully transitioned to ${data.status}.`));
38
104
  }
@@ -55,3 +121,27 @@ export async function transitionCommand(taskId, toStatus) {
55
121
  throw new CommandError(`Failed to transition issue: ${error.message}`, { hints });
56
122
  }
57
123
  }
124
+ export async function listTransitionsCommand(issueKey, options) {
125
+ await validateIssuePermissions(issueKey, 'transition');
126
+ const transitions = await getIssueTransitions(issueKey);
127
+ let rows = transitions.map((t) => {
128
+ const requiredFieldNames = t.fields
129
+ ? Object.entries(t.fields)
130
+ .filter(([, f]) => f.required)
131
+ .map(([key]) => key)
132
+ : [];
133
+ return {
134
+ id: t.id,
135
+ name: t.name,
136
+ to: t.to.name,
137
+ requiredFields: requiredFieldNames.length > 0 ? requiredFieldNames.join(', ') : '(none)',
138
+ };
139
+ });
140
+ if (options?.requiredOnly) {
141
+ rows = rows.filter((r) => r.requiredFields !== '(none)');
142
+ }
143
+ outputResult(rows, (data) => {
144
+ const lines = data.map((r) => ` ${r.id.padEnd(6)} ${r.name.padEnd(30)} → ${r.to.padEnd(20)} [required: ${r.requiredFields}]`);
145
+ return lines.join('\n');
146
+ });
147
+ }
@@ -0,0 +1,186 @@
1
+ import { AgileClient } from 'jira.js';
2
+ import { loadCredentials } from './auth-storage.js';
3
+ import { CommandError } from './errors.js';
4
+ import { resolveHost } from './jira-client.js';
5
+ /**
6
+ * Map HTTP status codes from Jira Agile API to user-friendly CommandErrors.
7
+ */
8
+ function mapAgileError(error, context) {
9
+ const status = error?.response?.status ?? error?.status;
10
+ const msg = error?.message || String(error);
11
+ if (status === 404) {
12
+ throw new CommandError(`${context} not found. Check that the ID is correct.`, {
13
+ hints: ['Use the list command to find valid IDs'],
14
+ });
15
+ }
16
+ if (status === 400) {
17
+ const detail = error?.response?.data?.message || error?.response?.data || '';
18
+ throw new CommandError(`${context} — bad request: ${detail || msg}`, {
19
+ hints: ['Check that all required fields are provided and the resource is in the correct state'],
20
+ });
21
+ }
22
+ if (status === 403) {
23
+ throw new CommandError(`${context} — permission denied. You do not have access to perform this action.`, {
24
+ hints: ['Check your Jira permissions for agile operations'],
25
+ });
26
+ }
27
+ throw error;
28
+ }
29
+ let agileClientInstance = null;
30
+ export function __resetAgileClient__() {
31
+ agileClientInstance = null;
32
+ }
33
+ export async function getAgileClient() {
34
+ if (!agileClientInstance) {
35
+ const creds = await loadCredentials();
36
+ if (!creds) {
37
+ throw new CommandError('Jira credentials not found. Please run "jira-ai auth"');
38
+ }
39
+ const host = resolveHost(creds);
40
+ agileClientInstance = new AgileClient({
41
+ host,
42
+ authentication: {
43
+ basic: {
44
+ email: creds.email,
45
+ apiToken: creds.apiToken,
46
+ },
47
+ },
48
+ });
49
+ }
50
+ return agileClientInstance;
51
+ }
52
+ // Board wrappers
53
+ export async function getBoards(options) {
54
+ const client = await getAgileClient();
55
+ return client.board.getAllBoards({ ...options });
56
+ }
57
+ export async function getBoard(boardId) {
58
+ const client = await getAgileClient();
59
+ try {
60
+ return await client.board.getBoard({ boardId });
61
+ }
62
+ catch (error) {
63
+ mapAgileError(error, `Board ${boardId}`);
64
+ }
65
+ }
66
+ export async function getBoardConfig(boardId) {
67
+ const client = await getAgileClient();
68
+ try {
69
+ return await client.board.getConfiguration({ boardId });
70
+ }
71
+ catch (error) {
72
+ mapAgileError(error, `Board config for ${boardId}`);
73
+ }
74
+ }
75
+ export async function getBoardIssues(boardId, options) {
76
+ const client = await getAgileClient();
77
+ try {
78
+ return await client.board.getIssuesForBoard({ boardId, ...options });
79
+ }
80
+ catch (error) {
81
+ mapAgileError(error, `Board issues for ${boardId}`);
82
+ }
83
+ }
84
+ // Sprint wrappers
85
+ export async function getSprints(boardId, options) {
86
+ const client = await getAgileClient();
87
+ try {
88
+ // getAllSprints is on client.board in jira.js v5.2.2, not client.sprint
89
+ return await client.board.getAllSprints({ boardId, ...options });
90
+ }
91
+ catch (error) {
92
+ const msg = error?.message || '';
93
+ if (msg.toLowerCase().includes('board does not support sprints') || msg.toLowerCase().includes('does not support sprints')) {
94
+ throw new CommandError('This board does not support sprints. Sprint operations require a Scrum board.', {
95
+ hints: ['Use a Scrum board for sprint operations', 'Kanban boards do not support sprints'],
96
+ });
97
+ }
98
+ throw error;
99
+ }
100
+ }
101
+ export async function getSprint(sprintId) {
102
+ const client = await getAgileClient();
103
+ try {
104
+ return await client.sprint.getSprint({ sprintId });
105
+ }
106
+ catch (error) {
107
+ mapAgileError(error, `Sprint ${sprintId}`);
108
+ }
109
+ }
110
+ export async function createSprint(boardId, name, options) {
111
+ const client = await getAgileClient();
112
+ return client.sprint.createSprint({
113
+ originBoardId: boardId,
114
+ name,
115
+ ...options,
116
+ });
117
+ }
118
+ export async function startSprint(sprintId) {
119
+ const client = await getAgileClient();
120
+ await client.sprint.partiallyUpdateSprint({ sprintId, state: 'active' });
121
+ }
122
+ export async function completeSprint(sprintId) {
123
+ const client = await getAgileClient();
124
+ await client.sprint.partiallyUpdateSprint({ sprintId, state: 'closed' });
125
+ }
126
+ export async function updateSprint(sprintId, updates) {
127
+ const client = await getAgileClient();
128
+ await client.sprint.partiallyUpdateSprint({ sprintId, ...updates });
129
+ }
130
+ export async function deleteSprint(sprintId) {
131
+ const client = await getAgileClient();
132
+ try {
133
+ await client.sprint.deleteSprint({ sprintId });
134
+ }
135
+ catch (error) {
136
+ mapAgileError(error, `Sprint ${sprintId}`);
137
+ }
138
+ }
139
+ export async function getSprintIssues(sprintId, options) {
140
+ const client = await getAgileClient();
141
+ return client.sprint.getIssuesForSprint({ sprintId, ...options });
142
+ }
143
+ export async function moveIssuesToSprint(sprintId, issues, options) {
144
+ if (issues.length > 50) {
145
+ throw new CommandError('Cannot move more than 50 issues at once.', {
146
+ hints: ['Split your issue list into batches of 50 or fewer'],
147
+ });
148
+ }
149
+ const client = await getAgileClient();
150
+ try {
151
+ await client.sprint.moveIssuesToSprintAndRank({ sprintId, issues, ...options });
152
+ }
153
+ catch (error) {
154
+ mapAgileError(error, `Move issues to sprint ${sprintId}`);
155
+ }
156
+ }
157
+ // Backlog wrapper
158
+ export async function moveIssuesToBacklog(issues) {
159
+ if (issues.length > 50) {
160
+ throw new CommandError('Cannot move more than 50 issues at once.', {
161
+ hints: ['Split your issue list into batches of 50 or fewer'],
162
+ });
163
+ }
164
+ const client = await getAgileClient();
165
+ try {
166
+ await client.backlog.moveIssuesToBacklog({ issues });
167
+ }
168
+ catch (error) {
169
+ mapAgileError(error, `Move issues to backlog`);
170
+ }
171
+ }
172
+ // Rank wrapper
173
+ export async function rankIssues(issues, options) {
174
+ if (issues.length > 50) {
175
+ throw new CommandError('Cannot rank more than 50 issues at once.', {
176
+ hints: ['Split your issue list into batches of 50 or fewer'],
177
+ });
178
+ }
179
+ const client = await getAgileClient();
180
+ try {
181
+ await client.issue.rankIssues({ issues, ...options });
182
+ }
183
+ catch (error) {
184
+ mapAgileError(error, `Rank issues`);
185
+ }
186
+ }
@@ -5,7 +5,7 @@ import { applyGlobalFilters, isProjectAllowed, isCommandAllowed, validateIssueAg
5
5
  import { CommandError } from './errors.js';
6
6
  import { getEpicFields, isNextGenProject } from './epic-fields.js';
7
7
  let jiraClient = null;
8
- function resolveHost(creds) {
8
+ export function resolveHost(creds) {
9
9
  if (creds.authType === 'service_account' && creds.cloudId) {
10
10
  return `https://api.atlassian.com/ex/jira/${creds.cloudId}`;
11
11
  }
@@ -463,6 +463,7 @@ export async function getIssueTransitions(issueIdOrKey) {
463
463
  const client = getJiraClient();
464
464
  const response = await client.issues.getTransitions({
465
465
  issueIdOrKey,
466
+ expand: "transitions.fields",
466
467
  });
467
468
  return (response.transitions || []).map((t) => ({
468
469
  id: t.id || '',
@@ -471,18 +472,21 @@ export async function getIssueTransitions(issueIdOrKey) {
471
472
  id: t.to?.id || '',
472
473
  name: t.to?.name || '',
473
474
  },
475
+ fields: t.fields,
474
476
  }));
475
477
  }
476
478
  /**
477
479
  * Perform a transition on an issue
478
480
  */
479
- export async function transitionIssue(issueIdOrKey, transitionId) {
481
+ export async function transitionIssue(issueIdOrKey, transitionId, payload) {
480
482
  const client = getJiraClient();
481
483
  await client.issues.doTransition({
482
484
  issueIdOrKey,
483
485
  transition: {
484
486
  id: transitionId,
485
487
  },
488
+ ...(payload?.fields && { fields: payload.fields }),
489
+ ...(payload?.update && { update: payload.update }),
486
490
  });
487
491
  }
488
492
  /**
@@ -0,0 +1,13 @@
1
+ import { isCommandAllowed, getAllowedCommands } from './settings.js';
2
+ import { CommandError } from './errors.js';
3
+ /**
4
+ * Throws a CommandError if the given command is not allowed by current settings.
5
+ * Shared utility used by board, sprint, backlog, and other command modules.
6
+ */
7
+ export function requirePermission(command) {
8
+ if (!isCommandAllowed(command)) {
9
+ throw new CommandError(`Command '${command}' is not allowed.`, {
10
+ hints: [`Allowed commands: ${getAllowedCommands().join(', ')}`, 'Update settings.yaml to enable this command.'],
11
+ });
12
+ }
13
+ }
@@ -39,7 +39,10 @@ export const DEFAULT_ORG_SETTINGS = {
39
39
  'issue',
40
40
  'project',
41
41
  'user',
42
- 'confl'
42
+ 'confl',
43
+ 'board',
44
+ 'sprint',
45
+ 'backlog',
43
46
  ],
44
47
  'allowed-confluence-spaces': ['all']
45
48
  };
@@ -187,3 +187,56 @@ export const EpicLinkSchema = z.object({
187
187
  export const EpicMaxSchema = z.object({
188
188
  max: NumericStringSchema.optional(),
189
189
  });
190
+ // =============================================================================
191
+ // BOARD VALIDATION SCHEMAS
192
+ // =============================================================================
193
+ export const BoardListSchema = z.object({
194
+ projectKey: z.string().trim().optional(),
195
+ type: z.string().trim().optional(),
196
+ });
197
+ export const BoardIssuesSchema = z.object({
198
+ jql: z.string().trim().optional(),
199
+ max: NumericStringSchema.optional(),
200
+ });
201
+ export const BoardRankSchema = z.object({
202
+ issues: z.array(z.string().trim().min(1)).min(1, 'At least one issue key is required'),
203
+ before: z.string().trim().optional(),
204
+ after: z.string().trim().optional(),
205
+ }).refine(data => data.before || data.after, {
206
+ message: 'Either --before or --after must be specified',
207
+ });
208
+ // =============================================================================
209
+ // SPRINT VALIDATION SCHEMAS
210
+ // =============================================================================
211
+ export const SprintListSchema = z.object({
212
+ state: z.enum(['future', 'active', 'closed']).optional(),
213
+ });
214
+ export const SprintCreateSchema = z.object({
215
+ name: z.string().trim().min(1, 'Sprint name is required'),
216
+ goal: z.string().trim().optional(),
217
+ start: z.string().trim().optional(),
218
+ end: z.string().trim().optional(),
219
+ });
220
+ export const SprintUpdateSchema = z.object({
221
+ name: z.string().trim().min(1).optional(),
222
+ goal: z.string().trim().optional(),
223
+ start: z.string().trim().optional(),
224
+ end: z.string().trim().optional(),
225
+ }).refine(data => data.name || data.goal || data.start || data.end, {
226
+ message: 'At least one of --name, --goal, --start, or --end is required',
227
+ });
228
+ export const SprintIssuesSchema = z.object({
229
+ jql: z.string().trim().optional(),
230
+ max: NumericStringSchema.optional(),
231
+ });
232
+ export const SprintMoveSchema = z.object({
233
+ issues: z.array(z.string().trim().min(1)).min(1, 'At least one issue key is required'),
234
+ before: z.string().trim().optional(),
235
+ after: z.string().trim().optional(),
236
+ });
237
+ // =============================================================================
238
+ // BACKLOG VALIDATION SCHEMAS
239
+ // =============================================================================
240
+ export const BacklogMoveSchema = z.object({
241
+ issues: z.array(z.string().trim().min(1)).min(1, 'At least one issue key is required'),
242
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jira-ai",
3
- "version": "0.9.97",
3
+ "version": "0.9.99",
4
4
  "description": "AI friendly Jira CLI to save context",
5
5
  "type": "module",
6
6
  "main": "dist/cli.js",