jira-ai 1.4.0 → 1.5.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,33 @@ 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`, and `issue transition`. 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
+ ```
61
+
62
+ Dry-run output follows a consistent JSON structure:
63
+
64
+ ```json
65
+ {
66
+ "dryRun": true,
67
+ "command": "issue.update",
68
+ "target": "PROJ-123",
69
+ "changes": {
70
+ "priority": { "from": "Medium", "to": "High" }
71
+ },
72
+ "preview": { "...": "same output as the real command" },
73
+ "message": "No changes were made. Remove --dry-run to execute."
74
+ }
75
+ ```
76
+
77
+ 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` only.
78
+
52
79
  ### Issue Hierarchy Tree
53
80
 
54
81
  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 +174,40 @@ jira-ai issue transitions PROJ-123
147
174
 
148
175
  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
176
 
177
+ ### Activity Feed & Comments
178
+
179
+ View a unified activity feed combining changelog entries and comments for an issue:
180
+
181
+ ```bash
182
+ jira-ai issue activity PROJ-123
183
+ ```
184
+
185
+ Filter by time, activity type, or author:
186
+
187
+ ```bash
188
+ jira-ai issue activity PROJ-123 --since 2026-01-01T00:00:00Z --types status_change,comment_added --author "Jane Smith"
189
+ ```
190
+
191
+ Use `--compact` to strip comment bodies for maximum token efficiency:
192
+
193
+ ```bash
194
+ jira-ai issue activity PROJ-123 --compact
195
+ ```
196
+
197
+ List comments on an issue:
198
+
199
+ ```bash
200
+ jira-ai issue comments PROJ-123
201
+ ```
202
+
203
+ Use `--reverse` for chronological (oldest-first) order, or `--since` to filter by time:
204
+
205
+ ```bash
206
+ jira-ai issue comments PROJ-123 --since 2026-01-01T00:00:00Z --reverse --limit 20
207
+ ```
208
+
209
+ **Activity types:** `status_change`, `field_change`, `comment_added`, `comment_updated`, `attachment_added`, `attachment_removed`, `link_added`, `link_removed`
210
+
150
211
  ## Service Account Authentication
151
212
 
152
213
  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,8 @@ 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';
34
36
  import { aboutCommand } from './commands/about.js';
35
37
  import { authCommand } from './commands/auth.js';
36
38
  import { settingsCommand } from './commands/settings.js';
@@ -208,6 +210,41 @@ issue
208
210
  schema: UpdateDescriptionSchema,
209
211
  validateArgs: (args) => validateOptions(IssueKeySchema, args[0])
210
212
  }));
213
+ issue
214
+ .command('comments <issue-id>')
215
+ .description('List comments on a Jira issue.')
216
+ .option('--limit <n>', 'Maximum number of comments to return (default: 50)')
217
+ .option('--since <iso>', 'Only include comments created on or after this ISO timestamp')
218
+ .option('--reverse', 'Return comments in chronological order (oldest first)')
219
+ .action(withPermission('issue.comments', (issueKey, options) => {
220
+ return issueCommentsCommand({
221
+ issueKey,
222
+ limit: options.limit ? parseInt(options.limit, 10) : undefined,
223
+ since: options.since,
224
+ reverse: options.reverse,
225
+ });
226
+ }, {
227
+ validateArgs: (args) => validateOptions(IssueKeySchema, args[0])
228
+ }));
229
+ issue
230
+ .command('activity <issue-id>')
231
+ .description('Show a unified activity feed (changelog + comments) for a Jira issue.')
232
+ .option('--since <iso>', 'Only include activities on or after this ISO timestamp')
233
+ .option('--limit <n>', 'Maximum number of activities to return (default: 50)')
234
+ .option('--types <types>', 'Comma-separated activity types to include (e.g., status_change,comment_added)')
235
+ .option('--author <name-or-email>', 'Filter by author display name, email, or accountId')
236
+ .action(withPermission('issue.activity', (issueKey, options) => {
237
+ return issueActivityCommand({
238
+ issueKey,
239
+ since: options.since,
240
+ limit: options.limit ? parseInt(options.limit, 10) : undefined,
241
+ types: options.types,
242
+ author: options.author,
243
+ compact: program.opts().compact || options.compact,
244
+ });
245
+ }, {
246
+ validateArgs: (args) => validateOptions(IssueKeySchema, args[0])
247
+ }));
211
248
  issue
212
249
  .command('stats <issue-ids>')
213
250
  .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
+ }
@@ -1044,6 +1044,183 @@ export async function downloadAttachment(issueKey, attachmentId, outputPath) {
1044
1044
  fs.writeFileSync(destPath, Buffer.from(content));
1045
1045
  return destPath;
1046
1046
  }
1047
+ /**
1048
+ * Get comments for a Jira issue with pagination, since-filter, and ordering
1049
+ */
1050
+ export async function getIssueCommentsList(issueKey, options = {}) {
1051
+ const client = getJiraClient();
1052
+ const { limit = 50, since, reverse = false } = options;
1053
+ const maxResults = 100;
1054
+ let startAt = 0;
1055
+ let allComments = [];
1056
+ let total = 0;
1057
+ // Paginate through all comments
1058
+ while (true) {
1059
+ const response = await client.issueComments.getComments({
1060
+ issueIdOrKey: issueKey,
1061
+ maxResults,
1062
+ startAt,
1063
+ orderBy: 'created',
1064
+ });
1065
+ total = response.total ?? 0;
1066
+ const pageComments = (response.comments || []).map((c) => ({
1067
+ id: c.id || '',
1068
+ author: {
1069
+ accountId: c.author?.accountId || '',
1070
+ displayName: c.author?.displayName || 'Unknown',
1071
+ emailAddress: c.author?.emailAddress,
1072
+ },
1073
+ body: convertADFToMarkdown(c.body) || '',
1074
+ created: c.created || '',
1075
+ updated: c.updated || '',
1076
+ }));
1077
+ allComments = allComments.concat(pageComments);
1078
+ if (allComments.length >= total || pageComments.length < maxResults) {
1079
+ break;
1080
+ }
1081
+ startAt += maxResults;
1082
+ }
1083
+ // Apply --since filter
1084
+ if (since) {
1085
+ const sinceDate = new Date(since).getTime();
1086
+ allComments = allComments.filter(c => new Date(c.created).getTime() >= sinceDate);
1087
+ }
1088
+ // Apply --reverse (ascending order; default is newest first)
1089
+ if (!reverse) {
1090
+ allComments = allComments.reverse();
1091
+ }
1092
+ // Apply --limit
1093
+ const hasMore = allComments.length > limit;
1094
+ const comments = allComments.slice(0, limit);
1095
+ return {
1096
+ issueKey,
1097
+ comments,
1098
+ total,
1099
+ hasMore,
1100
+ };
1101
+ }
1102
+ /**
1103
+ * Get unified activity feed (changelog + comments) for a Jira issue
1104
+ */
1105
+ export async function getIssueActivityFeed(issueKey, options = {}) {
1106
+ const client = getJiraClient();
1107
+ const { since, limit = 50, types, author } = options;
1108
+ const sinceDate = since ? new Date(since).getTime() : null;
1109
+ const typeFilter = types ? new Set(types.split(',').map(t => t.trim())) : null;
1110
+ // --- Fetch all changelog entries with pagination ---
1111
+ let changelogStartAt = 0;
1112
+ const changelogMaxResults = 100;
1113
+ let allChangelog = [];
1114
+ while (true) {
1115
+ const response = await client.issues.getChangeLogs({
1116
+ issueIdOrKey: issueKey,
1117
+ maxResults: changelogMaxResults,
1118
+ startAt: changelogStartAt,
1119
+ });
1120
+ const values = response.values || [];
1121
+ allChangelog = allChangelog.concat(values);
1122
+ const responseTotal = response.total ?? 0;
1123
+ if (allChangelog.length >= responseTotal || values.length < changelogMaxResults) {
1124
+ break;
1125
+ }
1126
+ changelogStartAt += changelogMaxResults;
1127
+ }
1128
+ // --- Fetch all comments with pagination ---
1129
+ let commentStartAt = 0;
1130
+ const commentMaxResults = 100;
1131
+ let allApiComments = [];
1132
+ while (true) {
1133
+ const response = await client.issueComments.getComments({
1134
+ issueIdOrKey: issueKey,
1135
+ maxResults: commentMaxResults,
1136
+ startAt: commentStartAt,
1137
+ });
1138
+ const comments = response.comments || [];
1139
+ allApiComments = allApiComments.concat(comments);
1140
+ const responseTotal = response.total ?? 0;
1141
+ if (allApiComments.length >= responseTotal || comments.length < commentMaxResults) {
1142
+ break;
1143
+ }
1144
+ commentStartAt += commentMaxResults;
1145
+ }
1146
+ // --- Convert changelog to ActivityEntry list ---
1147
+ const changelogActivities = [];
1148
+ for (const history of allChangelog) {
1149
+ const historyAuthor = {
1150
+ accountId: history.author?.accountId || '',
1151
+ displayName: history.author?.displayName || 'Unknown',
1152
+ emailAddress: history.author?.emailAddress,
1153
+ };
1154
+ for (const item of history.items || []) {
1155
+ const field = item.field || '';
1156
+ const fieldLower = field.toLowerCase();
1157
+ let type;
1158
+ if (fieldLower === 'status') {
1159
+ type = 'status_change';
1160
+ }
1161
+ else if (fieldLower.startsWith('link') || fieldLower === 'issuelinks') {
1162
+ // Determine add vs remove from to/from strings
1163
+ type = item.to ? 'link_added' : 'link_removed';
1164
+ }
1165
+ else if (fieldLower === 'attachment') {
1166
+ type = item.to ? 'attachment_added' : 'attachment_removed';
1167
+ }
1168
+ else {
1169
+ type = 'field_change';
1170
+ }
1171
+ changelogActivities.push({
1172
+ id: `${history.id}-${field}`,
1173
+ type,
1174
+ timestamp: history.created || '',
1175
+ author: historyAuthor,
1176
+ field,
1177
+ from: item.fromString ?? undefined,
1178
+ to: item.toString ?? undefined,
1179
+ });
1180
+ }
1181
+ }
1182
+ // --- Convert comments to ActivityEntry list ---
1183
+ const commentActivities = allApiComments.map((c) => {
1184
+ const isUpdated = c.created !== c.updated;
1185
+ return {
1186
+ id: c.id || '',
1187
+ type: isUpdated ? 'comment_updated' : 'comment_added',
1188
+ timestamp: isUpdated ? c.updated : c.created,
1189
+ author: {
1190
+ accountId: c.author?.accountId || '',
1191
+ displayName: c.author?.displayName || 'Unknown',
1192
+ emailAddress: c.author?.emailAddress,
1193
+ },
1194
+ commentBody: convertADFToMarkdown(c.body) || '',
1195
+ };
1196
+ });
1197
+ // --- Merge and sort by timestamp descending ---
1198
+ let activities = [...changelogActivities, ...commentActivities];
1199
+ activities.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
1200
+ const totalChanges = activities.length;
1201
+ // --- Apply filters ---
1202
+ if (sinceDate) {
1203
+ activities = activities.filter(a => new Date(a.timestamp).getTime() >= sinceDate);
1204
+ }
1205
+ if (typeFilter) {
1206
+ activities = activities.filter(a => typeFilter.has(a.type));
1207
+ }
1208
+ if (author) {
1209
+ const authorLower = author.toLowerCase();
1210
+ activities = activities.filter(a => a.author.displayName.toLowerCase().includes(authorLower) ||
1211
+ (a.author.emailAddress && a.author.emailAddress.toLowerCase().includes(authorLower)) ||
1212
+ a.author.accountId.toLowerCase().includes(authorLower));
1213
+ }
1214
+ // --- Apply limit ---
1215
+ const hasMore = activities.length > limit;
1216
+ activities = activities.slice(0, limit);
1217
+ return {
1218
+ issueKey,
1219
+ activities,
1220
+ totalChanges,
1221
+ hasMore,
1222
+ };
1223
+ }
1047
1224
  /**
1048
1225
  * Delete an attachment by ID
1049
1226
  */
@@ -254,3 +254,20 @@ 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
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jira-ai",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "AI friendly Jira CLI to save context",
5
5
  "type": "module",
6
6
  "main": "dist/cli.js",