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 +61 -0
- package/dist/cli.js +37 -0
- package/dist/commands/issue-activity.js +38 -0
- package/dist/commands/issue-comments.js +29 -0
- package/dist/lib/jira-client.js +177 -0
- package/dist/lib/validation.js +17 -0
- package/package.json +1 -1
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
|
+
}
|
package/dist/lib/jira-client.js
CHANGED
|
@@ -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
|
*/
|
package/dist/lib/validation.js
CHANGED
|
@@ -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
|
+
});
|