jira-ai 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -49,6 +49,34 @@ Errors are returned as structured JSON to stdout:
49
49
  { "error": true, "message": "Issue not found", "hints": ["Check the issue key"], "exitCode": 1 }
50
50
  ```
51
51
 
52
+ ### Dry-Run / Preview Mode
53
+
54
+ Preview write operations without executing them. The `--dry-run` flag is available on `issue create`, `issue update`, `issue transition`, and `issue worklog add/update/delete`. No Jira API write calls are made — output is purely a preview.
55
+
56
+ ```bash
57
+ jira-ai issue update PROJ-123 --priority High --dry-run
58
+ jira-ai issue transition PROJ-123 Done --resolution Fixed --dry-run
59
+ jira-ai issue create --project PROJ --type Bug --title "Fix crash" --dry-run
60
+ jira-ai issue worklog add PROJ-123 --time 2h --comment "Debugging" --dry-run
61
+ ```
62
+
63
+ Dry-run output follows a consistent JSON structure:
64
+
65
+ ```json
66
+ {
67
+ "dryRun": true,
68
+ "command": "issue.update",
69
+ "target": "PROJ-123",
70
+ "changes": {
71
+ "priority": { "from": "Medium", "to": "High" }
72
+ },
73
+ "preview": { "...": "same output as the real command" },
74
+ "message": "No changes were made. Remove --dry-run to execute."
75
+ }
76
+ ```
77
+
78
+ The `preview` field contains the same output the real command would produce, so AI agents can process it identically. Phase 1 supports `issue create`, `issue update`, and `issue transition`. Worklog dry-run: `issue worklog add`, `issue worklog update`, `issue worklog delete`.
79
+
52
80
  ### Issue Hierarchy Tree
53
81
 
54
82
  Explore issue hierarchies with the `issue tree` command. It returns a directed graph (nodes + edges) representing the full parent-child hierarchy starting from a given issue:
@@ -147,6 +175,108 @@ jira-ai issue transitions PROJ-123
147
175
 
148
176
  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.
149
177
 
178
+ ### Activity Feed & Comments
179
+
180
+ View a unified activity feed combining changelog entries and comments for an issue:
181
+
182
+ ```bash
183
+ jira-ai issue activity PROJ-123
184
+ ```
185
+
186
+ Filter by time, activity type, or author:
187
+
188
+ ```bash
189
+ jira-ai issue activity PROJ-123 --since 2026-01-01T00:00:00Z --types status_change,comment_added --author "Jane Smith"
190
+ ```
191
+
192
+ Use `--compact` to strip comment bodies for maximum token efficiency:
193
+
194
+ ```bash
195
+ jira-ai issue activity PROJ-123 --compact
196
+ ```
197
+
198
+ List comments on an issue:
199
+
200
+ ```bash
201
+ jira-ai issue comments PROJ-123
202
+ ```
203
+
204
+ Use `--reverse` for chronological (oldest-first) order, or `--since` to filter by time:
205
+
206
+ ```bash
207
+ jira-ai issue comments PROJ-123 --since 2026-01-01T00:00:00Z --reverse --limit 20
208
+ ```
209
+
210
+ **Activity types:** `status_change`, `field_change`, `comment_added`, `comment_updated`, `attachment_added`, `attachment_removed`, `link_added`, `link_removed`
211
+
212
+ ### Worklog Management
213
+
214
+ Log time against issues with full CRUD support:
215
+
216
+ ```bash
217
+ jira-ai issue worklog add PROJ-123 --time 2h
218
+ ```
219
+
220
+ Add a comment and specify when the work started:
221
+
222
+ ```bash
223
+ jira-ai issue worklog add PROJ-123 --time 1d2h30m --comment "Backend refactor" --started "2026-04-15T09:00:00+02:00"
224
+ ```
225
+
226
+ The `--started` flag accepts any standard ISO 8601 timestamp. Timezone offsets are automatically normalized to Jira's required format (`yyyy-MM-dd'T'HH:mm:ss.SSS±HHMM`):
227
+
228
+ - `2026-04-15T07:00:00Z` → `2026-04-15T07:00:00.000+0000`
229
+ - `2026-04-15T10:00:00+03:00` → `2026-04-15T10:00:00.000+0300`
230
+ - `2026-04-15T07:00:00-05:30` → `2026-04-15T07:00:00.000-0530`
231
+
232
+ When omitted, `--started` defaults to the current time.
233
+
234
+ Log time with estimate adjustment (`--adjust-estimate` accepts `auto`, `new`, `leave`, or `manual`):
235
+
236
+ ```bash
237
+ jira-ai issue worklog add PROJ-123 --time 4h --adjust-estimate new --new-estimate 2d
238
+ ```
239
+
240
+ Use `--reduce-by` with `--adjust-estimate manual` to decrease the remaining estimate:
241
+
242
+ ```bash
243
+ jira-ai issue worklog add PROJ-123 --time 3h --adjust-estimate manual --reduce-by 2h
244
+ ```
245
+
246
+ List worklogs for an issue:
247
+
248
+ ```bash
249
+ jira-ai issue worklog list PROJ-123
250
+ ```
251
+
252
+ Filter by time range or author:
253
+
254
+ ```bash
255
+ jira-ai issue worklog list PROJ-123 --started-after 1713139200000 --started-before 1715731200000
256
+ jira-ai issue worklog list PROJ-123 --author-account-id 557058:abc123-def456-ghi789
257
+ ```
258
+
259
+ Update an existing worklog:
260
+
261
+ ```bash
262
+ jira-ai issue worklog update PROJ-123 --id 12345 --time 3h --comment "Updated after review"
263
+ ```
264
+
265
+ Delete a worklog (use `--increase-by` with `--adjust-estimate manual` to restore estimate):
266
+
267
+ ```bash
268
+ jira-ai issue worklog delete PROJ-123 --id 12345
269
+ jira-ai issue worklog delete PROJ-123 --id 12345 --adjust-estimate manual --increase-by 2h
270
+ ```
271
+
272
+ Preview any write operation with `--dry-run`:
273
+
274
+ ```bash
275
+ jira-ai issue worklog add PROJ-123 --time 2h --dry-run
276
+ ```
277
+
278
+ Duration format uses Jira-style notation: `1w` (5 working days), `1d` (8 hours), `1h`, `30m`, or combinations like `1d2h30m`.
279
+
150
280
  ## Service Account Authentication
151
281
 
152
282
  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
@@ -31,6 +31,9 @@ import { epicListCommand, epicGetCommand, epicCreateCommand, epicUpdateCommand,
31
31
  import { boardListCommand, boardGetCommand, boardConfigCommand, boardIssuesCommand, boardRankCommand, } from './commands/board.js';
32
32
  import { sprintListCommand, sprintGetCommand, sprintCreateCommand, sprintStartCommand, sprintCompleteCommand, sprintUpdateCommand, sprintDeleteCommand, sprintIssuesCommand, sprintMoveCommand, } from './commands/sprint.js';
33
33
  import { backlogMoveCommand } from './commands/backlog.js';
34
+ import { issueCommentsCommand } from './commands/issue-comments.js';
35
+ import { issueActivityCommand } from './commands/issue-activity.js';
36
+ import { issueWorklogListCommand, issueWorklogAddCommand, issueWorklogUpdateCommand, issueWorklogDeleteCommand, } from './commands/issue-worklog.js';
34
37
  import { aboutCommand } from './commands/about.js';
35
38
  import { authCommand } from './commands/auth.js';
36
39
  import { settingsCommand } from './commands/settings.js';
@@ -208,6 +211,123 @@ issue
208
211
  schema: UpdateDescriptionSchema,
209
212
  validateArgs: (args) => validateOptions(IssueKeySchema, args[0])
210
213
  }));
214
+ issue
215
+ .command('comments <issue-id>')
216
+ .description('List comments on a Jira issue.')
217
+ .option('--limit <n>', 'Maximum number of comments to return (default: 50)')
218
+ .option('--since <iso>', 'Only include comments created on or after this ISO timestamp')
219
+ .option('--reverse', 'Return comments in chronological order (oldest first)')
220
+ .action(withPermission('issue.comments', (issueKey, options) => {
221
+ return issueCommentsCommand({
222
+ issueKey,
223
+ limit: options.limit ? parseInt(options.limit, 10) : undefined,
224
+ since: options.since,
225
+ reverse: options.reverse,
226
+ });
227
+ }, {
228
+ validateArgs: (args) => validateOptions(IssueKeySchema, args[0])
229
+ }));
230
+ issue
231
+ .command('activity <issue-id>')
232
+ .description('Show a unified activity feed (changelog + comments) for a Jira issue.')
233
+ .option('--since <iso>', 'Only include activities on or after this ISO timestamp')
234
+ .option('--limit <n>', 'Maximum number of activities to return (default: 50)')
235
+ .option('--types <types>', 'Comma-separated activity types to include (e.g., status_change,comment_added)')
236
+ .option('--author <name-or-email>', 'Filter by author display name, email, or accountId')
237
+ .action(withPermission('issue.activity', (issueKey, options) => {
238
+ return issueActivityCommand({
239
+ issueKey,
240
+ since: options.since,
241
+ limit: options.limit ? parseInt(options.limit, 10) : undefined,
242
+ types: options.types,
243
+ author: options.author,
244
+ compact: program.opts().compact || options.compact,
245
+ });
246
+ }, {
247
+ validateArgs: (args) => validateOptions(IssueKeySchema, args[0])
248
+ }));
249
+ // Issue worklog subcommands
250
+ const worklog = issue
251
+ .command('worklog')
252
+ .description('Manage worklogs for a Jira issue');
253
+ worklog
254
+ .command('list <issue-id>')
255
+ .description('List all worklogs for a Jira issue.')
256
+ .option('--started-after <timestamp>', 'Only return worklogs started at or after this UNIX timestamp (ms)')
257
+ .option('--started-before <timestamp>', 'Only return worklogs started before this UNIX timestamp (ms)')
258
+ .option('--author-account-id <accountId>', 'Filter by author account ID')
259
+ .action(withPermission('issue.worklog.list', (issueKey, options) => {
260
+ return issueWorklogListCommand({
261
+ issueKey,
262
+ startedAfter: options.startedAfter ? parseInt(options.startedAfter, 10) : undefined,
263
+ startedBefore: options.startedBefore ? parseInt(options.startedBefore, 10) : undefined,
264
+ authorAccountId: options.authorAccountId,
265
+ });
266
+ }, {
267
+ validateArgs: (args) => validateOptions(IssueKeySchema, args[0])
268
+ }));
269
+ worklog
270
+ .command('add <issue-id>')
271
+ .description('Log time against a Jira issue.')
272
+ .requiredOption('--time <duration>', 'Time to log (e.g. 1h, 30m, 1d2h30m, 1w)')
273
+ .option('--comment <text>', 'Optional comment for this worklog entry')
274
+ .option('--started <datetime>', 'When the work started (ISO 8601, defaults to now). Timezone offsets are auto-normalized.')
275
+ .option('--adjust-estimate <method>', 'Estimate adjustment: auto, new, leave, manual')
276
+ .option('--new-estimate <duration>', 'New remaining estimate (use with --adjust-estimate new or manual)')
277
+ .option('--reduce-by <duration>', 'Reduce remaining estimate by this amount (use with --adjust-estimate manual)')
278
+ .action(withPermission('issue.worklog.add', (issueKey, options) => {
279
+ return issueWorklogAddCommand({
280
+ issueKey,
281
+ time: options.time,
282
+ comment: options.comment,
283
+ started: options.started,
284
+ adjustEstimate: options.adjustEstimate,
285
+ newEstimate: options.newEstimate,
286
+ reduceBy: options.reduceBy,
287
+ });
288
+ }, {
289
+ validateArgs: (args) => validateOptions(IssueKeySchema, args[0])
290
+ }));
291
+ worklog
292
+ .command('update <issue-id>')
293
+ .description('Update an existing worklog entry.')
294
+ .requiredOption('--id <worklog-id>', 'ID of the worklog to update')
295
+ .option('--time <duration>', 'New time spent (e.g. 1h, 30m, 1d)')
296
+ .option('--comment <text>', 'New comment for this worklog')
297
+ .option('--started <datetime>', 'New start time (ISO 8601). Timezone offsets are auto-normalized.')
298
+ .option('--adjust-estimate <method>', 'Estimate adjustment: auto, new, leave, manual')
299
+ .option('--new-estimate <duration>', 'New remaining estimate')
300
+ .action(withPermission('issue.worklog.update', (issueKey, options) => {
301
+ return issueWorklogUpdateCommand({
302
+ issueKey,
303
+ id: options.id,
304
+ time: options.time,
305
+ comment: options.comment,
306
+ started: options.started,
307
+ adjustEstimate: options.adjustEstimate,
308
+ newEstimate: options.newEstimate,
309
+ });
310
+ }, {
311
+ validateArgs: (args) => validateOptions(IssueKeySchema, args[0])
312
+ }));
313
+ worklog
314
+ .command('delete <issue-id>')
315
+ .description('Delete a worklog entry from a Jira issue.')
316
+ .requiredOption('--id <worklog-id>', 'ID of the worklog to delete')
317
+ .option('--adjust-estimate <method>', 'Estimate adjustment: auto, new, leave, manual')
318
+ .option('--new-estimate <duration>', 'New remaining estimate (use with --adjust-estimate new)')
319
+ .option('--increase-by <duration>', 'Increase remaining estimate by this amount')
320
+ .action(withPermission('issue.worklog.delete', (issueKey, options) => {
321
+ return issueWorklogDeleteCommand({
322
+ issueKey,
323
+ id: options.id,
324
+ adjustEstimate: options.adjustEstimate,
325
+ newEstimate: options.newEstimate,
326
+ increaseBy: options.increaseBy,
327
+ });
328
+ }, {
329
+ validateArgs: (args) => validateOptions(IssueKeySchema, args[0])
330
+ }));
211
331
  issue
212
332
  .command('stats <issue-ids>')
213
333
  .description('Calculate time-based metrics for one or more issues (comma-separated). Shows time logged, estimates, and status breakdown.')
@@ -0,0 +1,38 @@
1
+ import { getIssueActivityFeed } from '../lib/jira-client.js';
2
+ import { CommandError } from '../lib/errors.js';
3
+ import { outputResult } from '../lib/json-mode.js';
4
+ export async function issueActivityCommand(options) {
5
+ const { issueKey, since, limit, types, author, compact } = options;
6
+ if (since !== undefined && isNaN(new Date(since).getTime())) {
7
+ throw new CommandError('--since must be a valid ISO 8601 datetime (e.g. 2024-01-01T00:00:00Z)');
8
+ }
9
+ if (limit !== undefined && (limit < 1 || !Number.isInteger(limit))) {
10
+ throw new CommandError('--limit must be a positive integer (>= 1)');
11
+ }
12
+ try {
13
+ const result = await getIssueActivityFeed(issueKey, { since, limit, types, author });
14
+ if (compact) {
15
+ const compactResult = {
16
+ ...result,
17
+ activities: result.activities.map(({ commentBody: _omit, ...rest }) => rest),
18
+ };
19
+ outputResult(compactResult);
20
+ }
21
+ else {
22
+ outputResult(result);
23
+ }
24
+ }
25
+ catch (error) {
26
+ if (error instanceof CommandError)
27
+ throw error;
28
+ const errorMsg = error.message?.toLowerCase() || '';
29
+ const hints = [];
30
+ if (errorMsg.includes('404')) {
31
+ hints.push('Check that the issue key is correct');
32
+ }
33
+ else if (errorMsg.includes('403')) {
34
+ hints.push('You may not have permission to view activity on this issue');
35
+ }
36
+ throw new CommandError(`Failed to get activity feed: ${error.message}`, { hints });
37
+ }
38
+ }
@@ -0,0 +1,29 @@
1
+ import { getIssueCommentsList } from '../lib/jira-client.js';
2
+ import { CommandError } from '../lib/errors.js';
3
+ import { outputResult } from '../lib/json-mode.js';
4
+ export async function issueCommentsCommand(options) {
5
+ const { issueKey, limit, since, reverse } = options;
6
+ if (since !== undefined && isNaN(new Date(since).getTime())) {
7
+ throw new CommandError('--since must be a valid ISO 8601 datetime (e.g. 2024-01-01T00:00:00Z)');
8
+ }
9
+ if (limit !== undefined && (limit < 1 || !Number.isInteger(limit))) {
10
+ throw new CommandError('--limit must be a positive integer (>= 1)');
11
+ }
12
+ try {
13
+ const result = await getIssueCommentsList(issueKey, { limit, since, reverse });
14
+ outputResult(result);
15
+ }
16
+ catch (error) {
17
+ if (error instanceof CommandError)
18
+ throw error;
19
+ const errorMsg = error.message?.toLowerCase() || '';
20
+ const hints = [];
21
+ if (errorMsg.includes('404')) {
22
+ hints.push('Check that the issue key is correct');
23
+ }
24
+ else if (errorMsg.includes('403')) {
25
+ hints.push('You may not have permission to view comments on this issue');
26
+ }
27
+ throw new CommandError(`Failed to get comments: ${error.message}`, { hints });
28
+ }
29
+ }
@@ -0,0 +1,162 @@
1
+ import { getIssueWorklogsList, addWorklogEntry, updateWorklogEntry, deleteWorklogEntry, } from '../lib/jira-client.js';
2
+ import { CommandError } from '../lib/errors.js';
3
+ import { outputResult } from '../lib/json-mode.js';
4
+ import { isDryRun, formatDryRunResult } from '../lib/dry-run.js';
5
+ import { parseDuration, normalizeJiraTimestamp } from '../lib/utils.js';
6
+ export async function issueWorklogListCommand(options) {
7
+ const { issueKey, startedAfter, startedBefore, authorAccountId } = options;
8
+ const filterOptions = {};
9
+ if (startedAfter !== undefined)
10
+ filterOptions.startedAfter = startedAfter;
11
+ if (startedBefore !== undefined)
12
+ filterOptions.startedBefore = startedBefore;
13
+ if (authorAccountId !== undefined)
14
+ filterOptions.authorAccountId = authorAccountId;
15
+ try {
16
+ const result = await getIssueWorklogsList(issueKey, filterOptions);
17
+ outputResult(result);
18
+ }
19
+ catch (error) {
20
+ if (error instanceof CommandError)
21
+ throw error;
22
+ const errorMsg = error.message?.toLowerCase() || '';
23
+ const hints = [];
24
+ if (errorMsg.includes('404')) {
25
+ hints.push('Check that the issue key is correct');
26
+ }
27
+ else if (errorMsg.includes('403')) {
28
+ hints.push('You may not have permission to view worklogs on this issue');
29
+ }
30
+ throw new CommandError(`Failed to list worklogs: ${error.message}`, { hints });
31
+ }
32
+ }
33
+ export async function issueWorklogAddCommand(options) {
34
+ const { issueKey, time, comment, started, adjustEstimate, newEstimate, reduceBy } = options;
35
+ const timeSpentSeconds = parseDuration(time);
36
+ if (timeSpentSeconds === null) {
37
+ throw new CommandError(`Invalid duration: "${time}". Use Jira format e.g. 1h, 30m, 1d2h30m, 1w.`, { hints: ['Examples: 1h, 30m, 1d, 1w, 1d2h30m'] });
38
+ }
39
+ if (adjustEstimate === 'new' && !newEstimate) {
40
+ throw new CommandError('--new-estimate is required when --adjust-estimate is "new"', {
41
+ hints: ['Example: --adjust-estimate new --new-estimate 5h']
42
+ });
43
+ }
44
+ if (adjustEstimate === 'manual' && !newEstimate && !reduceBy) {
45
+ throw new CommandError('--new-estimate or --reduce-by is required when --adjust-estimate is "manual"', {
46
+ hints: ['Example: --adjust-estimate manual --reduce-by 1h']
47
+ });
48
+ }
49
+ if (isDryRun()) {
50
+ formatDryRunResult('issue worklog add', issueKey, { timeSpentSeconds, comment, started, adjustEstimate, newEstimate, reduceBy });
51
+ return;
52
+ }
53
+ try {
54
+ const result = await addWorklogEntry(issueKey, {
55
+ timeSpentSeconds,
56
+ comment,
57
+ started: started !== undefined ? normalizeJiraTimestamp(started) : undefined,
58
+ adjustEstimate,
59
+ newEstimate,
60
+ reduceBy,
61
+ });
62
+ outputResult(result);
63
+ }
64
+ catch (error) {
65
+ if (error instanceof CommandError)
66
+ throw error;
67
+ const errorMsg = error.message?.toLowerCase() || '';
68
+ const hints = [];
69
+ if (errorMsg.includes('404')) {
70
+ hints.push('Check that the issue key is correct');
71
+ }
72
+ else if (errorMsg.includes('403')) {
73
+ hints.push('You may not have permission to log work on this issue');
74
+ }
75
+ throw new CommandError(`Failed to add worklog: ${error.message}`, { hints });
76
+ }
77
+ }
78
+ export async function issueWorklogUpdateCommand(options) {
79
+ const { issueKey, id, time, comment, started, adjustEstimate, newEstimate } = options;
80
+ if (time === undefined && comment === undefined && started === undefined) {
81
+ throw new CommandError('At least one of --time, --comment, or --started must be provided.');
82
+ }
83
+ let timeSpentSeconds;
84
+ if (time !== undefined) {
85
+ const parsed = parseDuration(time);
86
+ if (parsed === null) {
87
+ throw new CommandError(`Invalid duration: "${time}". Use Jira format e.g. 1h, 30m, 1d2h30m, 1w.`, { hints: ['Examples: 1h, 30m, 1d, 1w, 1d2h30m'] });
88
+ }
89
+ timeSpentSeconds = parsed;
90
+ }
91
+ if (adjustEstimate === 'new' && !newEstimate) {
92
+ throw new CommandError('--new-estimate is required when --adjust-estimate is "new"', {
93
+ hints: ['Example: --adjust-estimate new --new-estimate 5h']
94
+ });
95
+ }
96
+ if (adjustEstimate === 'manual' && !newEstimate) {
97
+ throw new CommandError('--new-estimate is required when --adjust-estimate is "manual"', {
98
+ hints: ['Example: --adjust-estimate manual --new-estimate 5h']
99
+ });
100
+ }
101
+ if (isDryRun()) {
102
+ formatDryRunResult('issue worklog update', `${issueKey} / worklog ${id}`, { timeSpentSeconds, comment, started, adjustEstimate, newEstimate });
103
+ return;
104
+ }
105
+ try {
106
+ const result = await updateWorklogEntry(issueKey, id, {
107
+ timeSpentSeconds,
108
+ comment,
109
+ started: started !== undefined ? normalizeJiraTimestamp(started) : undefined,
110
+ adjustEstimate,
111
+ newEstimate,
112
+ });
113
+ outputResult(result);
114
+ }
115
+ catch (error) {
116
+ if (error instanceof CommandError)
117
+ throw error;
118
+ const errorMsg = error.message?.toLowerCase() || '';
119
+ const hints = [];
120
+ if (errorMsg.includes('404')) {
121
+ hints.push('Check that the issue key and worklog ID are correct');
122
+ }
123
+ else if (errorMsg.includes('403')) {
124
+ hints.push('You may not have permission to update this worklog');
125
+ }
126
+ throw new CommandError(`Failed to update worklog: ${error.message}`, { hints });
127
+ }
128
+ }
129
+ export async function issueWorklogDeleteCommand(options) {
130
+ const { issueKey, id, adjustEstimate, newEstimate, increaseBy } = options;
131
+ if (adjustEstimate === 'new' && !newEstimate) {
132
+ throw new CommandError('--new-estimate is required when --adjust-estimate is "new"', {
133
+ hints: ['Example: --adjust-estimate new --new-estimate 5h']
134
+ });
135
+ }
136
+ if (adjustEstimate === 'manual' && !newEstimate && !increaseBy) {
137
+ throw new CommandError('--new-estimate or --increase-by is required when --adjust-estimate is "manual"', {
138
+ hints: ['Example: --adjust-estimate manual --increase-by 1h']
139
+ });
140
+ }
141
+ if (isDryRun()) {
142
+ formatDryRunResult('issue worklog delete', `${issueKey} / worklog ${id}`, { id, adjustEstimate, newEstimate, increaseBy });
143
+ return;
144
+ }
145
+ try {
146
+ await deleteWorklogEntry(issueKey, id, { adjustEstimate, newEstimate, increaseBy });
147
+ outputResult({ deleted: true, issueKey, id });
148
+ }
149
+ catch (error) {
150
+ if (error instanceof CommandError)
151
+ throw error;
152
+ const errorMsg = error.message?.toLowerCase() || '';
153
+ const hints = [];
154
+ if (errorMsg.includes('404')) {
155
+ hints.push('Check that the issue key and worklog ID are correct');
156
+ }
157
+ else if (errorMsg.includes('403')) {
158
+ hints.push('You may not have permission to delete this worklog');
159
+ }
160
+ throw new CommandError(`Failed to delete worklog: ${error.message}`, { hints });
161
+ }
162
+ }
@@ -1,4 +1,5 @@
1
1
  import { Version3Client } from 'jira.js';
2
+ import { markdownToAdf } from 'marklassian';
2
3
  import { calculateStatusStatistics, convertADFToMarkdown } from './utils.js';
3
4
  import { loadCredentials } from './auth-storage.js';
4
5
  import { applyGlobalFilters, isProjectAllowed, isCommandAllowed, validateIssueAgainstFilters, getAllowedProjects } from './settings.js';
@@ -1044,6 +1045,183 @@ export async function downloadAttachment(issueKey, attachmentId, outputPath) {
1044
1045
  fs.writeFileSync(destPath, Buffer.from(content));
1045
1046
  return destPath;
1046
1047
  }
1048
+ /**
1049
+ * Get comments for a Jira issue with pagination, since-filter, and ordering
1050
+ */
1051
+ export async function getIssueCommentsList(issueKey, options = {}) {
1052
+ const client = getJiraClient();
1053
+ const { limit = 50, since, reverse = false } = options;
1054
+ const maxResults = 100;
1055
+ let startAt = 0;
1056
+ let allComments = [];
1057
+ let total = 0;
1058
+ // Paginate through all comments
1059
+ while (true) {
1060
+ const response = await client.issueComments.getComments({
1061
+ issueIdOrKey: issueKey,
1062
+ maxResults,
1063
+ startAt,
1064
+ orderBy: 'created',
1065
+ });
1066
+ total = response.total ?? 0;
1067
+ const pageComments = (response.comments || []).map((c) => ({
1068
+ id: c.id || '',
1069
+ author: {
1070
+ accountId: c.author?.accountId || '',
1071
+ displayName: c.author?.displayName || 'Unknown',
1072
+ emailAddress: c.author?.emailAddress,
1073
+ },
1074
+ body: convertADFToMarkdown(c.body) || '',
1075
+ created: c.created || '',
1076
+ updated: c.updated || '',
1077
+ }));
1078
+ allComments = allComments.concat(pageComments);
1079
+ if (allComments.length >= total || pageComments.length < maxResults) {
1080
+ break;
1081
+ }
1082
+ startAt += maxResults;
1083
+ }
1084
+ // Apply --since filter
1085
+ if (since) {
1086
+ const sinceDate = new Date(since).getTime();
1087
+ allComments = allComments.filter(c => new Date(c.created).getTime() >= sinceDate);
1088
+ }
1089
+ // Apply --reverse (ascending order; default is newest first)
1090
+ if (!reverse) {
1091
+ allComments = allComments.reverse();
1092
+ }
1093
+ // Apply --limit
1094
+ const hasMore = allComments.length > limit;
1095
+ const comments = allComments.slice(0, limit);
1096
+ return {
1097
+ issueKey,
1098
+ comments,
1099
+ total,
1100
+ hasMore,
1101
+ };
1102
+ }
1103
+ /**
1104
+ * Get unified activity feed (changelog + comments) for a Jira issue
1105
+ */
1106
+ export async function getIssueActivityFeed(issueKey, options = {}) {
1107
+ const client = getJiraClient();
1108
+ const { since, limit = 50, types, author } = options;
1109
+ const sinceDate = since ? new Date(since).getTime() : null;
1110
+ const typeFilter = types ? new Set(types.split(',').map(t => t.trim())) : null;
1111
+ // --- Fetch all changelog entries with pagination ---
1112
+ let changelogStartAt = 0;
1113
+ const changelogMaxResults = 100;
1114
+ let allChangelog = [];
1115
+ while (true) {
1116
+ const response = await client.issues.getChangeLogs({
1117
+ issueIdOrKey: issueKey,
1118
+ maxResults: changelogMaxResults,
1119
+ startAt: changelogStartAt,
1120
+ });
1121
+ const values = response.values || [];
1122
+ allChangelog = allChangelog.concat(values);
1123
+ const responseTotal = response.total ?? 0;
1124
+ if (allChangelog.length >= responseTotal || values.length < changelogMaxResults) {
1125
+ break;
1126
+ }
1127
+ changelogStartAt += changelogMaxResults;
1128
+ }
1129
+ // --- Fetch all comments with pagination ---
1130
+ let commentStartAt = 0;
1131
+ const commentMaxResults = 100;
1132
+ let allApiComments = [];
1133
+ while (true) {
1134
+ const response = await client.issueComments.getComments({
1135
+ issueIdOrKey: issueKey,
1136
+ maxResults: commentMaxResults,
1137
+ startAt: commentStartAt,
1138
+ });
1139
+ const comments = response.comments || [];
1140
+ allApiComments = allApiComments.concat(comments);
1141
+ const responseTotal = response.total ?? 0;
1142
+ if (allApiComments.length >= responseTotal || comments.length < commentMaxResults) {
1143
+ break;
1144
+ }
1145
+ commentStartAt += commentMaxResults;
1146
+ }
1147
+ // --- Convert changelog to ActivityEntry list ---
1148
+ const changelogActivities = [];
1149
+ for (const history of allChangelog) {
1150
+ const historyAuthor = {
1151
+ accountId: history.author?.accountId || '',
1152
+ displayName: history.author?.displayName || 'Unknown',
1153
+ emailAddress: history.author?.emailAddress,
1154
+ };
1155
+ for (const item of history.items || []) {
1156
+ const field = item.field || '';
1157
+ const fieldLower = field.toLowerCase();
1158
+ let type;
1159
+ if (fieldLower === 'status') {
1160
+ type = 'status_change';
1161
+ }
1162
+ else if (fieldLower.startsWith('link') || fieldLower === 'issuelinks') {
1163
+ // Determine add vs remove from to/from strings
1164
+ type = item.to ? 'link_added' : 'link_removed';
1165
+ }
1166
+ else if (fieldLower === 'attachment') {
1167
+ type = item.to ? 'attachment_added' : 'attachment_removed';
1168
+ }
1169
+ else {
1170
+ type = 'field_change';
1171
+ }
1172
+ changelogActivities.push({
1173
+ id: `${history.id}-${field}`,
1174
+ type,
1175
+ timestamp: history.created || '',
1176
+ author: historyAuthor,
1177
+ field,
1178
+ from: item.fromString ?? undefined,
1179
+ to: item.toString ?? undefined,
1180
+ });
1181
+ }
1182
+ }
1183
+ // --- Convert comments to ActivityEntry list ---
1184
+ const commentActivities = allApiComments.map((c) => {
1185
+ const isUpdated = c.created !== c.updated;
1186
+ return {
1187
+ id: c.id || '',
1188
+ type: isUpdated ? 'comment_updated' : 'comment_added',
1189
+ timestamp: isUpdated ? c.updated : c.created,
1190
+ author: {
1191
+ accountId: c.author?.accountId || '',
1192
+ displayName: c.author?.displayName || 'Unknown',
1193
+ emailAddress: c.author?.emailAddress,
1194
+ },
1195
+ commentBody: convertADFToMarkdown(c.body) || '',
1196
+ };
1197
+ });
1198
+ // --- Merge and sort by timestamp descending ---
1199
+ let activities = [...changelogActivities, ...commentActivities];
1200
+ activities.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
1201
+ const totalChanges = activities.length;
1202
+ // --- Apply filters ---
1203
+ if (sinceDate) {
1204
+ activities = activities.filter(a => new Date(a.timestamp).getTime() >= sinceDate);
1205
+ }
1206
+ if (typeFilter) {
1207
+ activities = activities.filter(a => typeFilter.has(a.type));
1208
+ }
1209
+ if (author) {
1210
+ const authorLower = author.toLowerCase();
1211
+ activities = activities.filter(a => a.author.displayName.toLowerCase().includes(authorLower) ||
1212
+ (a.author.emailAddress && a.author.emailAddress.toLowerCase().includes(authorLower)) ||
1213
+ a.author.accountId.toLowerCase().includes(authorLower));
1214
+ }
1215
+ // --- Apply limit ---
1216
+ const hasMore = activities.length > limit;
1217
+ activities = activities.slice(0, limit);
1218
+ return {
1219
+ issueKey,
1220
+ activities,
1221
+ totalChanges,
1222
+ hasMore,
1223
+ };
1224
+ }
1047
1225
  /**
1048
1226
  * Delete an attachment by ID
1049
1227
  */
@@ -1051,3 +1229,140 @@ export async function deleteAttachment(issueKey, attachmentId) {
1051
1229
  const client = getJiraClient();
1052
1230
  await client.issueAttachments.removeAttachment(attachmentId);
1053
1231
  }
1232
+ const WORKLOG_PAGE_SIZE = 5000;
1233
+ /**
1234
+ * List all worklogs for an issue (returns structured result).
1235
+ * Paginates automatically through all pages and supports optional filtering.
1236
+ */
1237
+ export async function getIssueWorklogsList(issueIdOrKey, options = {}) {
1238
+ const { startedAfter, startedBefore, authorAccountId } = options;
1239
+ const client = getJiraClient();
1240
+ const allWorklogs = [];
1241
+ let startAt = 0;
1242
+ while (true) {
1243
+ const params = { issueIdOrKey, startAt, maxResults: WORKLOG_PAGE_SIZE };
1244
+ if (startedAfter !== undefined)
1245
+ params.startedAfter = startedAfter;
1246
+ if (startedBefore !== undefined)
1247
+ params.startedBefore = startedBefore;
1248
+ const response = await client.issueWorklogs.getIssueWorklog(params);
1249
+ const page = (response.worklogs || []).map((w) => ({
1250
+ id: w.id || '',
1251
+ author: {
1252
+ accountId: w.author?.accountId || '',
1253
+ displayName: w.author?.displayName || 'Unknown',
1254
+ emailAddress: w.author?.emailAddress,
1255
+ },
1256
+ comment: convertADFToMarkdown(w.comment),
1257
+ created: w.created || '',
1258
+ updated: w.updated || '',
1259
+ started: w.started || '',
1260
+ timeSpent: w.timeSpent || '',
1261
+ timeSpentSeconds: w.timeSpentSeconds || 0,
1262
+ issueKey: issueIdOrKey,
1263
+ }));
1264
+ allWorklogs.push(...page);
1265
+ const totalOnServer = typeof response.total === 'number' ? response.total : page.length;
1266
+ if (allWorklogs.length >= totalOnServer || page.length < WORKLOG_PAGE_SIZE)
1267
+ break;
1268
+ startAt += page.length;
1269
+ }
1270
+ const filtered = authorAccountId
1271
+ ? allWorklogs.filter(w => w.author.accountId === authorAccountId)
1272
+ : allWorklogs;
1273
+ return {
1274
+ issueKey: issueIdOrKey,
1275
+ worklogs: filtered,
1276
+ total: filtered.length,
1277
+ };
1278
+ }
1279
+ /**
1280
+ * Add a worklog entry to an issue
1281
+ */
1282
+ export async function addWorklogEntry(issueIdOrKey, options) {
1283
+ const client = getJiraClient();
1284
+ const { timeSpentSeconds, comment, started, adjustEstimate, newEstimate, reduceBy } = options;
1285
+ const params = {
1286
+ issueIdOrKey,
1287
+ timeSpentSeconds,
1288
+ comment: comment ? markdownToAdf(comment) : undefined,
1289
+ started: started || new Date().toISOString().replace('Z', '+0000'),
1290
+ };
1291
+ if (adjustEstimate)
1292
+ params.adjustEstimate = adjustEstimate;
1293
+ if (newEstimate)
1294
+ params.newEstimate = newEstimate;
1295
+ if (reduceBy)
1296
+ params.reduceBy = reduceBy;
1297
+ const w = await client.issueWorklogs.addWorklog(params);
1298
+ return {
1299
+ id: w.id || '',
1300
+ author: {
1301
+ accountId: w.author?.accountId || '',
1302
+ displayName: w.author?.displayName || 'Unknown',
1303
+ emailAddress: w.author?.emailAddress,
1304
+ },
1305
+ comment: convertADFToMarkdown(w.comment),
1306
+ created: w.created || '',
1307
+ updated: w.updated || '',
1308
+ started: w.started || '',
1309
+ timeSpent: w.timeSpent || '',
1310
+ timeSpentSeconds: w.timeSpentSeconds || 0,
1311
+ issueKey: issueIdOrKey,
1312
+ };
1313
+ }
1314
+ /**
1315
+ * Update an existing worklog entry
1316
+ */
1317
+ export async function updateWorklogEntry(issueIdOrKey, worklogId, options) {
1318
+ const client = getJiraClient();
1319
+ const { timeSpentSeconds, comment, started, adjustEstimate, newEstimate } = options;
1320
+ const params = {
1321
+ issueIdOrKey,
1322
+ id: worklogId,
1323
+ };
1324
+ if (timeSpentSeconds !== undefined)
1325
+ params.timeSpentSeconds = timeSpentSeconds;
1326
+ if (comment !== undefined)
1327
+ params.comment = markdownToAdf(comment);
1328
+ if (started !== undefined)
1329
+ params.started = started;
1330
+ if (adjustEstimate)
1331
+ params.adjustEstimate = adjustEstimate;
1332
+ if (newEstimate)
1333
+ params.newEstimate = newEstimate;
1334
+ const w = await client.issueWorklogs.updateWorklog(params);
1335
+ return {
1336
+ id: w.id || worklogId,
1337
+ author: {
1338
+ accountId: w.author?.accountId || '',
1339
+ displayName: w.author?.displayName || 'Unknown',
1340
+ emailAddress: w.author?.emailAddress,
1341
+ },
1342
+ comment: convertADFToMarkdown(w.comment),
1343
+ created: w.created || '',
1344
+ updated: w.updated || '',
1345
+ started: w.started || '',
1346
+ timeSpent: w.timeSpent || '',
1347
+ timeSpentSeconds: w.timeSpentSeconds || 0,
1348
+ issueKey: issueIdOrKey,
1349
+ };
1350
+ }
1351
+ /**
1352
+ * Delete a worklog entry
1353
+ */
1354
+ export async function deleteWorklogEntry(issueIdOrKey, worklogId, options = {}) {
1355
+ const client = getJiraClient();
1356
+ const { adjustEstimate, newEstimate, increaseBy } = options;
1357
+ const params = {
1358
+ issueIdOrKey,
1359
+ id: worklogId,
1360
+ };
1361
+ if (adjustEstimate)
1362
+ params.adjustEstimate = adjustEstimate;
1363
+ if (newEstimate)
1364
+ params.newEstimate = newEstimate;
1365
+ if (increaseBy)
1366
+ params.increaseBy = increaseBy;
1367
+ await client.issueWorklogs.deleteWorklog(params);
1368
+ }
package/dist/lib/utils.js CHANGED
@@ -152,3 +152,42 @@ export function parseTimeframe(timeframe) {
152
152
  export function formatDateForJql(date) {
153
153
  return date.toISOString().split('T')[0];
154
154
  }
155
+ /**
156
+ * Normalize an ISO-8601 timestamp to the format Jira accepts: yyyy-MM-dd'T'HH:mm:ss.SSSZ
157
+ * - No colon in timezone offset, no Z suffix, milliseconds always present.
158
+ * - "2026-04-15T07:00:00.000Z" → "2026-04-15T07:00:00.000+0000"
159
+ * - "2026-04-15T10:00:00+03:00" → "2026-04-15T10:00:00.000+0300"
160
+ * - "2026-04-15T07:00:00Z" → "2026-04-15T07:00:00.000+0000"
161
+ * - "2026-04-15T07:00:00.000+0000" → unchanged
162
+ */
163
+ export function normalizeJiraTimestamp(timestamp) {
164
+ // Replace Z suffix with +0000
165
+ let normalized = timestamp.replace(/Z$/, '+0000');
166
+ // Remove colon from timezone offset: +HH:MM → +HHMM or -HH:MM → -HHMM
167
+ normalized = normalized.replace(/([+-])(\d{2}):(\d{2})$/, '$1$2$3');
168
+ // Add .000 milliseconds if missing (before the timezone offset)
169
+ normalized = normalized.replace(/(\d{2}:\d{2}:\d{2})([+-]\d{4})$/, '$1.000$2');
170
+ return normalized;
171
+ }
172
+ /**
173
+ * Parse a Jira-style duration string into total seconds.
174
+ * Supports: 1w, 2d, 3h, 30m and combinations like 1d2h30m.
175
+ * Conversion: 1w = 5d, 1d = 8h.
176
+ * Returns null for invalid input.
177
+ */
178
+ export function parseDuration(duration) {
179
+ const match = duration.match(/^(?:(\d+)w)?(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?$/);
180
+ if (!match || match[0] === '')
181
+ return null;
182
+ const weeks = parseInt(match[1] || '0', 10);
183
+ const days = parseInt(match[2] || '0', 10);
184
+ const hours = parseInt(match[3] || '0', 10);
185
+ const minutes = parseInt(match[4] || '0', 10);
186
+ const totalSeconds = weeks * 5 * 8 * 3600 +
187
+ days * 8 * 3600 +
188
+ hours * 3600 +
189
+ minutes * 60;
190
+ if (totalSeconds === 0)
191
+ return null;
192
+ return totalSeconds;
193
+ }
@@ -254,3 +254,57 @@ export const AttachDownloadSchema = z.object({
254
254
  id: z.string().trim().min(1, 'Attachment ID is required'),
255
255
  output: z.string().trim().optional(),
256
256
  });
257
+ // =============================================================================
258
+ // ISSUE COMMENTS / ACTIVITY SCHEMAS
259
+ // =============================================================================
260
+ export const CommentsListSchema = z.object({
261
+ issueKey: z.string().trim().min(1, 'Issue key is required').pipe(IssueKeySchema),
262
+ limit: z.number().int().positive().optional(),
263
+ since: z.string().datetime({ offset: true, message: 'since must be a valid ISO 8601 datetime (e.g. 2024-01-01T00:00:00Z)' }).optional(),
264
+ reverse: z.boolean().optional(),
265
+ });
266
+ export const ActivityFeedSchema = z.object({
267
+ issueKey: z.string().trim().min(1, 'Issue key is required').pipe(IssueKeySchema),
268
+ since: z.string().datetime({ offset: true, message: 'since must be a valid ISO 8601 datetime (e.g. 2024-01-01T00:00:00Z)' }).optional(),
269
+ limit: z.number().int().positive().optional(),
270
+ types: z.string().optional(),
271
+ author: z.string().optional(),
272
+ compact: z.boolean().optional(),
273
+ });
274
+ // =============================================================================
275
+ // WORKLOG SCHEMAS
276
+ // =============================================================================
277
+ export const DurationSchema = z
278
+ .string()
279
+ .regex(/^(?:(\d+)w)?(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?$/, 'Duration must be in Jira format: e.g. 1h, 30m, 1d2h30m, 1w');
280
+ export const WorklogListSchema = z.object({
281
+ issueKey: z.string().trim().min(1, 'Issue key is required').pipe(IssueKeySchema),
282
+ startedAfter: z.number().int().positive().optional(),
283
+ startedBefore: z.number().int().positive().optional(),
284
+ authorAccountId: z.string().optional(),
285
+ });
286
+ export const WorklogAddSchema = z.object({
287
+ issueKey: z.string().trim().min(1, 'Issue key is required').pipe(IssueKeySchema),
288
+ time: DurationSchema,
289
+ comment: z.string().optional(),
290
+ started: z.string().optional(),
291
+ adjustEstimate: z.enum(['auto', 'new', 'leave', 'manual']).optional(),
292
+ newEstimate: z.string().optional(),
293
+ reduceBy: z.string().optional(),
294
+ });
295
+ export const WorklogUpdateSchema = z.object({
296
+ issueKey: z.string().trim().min(1, 'Issue key is required').pipe(IssueKeySchema),
297
+ id: z.string().min(1, 'Worklog ID is required'),
298
+ time: DurationSchema.optional(),
299
+ comment: z.string().optional(),
300
+ started: z.string().optional(),
301
+ adjustEstimate: z.enum(['auto', 'new', 'leave', 'manual']).optional(),
302
+ newEstimate: z.string().optional(),
303
+ });
304
+ export const WorklogDeleteSchema = z.object({
305
+ issueKey: z.string().trim().min(1, 'Issue key is required').pipe(IssueKeySchema),
306
+ id: z.string().min(1, 'Worklog ID is required'),
307
+ adjustEstimate: z.enum(['auto', 'new', 'leave', 'manual']).optional(),
308
+ newEstimate: z.string().optional(),
309
+ increaseBy: z.string().optional(),
310
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jira-ai",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "AI friendly Jira CLI to save context",
5
5
  "type": "module",
6
6
  "main": "dist/cli.js",
@@ -48,12 +48,13 @@
48
48
  "node": ">=18.0.0"
49
49
  },
50
50
  "dependencies": {
51
+ "@modelcontextprotocol/sdk": "^1.28.0",
51
52
  "adf-to-markdown": "^1.0.1",
52
53
  "commander": "^11.0.0",
53
54
  "confluence.js": "^2.1.0",
54
55
  "dotenv": "^17.2.3",
55
56
  "html-entities": "^2.6.0",
56
- "jira.js": "^5.2.2",
57
+ "jira.js": "^5.2.2",
57
58
  "js-yaml": "^4.1.1",
58
59
  "marklassian": "^1.0.0",
59
60
  "zod": "^4.3.5"