jira-ai 1.7.1 → 1.8.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
@@ -209,6 +209,46 @@ jira-ai issue comments PROJ-123 --since 2026-01-01T00:00:00Z --reverse --limit 2
209
209
 
210
210
  **Activity types:** `status_change`, `field_change`, `comment_added`, `comment_updated`, `attachment_added`, `attachment_removed`, `link_added`, `link_removed`
211
211
 
212
+ ### User Activity
213
+
214
+ Retrieve a user's activity across all issues in a timeframe — comments, status changes, field changes, and more:
215
+
216
+ ```bash
217
+ jira-ai user activity "Jane Smith" 7d
218
+ ```
219
+
220
+ Filter by project:
221
+
222
+ ```bash
223
+ jira-ai user activity "Jane Smith" 7d --project PS
224
+ ```
225
+
226
+ Filter by activity type:
227
+
228
+ ```bash
229
+ jira-ai user activity "Jane Smith" 7d --types comment_added,status_change
230
+ ```
231
+
232
+ Group results by issue instead of a flat timeline:
233
+
234
+ ```bash
235
+ jira-ai user activity "Jane Smith" 14d --project PS --group-by-issue
236
+ ```
237
+
238
+ Use `--compact` to strip comment bodies for token efficiency:
239
+
240
+ ```bash
241
+ jira-ai --compact user activity "Jane Smith" 30d --types comment_added
242
+ ```
243
+
244
+ Set a result limit:
245
+
246
+ ```bash
247
+ jira-ai user activity "Jane Smith" 7d --limit 100
248
+ ```
249
+
250
+ **Permission:** `user.activity` — must be explicitly granted (not covered by `user.worklog`).
251
+
212
252
  ### Worklog Management
213
253
 
214
254
  Log time against issues with full CRUD support:
@@ -277,6 +317,42 @@ jira-ai issue worklog add PROJ-123 --time 2h --dry-run
277
317
 
278
318
  Duration format uses Jira-style notation: `1w` (5 working days), `1d` (8 hours), `1h`, `30m`, or combinations like `1d2h30m`.
279
319
 
320
+ ### User Worklog
321
+
322
+ Retrieve worklogs for a specific user over a timeframe:
323
+
324
+ ```bash
325
+ jira-ai user worklog "Jane Smith" 7d
326
+ ```
327
+
328
+ Group results by issue:
329
+
330
+ ```bash
331
+ jira-ai user worklog "Jane Smith" 30d --group-by-issue
332
+ ```
333
+
334
+ Restrict to a specific project:
335
+
336
+ ```bash
337
+ jira-ai user worklog "Jane Smith" 30d --project PS
338
+ ```
339
+
340
+ ### Issue Search Enhancements
341
+
342
+ Search issues with JQL and filter by comment author:
343
+
344
+ ```bash
345
+ jira-ai issue search 'project = PS AND updated >= -7d' --comment-author "Jane Smith"
346
+ ```
347
+
348
+ Use `--comment-author` without a JQL query to find all issues commented on by a user:
349
+
350
+ ```bash
351
+ jira-ai issue search --comment-author "Jane Smith" --limit 50
352
+ ```
353
+
354
+ `--comment-author` can be combined with both positional JQL and `--query` (saved queries).
355
+
280
356
  ## Service Account Authentication
281
357
 
282
358
  Atlassian service accounts use scoped API tokens that must authenticate through the `api.atlassian.com` gateway rather than direct site URLs.
@@ -382,7 +458,7 @@ The `--preset`, `--list-presets`, `--detect-preset`, `--apply`, `--validate`, an
382
458
 
383
459
  #### What each preset allows
384
460
 
385
- - **`read-only`** — `issue get/search/stats/comments/activity/tree/worklog.list/link.list/link.types/attach/list`, `project list/statuses/types/fields`, `user me/search/worklog`, `confl get/spaces/pages/search`, `epic list/get/issues/progress`, `board list/get/config/issues`, `sprint list/get/issues/tree`
461
+ - **`read-only`** — `issue get/search/stats/comments/activity/tree/worklog.list/link.list/link.types/attach/list`, `project list/statuses/types/fields`, `user me/search/worklog/activity`, `confl get/spaces/pages/search`, `epic list/get/issues/progress`, `board list/get/config/issues`, `sprint list/get/issues/tree`
386
462
  - **`standard`** — Everything in `read-only`, plus `issue create/update/transition/comment/assign/label.add/label.remove/link.create/attach.upload/attach.download/worklog.add/worklog.update`, `confl create/comment/update`, `epic create/update/link/unlink`, `sprint update`
387
463
  - **`my-tasks`** — All commands across all domains (`issue`, `project`, `user`, `confl`, `epic`, `board`, `sprint`, `backlog`), but issue visibility is filtered to those where the user participated (see [globalParticipationFilter](#globalparticipationfilter) below)
388
464
  - **`yolo`** — All commands, all projects, all Confluence spaces. No restrictions.
package/dist/cli.js CHANGED
@@ -25,6 +25,7 @@ import { sprintTreeCommand } from './commands/sprint-tree.js';
25
25
  import { uploadAttachmentCommand, listAttachmentsCommand, downloadAttachmentCommand, deleteAttachmentCommand, } from './commands/attach.js';
26
26
  import { getIssueStatisticsCommand } from './commands/get-issue-statistics.js';
27
27
  import { getPersonWorklogCommand } from './commands/get-person-worklog.js';
28
+ import { userActivityCommand } from './commands/user-activity.js';
28
29
  import { isCommandAllowed, getAllowedCommands } from './lib/settings.js';
29
30
  import { confluenceGetPageCommand, confluenceListSpacesCommand, confluenceGetSpacePagesHierarchyCommand, confluenceAddCommentCommand, confluenceCreatePageCommand, confluenceUpdateDescriptionCommand, confluenceSearchCommand } from './commands/confluence.js';
30
31
  import { epicListCommand, epicGetCommand, epicCreateCommand, epicUpdateCommand, epicIssuesCommand, epicLinkCommand, epicUnlinkCommand, epicProgressCommand, } from './commands/epic.js';
@@ -42,7 +43,7 @@ import { checkForUpdate, formatUpdateMessage, checkForUpdateSync } from './lib/u
42
43
  import { CliError } from './types/errors.js';
43
44
  import { CommandError } from './lib/errors.js';
44
45
  import { initJsonMode, outputError } from './lib/json-mode.js';
45
- import { CreateTaskSchema, UpdateDescriptionSchema, ProjectFieldsSchema, ConfluenceAddCommentSchema, RunJqlSchema, GetPersonWorklogSchema, GetIssueStatisticsSchema, EpicListSchema, EpicCreateSchema, EpicUpdateSchema, EpicLinkSchema, EpicMaxSchema, validateOptions, IssueKeySchema, ProjectKeySchema, TimeframeSchema, BoardListSchema, BoardIssuesSchema, BoardRankSchema, SprintListSchema, SprintCreateSchema, SprintUpdateSchema, SprintIssuesSchema, SprintMoveSchema, BacklogMoveSchema, AttachUploadSchema, AttachDownloadSchema, } from './lib/validation.js';
46
+ import { CreateTaskSchema, UpdateDescriptionSchema, ProjectFieldsSchema, ConfluenceAddCommentSchema, RunJqlSchema, GetPersonWorklogSchema, UserActivitySchema, GetIssueStatisticsSchema, EpicListSchema, EpicCreateSchema, EpicUpdateSchema, EpicLinkSchema, EpicMaxSchema, validateOptions, IssueKeySchema, ProjectKeySchema, TimeframeSchema, BoardListSchema, BoardIssuesSchema, BoardRankSchema, SprintListSchema, SprintCreateSchema, SprintUpdateSchema, SprintIssuesSchema, SprintMoveSchema, BacklogMoveSchema, AttachUploadSchema, AttachDownloadSchema, } from './lib/validation.js';
46
47
  import { realpathSync } from 'fs';
47
48
  // Create CLI program
48
49
  const program = new Command();
@@ -142,6 +143,7 @@ issue
142
143
  .option('-l, --limit <number>', 'Maximum number of results (default: 50)', '50')
143
144
  .option('--query <name>', 'Use a saved query by name (mutually exclusive with positional JQL)')
144
145
  .option('--list-queries', 'List all available saved queries')
146
+ .option('--comment-author <name>', 'Filter to issues commented on by this user (display name or account ID)')
145
147
  .action(withPermission('issue.search', runJqlCommand, {
146
148
  schema: RunJqlSchema,
147
149
  validateArgs: (args) => {
@@ -149,8 +151,9 @@ issue
149
151
  const opts = args[args.length - 2];
150
152
  const hasQuery = opts && opts.query;
151
153
  const hasListQueries = opts && opts.listQueries;
152
- if (!hasQuery && !hasListQueries && (typeof jqlQuery !== 'string' || jqlQuery.trim() === '')) {
153
- throw new CliError('JQL query cannot be empty. Provide a JQL query, use --query <name>, or use --list-queries.');
154
+ const hasCommentAuthor = opts && opts.commentAuthor;
155
+ if (!hasQuery && !hasListQueries && !hasCommentAuthor && (typeof jqlQuery !== 'string' || jqlQuery.trim() === '')) {
156
+ throw new CliError('JQL query cannot be empty. Provide a JQL query, use --query <name>, --comment-author <name>, or use --list-queries.');
154
157
  }
155
158
  }
156
159
  }));
@@ -522,12 +525,26 @@ user
522
525
  .command('worklog <person> <timeframe>')
523
526
  .description('Retrieve worklogs for a user over a timeframe (e.g., "7d", "2w").')
524
527
  .option('--group-by-issue', 'Group the output by issue')
528
+ .option('--project <key>', 'Filter worklogs to a specific project key')
525
529
  .action(withPermission('user.worklog', getPersonWorklogCommand, {
526
530
  schema: GetPersonWorklogSchema,
527
531
  validateArgs: (args) => {
528
532
  validateOptions(TimeframeSchema, args[1]);
529
533
  }
530
534
  }));
535
+ user
536
+ .command('activity <person> <timeframe>')
537
+ .description('Show activity (comments, status changes, field changes) for a user over a timeframe.')
538
+ .option('--project <key>', 'Filter to a specific project key')
539
+ .option('--types <types>', 'Comma-separated activity types to include (e.g., "comment_added,status_change")')
540
+ .option('--limit <number>', 'Maximum number of activity entries to return')
541
+ .option('--group-by-issue', 'Group activities by issue')
542
+ .action(withPermission('user.activity', userActivityCommand, {
543
+ schema: UserActivitySchema,
544
+ validateArgs: (args) => {
545
+ validateOptions(TimeframeSchema, args[1]);
546
+ }
547
+ }));
531
548
  // =============================================================================
532
549
  // CONFLUENCE COMMANDS
533
550
  // =============================================================================
@@ -789,7 +806,7 @@ Settings File Structure:
789
806
  Command Groups (use in allowed-commands):
790
807
  issue - get, create, search, transition, update, comment, stats, assign, label
791
808
  project - list, statuses, types
792
- user - me, search, worklog
809
+ user - me, search, worklog, activity
793
810
  epic - list, get, create, update, issues, link, unlink, progress
794
811
  confl - get, spaces, pages, create, comment, update
795
812
  board - list, get, config, issues, rank
@@ -1,4 +1,4 @@
1
- import { searchIssuesByJql, getIssueWorklogs } from '../lib/jira-client.js';
1
+ import { searchIssuesByJql, getIssueWorklogs, resolveUserByName } from '../lib/jira-client.js';
2
2
  import { parseTimeframe, formatDateForJql } from '../lib/utils.js';
3
3
  import { CommandError } from '../lib/errors.js';
4
4
  import { outputResult } from '../lib/json-mode.js';
@@ -7,9 +7,13 @@ export async function getPersonWorklogCommand(person, timeframe, options) {
7
7
  const { startDate, endDate } = parseTimeframe(timeframe);
8
8
  const startJql = formatDateForJql(startDate);
9
9
  const endJql = formatDateForJql(endDate);
10
+ // Resolve person to accountId for consistent JQL and in-memory filtering
11
+ const resolved = await resolveUserByName(person);
12
+ const worklogAuthor = resolved ?? person;
10
13
  // 1. Search for issues where the person has tracked time in the timeframe
11
14
  // We use a broader search first to find relevant issues
12
- const jql = `worklogAuthor = "${person}" AND worklogDate >= "${startJql}" AND worklogDate <= "${endJql}"`;
15
+ const projectClause = options.project ? ` AND project = "${options.project}"` : '';
16
+ const jql = `worklogAuthor = "${worklogAuthor}" AND worklogDate >= "${startJql}" AND worklogDate <= "${endJql}"${projectClause}`;
13
17
  const issues = await searchIssuesByJql(jql, 100);
14
18
  if (issues.length === 0) {
15
19
  outputResult([]);
@@ -20,7 +24,7 @@ export async function getPersonWorklogCommand(person, timeframe, options) {
20
24
  for (const issue of issues) {
21
25
  const worklogs = await getIssueWorklogs(issue.key);
22
26
  const filteredWorklogs = worklogs.filter(w => {
23
- const matchesPerson = w.author.accountId === person || w.author.emailAddress === person;
27
+ const matchesPerson = w.author.accountId === worklogAuthor || w.author.emailAddress === worklogAuthor;
24
28
  const worklogDate = new Date(w.started);
25
29
  const matchesDate = worklogDate >= startDate && worklogDate <= endDate;
26
30
  return matchesPerson && matchesDate;
@@ -1,4 +1,4 @@
1
- import { searchIssuesByJql } from '../lib/jira-client.js';
1
+ import { searchIssuesByJql, resolveUserByName } from '../lib/jira-client.js';
2
2
  import { outputResult } from '../lib/json-mode.js';
3
3
  import { getSavedQuery, listSavedQueries } from '../lib/settings.js';
4
4
  import { CliError } from '../types/errors.js';
@@ -24,8 +24,21 @@ export async function runJqlCommand(jqlQuery, options) {
24
24
  resolvedJql = savedJql;
25
25
  }
26
26
  else {
27
+ if (!jqlQuery || jqlQuery.trim() === '') {
28
+ throw new CliError('A JQL query is required. Provide a JQL string or use --query <name>.');
29
+ }
27
30
  resolvedJql = jqlQuery;
28
31
  }
32
+ // Append commentAuthor filter if provided (not mutually exclusive with other options)
33
+ if (options.commentAuthor) {
34
+ const accountId = (await resolveUserByName(options.commentAuthor)) ?? options.commentAuthor;
35
+ if (resolvedJql && resolvedJql.trim() !== '') {
36
+ resolvedJql = `(${resolvedJql}) AND commentAuthor = "${accountId}"`;
37
+ }
38
+ else {
39
+ resolvedJql = `commentAuthor = "${accountId}"`;
40
+ }
41
+ }
29
42
  let maxResults = options.limit || 50;
30
43
  if (maxResults > 1000) {
31
44
  maxResults = 1000;
@@ -0,0 +1,67 @@
1
+ import { searchIssuesByJql, resolveUserByName, buildUserActivityJql, getUserActivity, } from '../lib/jira-client.js';
2
+ import { parseTimeframe, formatDateForJql } from '../lib/utils.js';
3
+ import { CommandError } from '../lib/errors.js';
4
+ import { outputResult } from '../lib/json-mode.js';
5
+ const VALID_ACTIVITY_TYPES = [
6
+ 'status_change',
7
+ 'field_change',
8
+ 'link_added',
9
+ 'link_removed',
10
+ 'attachment_added',
11
+ 'attachment_removed',
12
+ 'comment_added',
13
+ 'comment_updated',
14
+ ];
15
+ export async function userActivityCommand(person, timeframe, options) {
16
+ const { project, types, limit, groupByIssue } = options;
17
+ if (limit !== undefined && limit <= 0) {
18
+ throw new CommandError('--limit must be greater than 0');
19
+ }
20
+ if (types) {
21
+ const requestedTypes = types.split(',').map((t) => t.trim());
22
+ const invalid = requestedTypes.filter((t) => !VALID_ACTIVITY_TYPES.includes(t));
23
+ if (invalid.length > 0) {
24
+ throw new CommandError(`Invalid --types value(s): ${invalid.join(', ')}. Valid types: ${VALID_ACTIVITY_TYPES.join(', ')}`);
25
+ }
26
+ }
27
+ const { startDate, endDate } = parseTimeframe(timeframe);
28
+ const startJql = formatDateForJql(startDate);
29
+ const endJql = formatDateForJql(endDate);
30
+ // Resolve person to accountId, fall back to raw string
31
+ const accountId = (await resolveUserByName(person)) ?? person;
32
+ const jql = buildUserActivityJql(accountId, startJql, endJql, project);
33
+ const issues = await searchIssuesByJql(jql, 100);
34
+ if (issues.length === 0) {
35
+ outputResult({ activities: [], skipped: 0 });
36
+ return;
37
+ }
38
+ const feedOptions = {
39
+ since: startDate.toISOString(),
40
+ author: accountId,
41
+ ...(types ? { types } : {}),
42
+ ...(limit ? { limit: limit * 2 } : {}), // fetch extra to allow for sorting + slicing
43
+ };
44
+ const { entries, skipped } = await getUserActivity(accountId, issues, feedOptions);
45
+ // Sort by timestamp descending
46
+ entries.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
47
+ // Apply limit (applies to activities, not issues)
48
+ const limited = limit ? entries.slice(0, limit) : entries;
49
+ if (groupByIssue) {
50
+ const grouped = new Map();
51
+ for (const entry of limited) {
52
+ if (!grouped.has(entry.issueKey)) {
53
+ grouped.set(entry.issueKey, {
54
+ issueKey: entry.issueKey,
55
+ summary: entry.issueSummary,
56
+ activities: [],
57
+ });
58
+ }
59
+ const { issueKey: _k, issueSummary: _s, ...rest } = entry;
60
+ grouped.get(entry.issueKey).activities.push(rest);
61
+ }
62
+ outputResult({ issues: Array.from(grouped.values()), skipped });
63
+ }
64
+ else {
65
+ outputResult({ activities: limited, skipped });
66
+ }
67
+ }
@@ -1366,3 +1366,42 @@ export async function deleteWorklogEntry(issueIdOrKey, worklogId, options = {})
1366
1366
  params.increaseBy = increaseBy;
1367
1367
  await client.issueWorklogs.deleteWorklog(params);
1368
1368
  }
1369
+ /**
1370
+ * Build JQL to find issues where the given user has had activity in the timeframe.
1371
+ * Uses commentAuthor for comment-based search (valid JQL field).
1372
+ */
1373
+ export function buildUserActivityJql(accountId, startJql, endJql, projectKey) {
1374
+ const projectClause = projectKey ? ` AND project = "${projectKey}"` : '';
1375
+ return (`(` +
1376
+ `worklogAuthor = "${accountId}" AND worklogDate >= "${startJql}" AND worklogDate <= "${endJql}"` +
1377
+ ` OR commentAuthor = "${accountId}" AND updated >= "${startJql}" AND updated <= "${endJql}"` +
1378
+ ` OR assignee = "${accountId}" AND updated >= "${startJql}" AND updated <= "${endJql}"` +
1379
+ `)` +
1380
+ projectClause);
1381
+ }
1382
+ /**
1383
+ * Fetch activity for a user across issues, using batch parallelism of 5 concurrent requests.
1384
+ * Skips issues that fail (partial failure tolerance).
1385
+ */
1386
+ export async function getUserActivity(accountId, issues, feedOptions) {
1387
+ const BATCH_SIZE = 5;
1388
+ let skipped = 0;
1389
+ const allEntries = [];
1390
+ for (let i = 0; i < issues.length; i += BATCH_SIZE) {
1391
+ const batch = issues.slice(i, i + BATCH_SIZE);
1392
+ const results = await Promise.allSettled(batch.map((issue) => getIssueActivityFeed(issue.key, feedOptions)));
1393
+ for (let j = 0; j < results.length; j++) {
1394
+ const result = results[j];
1395
+ const issue = batch[j];
1396
+ if (result.status === 'fulfilled') {
1397
+ for (const entry of result.value.activities) {
1398
+ allEntries.push({ ...entry, issueKey: issue.key, issueSummary: issue.summary });
1399
+ }
1400
+ }
1401
+ else {
1402
+ skipped++;
1403
+ }
1404
+ }
1405
+ }
1406
+ return { entries: allEntries, skipped };
1407
+ }
@@ -21,6 +21,7 @@ export const PRESETS = {
21
21
  'user.me',
22
22
  'user.search',
23
23
  'user.worklog',
24
+ 'user.activity',
24
25
  'confl.get',
25
26
  'confl.spaces',
26
27
  'confl.pages',
@@ -75,6 +76,7 @@ export const PRESETS = {
75
76
  'user.me',
76
77
  'user.search',
77
78
  'user.worklog',
79
+ 'user.activity',
78
80
  'confl.get',
79
81
  'confl.spaces',
80
82
  'confl.pages',
@@ -115,10 +115,18 @@ export const RunJqlSchema = z.object({
115
115
  limit: NumericStringSchema.optional(),
116
116
  query: z.string().optional(),
117
117
  listQueries: z.boolean().optional(),
118
+ commentAuthor: z.string().optional(),
118
119
  });
119
120
  export const TimeframeSchema = z.string().regex(/^\d+d$/, 'Timeframe must be in format like "7d" or "30d"');
120
121
  export const GetPersonWorklogSchema = z.object({
121
122
  groupByIssue: z.boolean().optional(),
123
+ project: z.string().optional(),
124
+ });
125
+ export const UserActivitySchema = z.object({
126
+ project: z.string().optional(),
127
+ types: z.string().optional(),
128
+ limit: NumericStringSchema.optional(),
129
+ groupByIssue: z.boolean().optional(),
122
130
  });
123
131
  export const GetIssueStatisticsSchema = z.object({
124
132
  fullBreakdown: z.boolean().optional(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jira-ai",
3
- "version": "1.7.1",
3
+ "version": "1.8.0",
4
4
  "description": "AI friendly Jira CLI to save context",
5
5
  "type": "module",
6
6
  "main": "dist/cli.js",