jira-ai 1.3.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 +38 -0
- package/dist/commands/create-task.js +12 -0
- package/dist/commands/issue-activity.js +38 -0
- package/dist/commands/issue-comments.js +29 -0
- package/dist/commands/transition.js +31 -1
- package/dist/commands/update-issue.js +42 -1
- package/dist/lib/dry-run.js +15 -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';
|
|
@@ -48,6 +50,7 @@ program
|
|
|
48
50
|
.description('CLI tool for interacting with Atlassian Jira')
|
|
49
51
|
.version(getVersion())
|
|
50
52
|
.option('--compact', 'Output as compact JSON')
|
|
53
|
+
.option('--dry-run', 'Preview changes without executing them')
|
|
51
54
|
.addHelpText('after', () => {
|
|
52
55
|
const latestVersion = checkForUpdateSync();
|
|
53
56
|
if (latestVersion) {
|
|
@@ -207,6 +210,41 @@ issue
|
|
|
207
210
|
schema: UpdateDescriptionSchema,
|
|
208
211
|
validateArgs: (args) => validateOptions(IssueKeySchema, args[0])
|
|
209
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
|
+
}));
|
|
210
248
|
issue
|
|
211
249
|
.command('stats <issue-ids>')
|
|
212
250
|
.description('Calculate time-based metrics for one or more issues (comma-separated). Shows time logged, estimates, and status breakdown.')
|
|
@@ -6,6 +6,7 @@ import { CommandError } from '../lib/errors.js';
|
|
|
6
6
|
import { validateOptions, CreateTaskSchema } from '../lib/validation.js';
|
|
7
7
|
import { isCommandAllowed, isProjectAllowed } from '../lib/settings.js';
|
|
8
8
|
import { outputResult } from '../lib/json-mode.js';
|
|
9
|
+
import { isDryRun, formatDryRunResult } from '../lib/dry-run.js';
|
|
9
10
|
export async function createTaskCommand(options) {
|
|
10
11
|
validateOptions(CreateTaskSchema, options);
|
|
11
12
|
const { title, project, issueType, parent, priority, description, descriptionFile, labels, component, fixVersion, dueDate, assignee, customField } = options;
|
|
@@ -74,6 +75,17 @@ export async function createTaskCommand(options) {
|
|
|
74
75
|
issueFields[fieldId] = isNaN(numValue) ? rawValue : numValue;
|
|
75
76
|
}
|
|
76
77
|
}
|
|
78
|
+
if (isDryRun()) {
|
|
79
|
+
const changes = {
|
|
80
|
+
project,
|
|
81
|
+
summary: title,
|
|
82
|
+
issueType,
|
|
83
|
+
...(parent !== undefined ? { parent } : {}),
|
|
84
|
+
...issueFields,
|
|
85
|
+
};
|
|
86
|
+
formatDryRunResult('issue.create', project, changes);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
77
89
|
try {
|
|
78
90
|
const result = await createIssue({
|
|
79
91
|
project,
|
|
@@ -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
|
+
}
|
|
@@ -5,13 +5,14 @@ import { outputResult } from '../lib/json-mode.js';
|
|
|
5
5
|
import { markdownToAdf } from 'marklassian';
|
|
6
6
|
import { FieldResolver } from '../lib/field-resolver.js';
|
|
7
7
|
import { processMentionsInADF } from '../lib/adf-mentions.js';
|
|
8
|
+
import { isDryRun, formatDryRunResult } from '../lib/dry-run.js';
|
|
8
9
|
export async function transitionCommand(taskId, toStatus, options) {
|
|
9
10
|
// Validate mutual exclusivity of --comment and --comment-file
|
|
10
11
|
if (options?.comment && options?.commentFile) {
|
|
11
12
|
throw new CommandError('Cannot use both --comment and --comment-file flags simultaneously.');
|
|
12
13
|
}
|
|
13
14
|
// Check permissions and filters
|
|
14
|
-
await validateIssuePermissions(taskId, 'transition');
|
|
15
|
+
const currentIssue = await validateIssuePermissions(taskId, 'transition');
|
|
15
16
|
try {
|
|
16
17
|
const transitions = await getIssueTransitions(taskId);
|
|
17
18
|
const matchingTransitions = transitions.filter((t) => t.to.name.toLowerCase() === toStatus.toLowerCase());
|
|
@@ -34,6 +35,35 @@ export async function transitionCommand(taskId, toStatus, options) {
|
|
|
34
35
|
});
|
|
35
36
|
}
|
|
36
37
|
const transition = matchingTransitions[0];
|
|
38
|
+
if (isDryRun()) {
|
|
39
|
+
const changes = {
|
|
40
|
+
status: {
|
|
41
|
+
from: currentIssue?.status?.name,
|
|
42
|
+
to: transition.to.name,
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
if (options?.resolution !== undefined) {
|
|
46
|
+
changes.resolution = { to: options.resolution };
|
|
47
|
+
}
|
|
48
|
+
if (options?.assignee !== undefined) {
|
|
49
|
+
changes.assignee = { to: options.assignee };
|
|
50
|
+
}
|
|
51
|
+
if (options?.fixVersion !== undefined) {
|
|
52
|
+
changes.fixVersions = { to: options.fixVersion };
|
|
53
|
+
}
|
|
54
|
+
if (options?.customFields && options.customFields.length > 0) {
|
|
55
|
+
for (const entry of options.customFields) {
|
|
56
|
+
const eqIdx = entry.indexOf('=');
|
|
57
|
+
if (eqIdx === -1)
|
|
58
|
+
continue;
|
|
59
|
+
const fieldId = entry.slice(0, eqIdx).trim();
|
|
60
|
+
const value = entry.slice(eqIdx + 1).trim();
|
|
61
|
+
changes[fieldId] = { to: value };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
formatDryRunResult('issue.transition', taskId, changes);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
37
67
|
// Build optional payload if any options were provided
|
|
38
68
|
let payload;
|
|
39
69
|
if (options && Object.keys(options).some(k => options[k] !== undefined)) {
|
|
@@ -6,6 +6,7 @@ import { CommandError } from '../lib/errors.js';
|
|
|
6
6
|
import { validateOptions, UpdateIssueSchema, IssueKeySchema } from '../lib/validation.js';
|
|
7
7
|
import { isCommandAllowed, isProjectAllowed } from '../lib/settings.js';
|
|
8
8
|
import { outputResult } from '../lib/json-mode.js';
|
|
9
|
+
import { isDryRun, formatDryRunResult } from '../lib/dry-run.js';
|
|
9
10
|
export async function updateIssueCommand(issueKey, options) {
|
|
10
11
|
validateOptions(IssueKeySchema, issueKey);
|
|
11
12
|
validateOptions(UpdateIssueSchema, options);
|
|
@@ -16,7 +17,47 @@ export async function updateIssueCommand(issueKey, options) {
|
|
|
16
17
|
if (!isCommandAllowed('update-issue', projectKey)) {
|
|
17
18
|
throw new CommandError(`Command 'update-issue' is not allowed for project ${projectKey}.`);
|
|
18
19
|
}
|
|
19
|
-
await validateIssuePermissions(issueKey, 'update-issue');
|
|
20
|
+
const currentIssue = await validateIssuePermissions(issueKey, 'update-issue');
|
|
21
|
+
if (isDryRun()) {
|
|
22
|
+
const changes = {};
|
|
23
|
+
if (options.priority !== undefined) {
|
|
24
|
+
changes.priority = { from: currentIssue?.priority, to: options.priority };
|
|
25
|
+
}
|
|
26
|
+
if (options.summary !== undefined) {
|
|
27
|
+
changes.summary = { from: currentIssue?.summary, to: options.summary };
|
|
28
|
+
}
|
|
29
|
+
if (options.labels !== undefined || options.clearLabels) {
|
|
30
|
+
const newLabels = options.clearLabels ? [] : options.labels?.split(',').map(l => l.trim()).filter(Boolean);
|
|
31
|
+
changes.labels = { from: currentIssue?.labels, to: newLabels };
|
|
32
|
+
}
|
|
33
|
+
if (options.component !== undefined) {
|
|
34
|
+
changes.components = { from: currentIssue?.components, to: options.component };
|
|
35
|
+
}
|
|
36
|
+
if (options.fixVersion !== undefined) {
|
|
37
|
+
changes.fixVersions = { from: currentIssue?.fixVersions, to: options.fixVersion };
|
|
38
|
+
}
|
|
39
|
+
if (options.dueDate !== undefined) {
|
|
40
|
+
changes.dueDate = { from: currentIssue?.duedate, to: options.dueDate };
|
|
41
|
+
}
|
|
42
|
+
if (options.assignee !== undefined) {
|
|
43
|
+
changes.assignee = { from: currentIssue?.assignee?.displayName, to: options.assignee };
|
|
44
|
+
}
|
|
45
|
+
if (options.description !== undefined || options.fromFile !== undefined) {
|
|
46
|
+
changes.description = { from: '(current)', to: '(updated)' };
|
|
47
|
+
}
|
|
48
|
+
if (options.customField && options.customField.length > 0) {
|
|
49
|
+
for (const cf of options.customField) {
|
|
50
|
+
const eqIdx = cf.indexOf('=');
|
|
51
|
+
if (eqIdx === -1)
|
|
52
|
+
continue;
|
|
53
|
+
const fieldId = cf.slice(0, eqIdx);
|
|
54
|
+
const value = cf.slice(eqIdx + 1);
|
|
55
|
+
changes[fieldId] = { to: value };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
formatDryRunResult('issue.update', issueKey, changes);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
20
61
|
const fields = {};
|
|
21
62
|
if (options.priority !== undefined) {
|
|
22
63
|
if (!options.priority)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { outputResult } from './json-mode.js';
|
|
2
|
+
export function isDryRun() {
|
|
3
|
+
return process.argv.includes('--dry-run');
|
|
4
|
+
}
|
|
5
|
+
export function formatDryRunResult(command, target, changes, preview = {}) {
|
|
6
|
+
const result = {
|
|
7
|
+
dryRun: true,
|
|
8
|
+
command,
|
|
9
|
+
target,
|
|
10
|
+
changes,
|
|
11
|
+
preview,
|
|
12
|
+
message: 'No changes were made. Remove --dry-run to execute.',
|
|
13
|
+
};
|
|
14
|
+
outputResult(result);
|
|
15
|
+
}
|
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
|
+
});
|