jira-ai 1.6.0 → 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.
@@ -365,15 +441,134 @@ jira-ai issue search --query overdue-tasks --limit 10
365
441
 
366
442
  Saved queries are mutually exclusive with raw JQL — you cannot provide both a positional JQL argument and `--query` at the same time.
367
443
 
444
+ ## Presets
445
+
446
+ Predefined configuration presets let you quickly set up permission levels without manually editing `settings.yaml`. Presets configure `allowed-commands`, `allowed-jira-projects`, and `allowed-confluence-spaces` in one step.
447
+
448
+ The `--preset`, `--list-presets`, `--detect-preset`, `--apply`, `--validate`, and `--reset` flags are mutually exclusive — only one can be used at a time.
449
+
450
+ ### Available Presets
451
+
452
+ | Preset | Description |
453
+ | :--- | :--- |
454
+ | `read-only` | AI can only observe. No create, update, delete, or transition operations. |
455
+ | `standard` | AI can perform common productive actions but cannot do destructive operations (delete, sprint management). |
456
+ | `my-tasks` | AI has full command access but is restricted to issues where the current user participated (assignee, reporter, commenter, or watcher). |
457
+ | `yolo` | Unrestricted access. The AI can do everything. The name explicitly signals risk. |
458
+
459
+ #### What each preset allows
460
+
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`
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`
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)
464
+ - **`yolo`** — All commands, all projects, all Confluence spaces. No restrictions.
465
+
466
+ ### Usage
467
+
468
+ Apply a preset:
469
+
470
+ ```bash
471
+ jira-ai settings --preset read-only
472
+ jira-ai settings --preset standard
473
+ jira-ai settings --preset my-tasks
474
+ jira-ai settings --preset yolo
475
+ ```
476
+
477
+ List all available presets with their full configuration details:
478
+
479
+ ```bash
480
+ jira-ai settings --list-presets
481
+ ```
482
+
483
+ Detect which preset (if any) your current settings match:
484
+
485
+ ```bash
486
+ jira-ai settings --detect-preset
487
+ ```
488
+
489
+ If your settings don't match any preset exactly, `--detect-preset` reports `custom` and shows the closest match with a diff of added/removed commands.
490
+
491
+ Reset settings to defaults:
492
+
493
+ ```bash
494
+ jira-ai settings --reset
495
+ ```
496
+
497
+ Validate a settings file without applying it:
498
+
499
+ ```bash
500
+ jira-ai settings --validate my-settings.yaml
501
+ ```
502
+
503
+ Apply a settings file:
504
+
505
+ ```bash
506
+ jira-ai settings --apply my-settings.yaml
507
+ ```
508
+
509
+ View current settings:
510
+
511
+ ```bash
512
+ jira-ai settings
513
+ ```
514
+
515
+ After applying a preset, you can further customize permissions by editing `~/.jira-ai/settings.yaml`. Saved queries are preserved when switching presets.
516
+
517
+ ### globalParticipationFilter
518
+
519
+ The `my-tasks` preset sets a `globalParticipationFilter` in `settings.yaml` that restricts which issues the AI can see and interact with. Only issues where the current user matches at least one participation criterion are accessible:
520
+
521
+ ```yaml
522
+ defaults:
523
+ globalParticipationFilter:
524
+ was_assignee: true
525
+ was_reporter: true
526
+ was_commenter: true
527
+ is_watcher: true
528
+ ```
529
+
530
+ | Field | JQL equivalent | Meaning |
531
+ | :--- | :--- | :--- |
532
+ | `was_assignee` | `assignee was currentUser()` | User was ever assigned to the issue |
533
+ | `was_reporter` | `reporter = currentUser()` | User is the issue reporter |
534
+ | `was_commenter` | `issue in issueHistory()` | User commented on the issue |
535
+ | `is_watcher` | `issue in watchedIssues()` | User is watching the issue |
536
+
537
+ The filter applies to both search queries (JQL is automatically wrapped) and direct issue access (per-issue validation). You can customize the filter after applying a preset by editing `~/.jira-ai/settings.yaml` — set individual fields to `false` to relax that criterion.
538
+
368
539
  ## Configuration & Restrictions
369
540
 
370
541
  Tool allows you to have very complex configutations of what Projects/Jira commands/Issue types you would have acess to thought the tool.
371
- Use this command to start setup:
542
+
543
+ ### Quick setup with presets
544
+
545
+ The fastest way to configure permissions is with a preset (see [Presets](#presets) above):
546
+
547
+ ```bash
548
+ jira-ai settings --preset standard
549
+ ```
550
+
551
+ ### Manual setup
552
+
553
+ Use this command to start setup:
372
554
 
373
555
  ```bash
374
556
  jira-ai settings --help
375
557
  ```
376
558
 
559
+ You can also validate or apply settings from a YAML file:
560
+
561
+ ```bash
562
+ jira-ai settings --validate my-settings.yaml
563
+ jira-ai settings --apply my-settings.yaml
564
+ ```
565
+
566
+ To revert to default settings:
567
+
568
+ ```bash
569
+ jira-ai settings --reset
570
+ ```
571
+
377
572
  All avalible commands: [https://github.com/festoinc/jira-ai/blob/main/all_avaliable_commands.md](https://github.com/festoinc/jira-ai/blob/main/all_avaliable_commands.md)
378
573
 
379
574
  ## Links
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
  // =============================================================================
@@ -763,12 +780,19 @@ program
763
780
  .option('--apply <path>', 'Validate and apply settings from a YAML file')
764
781
  .option('--validate <path>', 'Perform schema and deep validation of a settings YAML file')
765
782
  .option('--reset', 'Revert settings to default')
783
+ .option('--preset <name>', 'Apply a predefined configuration preset (read-only, standard, my-tasks, yolo)')
784
+ .option('--list-presets', 'List all available predefined presets with their details')
785
+ .option('--detect-preset', 'Detect which preset (if any) matches current settings')
766
786
  .addHelpText('after', `
767
787
  Examples:
768
788
  $ jira-ai settings
769
789
  $ jira-ai settings --validate my-settings.yaml
770
790
  $ jira-ai settings --apply my-settings.yaml
771
791
  $ jira-ai settings --reset
792
+ $ jira-ai settings --preset read-only
793
+ $ jira-ai settings --preset standard
794
+ $ jira-ai settings --list-presets
795
+ $ jira-ai settings --detect-preset
772
796
 
773
797
  Settings File Structure:
774
798
  defaults:
@@ -782,7 +806,7 @@ Settings File Structure:
782
806
  Command Groups (use in allowed-commands):
783
807
  issue - get, create, search, transition, update, comment, stats, assign, label
784
808
  project - list, statuses, types
785
- user - me, search, worklog
809
+ user - me, search, worklog, activity
786
810
  epic - list, get, create, update, issues, link, unlink, progress
787
811
  confl - get, spaces, pages, create, comment, update
788
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;
@@ -1,12 +1,40 @@
1
1
  import fs from 'fs';
2
2
  import yaml from 'js-yaml';
3
- import { loadSettings, saveSettings, DEFAULT_SETTINGS, migrateSettings } from '../lib/settings.js';
3
+ import { loadSettings, saveSettings, DEFAULT_SETTINGS, migrateSettings, __resetCache__, } from '../lib/settings.js';
4
4
  import { SettingsSchema } from '../lib/validation.js';
5
5
  import { getProjects } from '../lib/jira-client.js';
6
6
  import { CommandError } from '../lib/errors.js';
7
7
  import { validateEnvVars } from '../lib/utils.js';
8
8
  import { outputResult } from '../lib/json-mode.js';
9
+ import { getPreset, listPresets, detectPreset } from '../lib/presets.js';
9
10
  export async function settingsCommand(options) {
11
+ const presetFlags = [options.preset, options.listPresets, options.detectPreset].filter(Boolean).length;
12
+ const exclusiveFlags = presetFlags + (options.reset ? 1 : 0) + (options.apply ? 1 : 0) + (options.validate ? 1 : 0);
13
+ if (exclusiveFlags > 1) {
14
+ throw new CommandError('--preset, --list-presets, --detect-preset, --reset, --apply, and --validate are mutually exclusive');
15
+ }
16
+ if (options.listPresets) {
17
+ outputResult({ presets: listPresets() });
18
+ return;
19
+ }
20
+ if (options.detectPreset) {
21
+ const settings = loadSettings();
22
+ const defaults = settings.defaults || DEFAULT_SETTINGS.defaults;
23
+ outputResult(detectPreset(defaults));
24
+ return;
25
+ }
26
+ if (options.preset) {
27
+ const preset = getPreset(options.preset);
28
+ __resetCache__();
29
+ const current = loadSettings();
30
+ const newSettings = {
31
+ defaults: { ...preset.defaults, ...(preset.globalParticipationFilter ? { globalParticipationFilter: preset.globalParticipationFilter } : {}) },
32
+ savedQueries: current.savedQueries,
33
+ };
34
+ saveSettings(newSettings);
35
+ outputResult({ success: true, preset: options.preset, message: `Preset applied. Edit ~/.jira-ai/settings.yaml to customize.` });
36
+ return;
37
+ }
10
38
  if (options.reset) {
11
39
  try {
12
40
  saveSettings(DEFAULT_SETTINGS);
@@ -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
+ }
@@ -0,0 +1,215 @@
1
+ export const PRESETS = {
2
+ 'read-only': {
3
+ description: 'AI can only observe. No create, update, delete, or transition operations.',
4
+ defaults: {
5
+ 'allowed-jira-projects': ['all'],
6
+ 'allowed-commands': [
7
+ 'issue.get',
8
+ 'issue.search',
9
+ 'issue.stats',
10
+ 'issue.comments',
11
+ 'issue.activity',
12
+ 'issue.tree',
13
+ 'issue.worklog.list',
14
+ 'issue.link.list',
15
+ 'issue.link.types',
16
+ 'issue.attach.list',
17
+ 'project.list',
18
+ 'project.statuses',
19
+ 'project.types',
20
+ 'project.fields',
21
+ 'user.me',
22
+ 'user.search',
23
+ 'user.worklog',
24
+ 'user.activity',
25
+ 'confl.get',
26
+ 'confl.spaces',
27
+ 'confl.pages',
28
+ 'confl.search',
29
+ 'epic.list',
30
+ 'epic.get',
31
+ 'epic.issues',
32
+ 'epic.progress',
33
+ 'board.list',
34
+ 'board.get',
35
+ 'board.config',
36
+ 'board.issues',
37
+ 'sprint.list',
38
+ 'sprint.get',
39
+ 'sprint.issues',
40
+ 'sprint.tree',
41
+ ],
42
+ 'allowed-confluence-spaces': ['all'],
43
+ },
44
+ },
45
+ 'standard': {
46
+ description: 'AI can perform common productive actions but cannot do destructive operations.',
47
+ defaults: {
48
+ 'allowed-jira-projects': ['all'],
49
+ 'allowed-commands': [
50
+ 'issue.get',
51
+ 'issue.create',
52
+ 'issue.search',
53
+ 'issue.transition',
54
+ 'issue.update',
55
+ 'issue.comment',
56
+ 'issue.stats',
57
+ 'issue.assign',
58
+ 'issue.label.add',
59
+ 'issue.label.remove',
60
+ 'issue.link.list',
61
+ 'issue.link.create',
62
+ 'issue.link.types',
63
+ 'issue.attach.upload',
64
+ 'issue.attach.list',
65
+ 'issue.attach.download',
66
+ 'issue.comments',
67
+ 'issue.activity',
68
+ 'issue.tree',
69
+ 'issue.worklog.list',
70
+ 'issue.worklog.add',
71
+ 'issue.worklog.update',
72
+ 'project.list',
73
+ 'project.statuses',
74
+ 'project.types',
75
+ 'project.fields',
76
+ 'user.me',
77
+ 'user.search',
78
+ 'user.worklog',
79
+ 'user.activity',
80
+ 'confl.get',
81
+ 'confl.spaces',
82
+ 'confl.pages',
83
+ 'confl.create',
84
+ 'confl.comment',
85
+ 'confl.update',
86
+ 'confl.search',
87
+ 'epic.list',
88
+ 'epic.get',
89
+ 'epic.create',
90
+ 'epic.update',
91
+ 'epic.issues',
92
+ 'epic.link',
93
+ 'epic.unlink',
94
+ 'epic.progress',
95
+ 'board.list',
96
+ 'board.get',
97
+ 'board.config',
98
+ 'board.issues',
99
+ 'sprint.list',
100
+ 'sprint.get',
101
+ 'sprint.issues',
102
+ 'sprint.tree',
103
+ 'sprint.update',
104
+ ],
105
+ 'allowed-confluence-spaces': ['all'],
106
+ },
107
+ },
108
+ 'my-tasks': {
109
+ description: 'AI has full command access but restricted to issues where the current user participated.',
110
+ defaults: {
111
+ 'allowed-jira-projects': ['all'],
112
+ 'allowed-commands': [
113
+ 'issue',
114
+ 'project',
115
+ 'user',
116
+ 'confl',
117
+ 'epic',
118
+ 'board',
119
+ 'sprint',
120
+ 'backlog',
121
+ ],
122
+ 'allowed-confluence-spaces': ['all'],
123
+ },
124
+ globalParticipationFilter: {
125
+ was_assignee: true,
126
+ was_reporter: true,
127
+ was_commenter: true,
128
+ is_watcher: true,
129
+ },
130
+ },
131
+ 'yolo': {
132
+ description: 'Unrestricted access. The AI can do everything. The name explicitly signals risk.',
133
+ defaults: {
134
+ 'allowed-jira-projects': ['all'],
135
+ 'allowed-commands': ['all'],
136
+ 'allowed-confluence-spaces': ['all'],
137
+ },
138
+ },
139
+ };
140
+ export function getPreset(name) {
141
+ const preset = PRESETS[name];
142
+ if (!preset) {
143
+ const available = Object.keys(PRESETS).join(', ');
144
+ throw new Error(`Unknown preset "${name}". Available presets: ${available}`);
145
+ }
146
+ return preset;
147
+ }
148
+ export function listPresets() {
149
+ const result = {};
150
+ for (const [name, preset] of Object.entries(PRESETS)) {
151
+ result[name] = {
152
+ description: preset.description,
153
+ 'allowed-commands': preset.defaults['allowed-commands'],
154
+ 'allowed-jira-projects': preset.defaults['allowed-jira-projects'],
155
+ 'allowed-confluence-spaces': preset.defaults['allowed-confluence-spaces'],
156
+ ...(preset.globalParticipationFilter !== undefined && { globalParticipationFilter: preset.globalParticipationFilter }),
157
+ };
158
+ }
159
+ return result;
160
+ }
161
+ export function detectPreset(settings) {
162
+ for (const [name, preset] of Object.entries(PRESETS)) {
163
+ if (settingsMatchPreset(settings, preset.defaults, preset.globalParticipationFilter)) {
164
+ return {
165
+ current: name,
166
+ description: `Your settings match the '${name}' preset.`,
167
+ };
168
+ }
169
+ }
170
+ // Find closest match
171
+ let closestMatch;
172
+ let minDiff = Infinity;
173
+ for (const [name, preset] of Object.entries(PRESETS)) {
174
+ const currentCmds = new Set(settings['allowed-commands']);
175
+ const presetCmds = new Set(preset.defaults['allowed-commands']);
176
+ const added = [...currentCmds].filter(c => !presetCmds.has(c));
177
+ const removed = [...presetCmds].filter(c => !currentCmds.has(c));
178
+ const diff = added.length + removed.length;
179
+ if (diff < minDiff) {
180
+ minDiff = diff;
181
+ closestMatch = name;
182
+ }
183
+ }
184
+ const closestPreset = closestMatch ? PRESETS[closestMatch] : undefined;
185
+ const currentCmds = new Set(settings['allowed-commands']);
186
+ const presetCmds = closestPreset ? new Set(closestPreset.defaults['allowed-commands']) : new Set();
187
+ const addedCommands = [...currentCmds].filter(c => !presetCmds.has(c));
188
+ const removedCommands = [...presetCmds].filter(c => !currentCmds.has(c));
189
+ return {
190
+ current: 'custom',
191
+ description: 'Your settings do not match any predefined preset.',
192
+ closestMatch,
193
+ differences: {
194
+ addedCommands,
195
+ removedCommands,
196
+ },
197
+ };
198
+ }
199
+ function settingsMatchPreset(settings, presetDefaults, presetGlobalFilter) {
200
+ const settingsCmds = [...settings['allowed-commands']].sort();
201
+ const presetCmds = [...presetDefaults['allowed-commands']].sort();
202
+ if (JSON.stringify(settingsCmds) !== JSON.stringify(presetCmds))
203
+ return false;
204
+ const settingsProjects = [...settings['allowed-jira-projects']].map(p => typeof p === 'string' ? p : JSON.stringify(p)).sort();
205
+ const presetProjects = [...presetDefaults['allowed-jira-projects']].map(p => typeof p === 'string' ? p : JSON.stringify(p)).sort();
206
+ if (JSON.stringify(settingsProjects) !== JSON.stringify(presetProjects))
207
+ return false;
208
+ const settingsSpaces = [...settings['allowed-confluence-spaces']].sort();
209
+ const presetSpaces = [...presetDefaults['allowed-confluence-spaces']].sort();
210
+ if (JSON.stringify(settingsSpaces) !== JSON.stringify(presetSpaces))
211
+ return false;
212
+ if (JSON.stringify(settings.globalParticipationFilter ?? null) !== JSON.stringify(presetGlobalFilter ?? null))
213
+ return false;
214
+ return true;
215
+ }
@@ -249,12 +249,41 @@ export function getAllowedConfluenceSpaces() {
249
249
  const settings = getEffectiveSettings();
250
250
  return settings ? settings['allowed-confluence-spaces'] : ['all'];
251
251
  }
252
+ function buildParticipationJql(filter) {
253
+ const parts = [];
254
+ if (filter.was_assignee)
255
+ parts.push('assignee was currentUser()');
256
+ if (filter.was_reporter)
257
+ parts.push('reporter = currentUser()');
258
+ if (filter.was_commenter)
259
+ parts.push('issue in issueHistory()');
260
+ if (filter.is_watcher)
261
+ parts.push('issue in watchedIssues()');
262
+ return parts.join(' OR ');
263
+ }
252
264
  export function applyGlobalFilters(jql) {
253
265
  const settings = getEffectiveSettings();
254
266
  if (!settings)
255
267
  return jql;
256
268
  const allAllowed = settings['allowed-jira-projects'].some(p => p === 'all');
257
269
  if (allAllowed) {
270
+ // When globalParticipationFilter is set, inject participation-based JQL so
271
+ // issue.search is restricted to issues the user participated in (not just individual
272
+ // issue actions which validateIssueAgainstFilters already gates).
273
+ if (settings.globalParticipationFilter) {
274
+ const participationJql = buildParticipationJql(settings.globalParticipationFilter);
275
+ if (participationJql) {
276
+ let filterPart = jql;
277
+ let orderByPart = '';
278
+ const orderByMatch = jql.match(/(.*)\bORDER BY\b(.*)/i);
279
+ if (orderByMatch) {
280
+ filterPart = orderByMatch[1].trim();
281
+ orderByPart = ` ORDER BY ${orderByMatch[2].trim()}`;
282
+ }
283
+ const filterJql = filterPart.trim() ? ` AND (${filterPart})` : '';
284
+ return `(${participationJql})${filterJql}${orderByPart}`;
285
+ }
286
+ }
258
287
  return jql;
259
288
  }
260
289
  // Handle ORDER BY
@@ -297,8 +326,23 @@ export function validateIssueAgainstFilters(issue, currentUserId) {
297
326
  if (!project) {
298
327
  return false;
299
328
  }
300
- if (typeof project === 'string')
329
+ if (typeof project === 'string') {
330
+ // Apply global participation filter when project is 'all' and globalParticipationFilter is set
331
+ if (project === 'all' && settings.globalParticipationFilter) {
332
+ const participated = settings.globalParticipationFilter;
333
+ let hasParticipated = false;
334
+ if (participated.was_assignee && issue.assignee?.accountId === currentUserId)
335
+ hasParticipated = true;
336
+ if (participated.was_reporter && issue.reporter?.accountId === currentUserId)
337
+ hasParticipated = true;
338
+ if (participated.was_commenter && issue.comments?.some((c) => c.author?.accountId === currentUserId))
339
+ hasParticipated = true;
340
+ if (participated.is_watcher && issue.watchers?.includes('CURRENT_USER'))
341
+ hasParticipated = true;
342
+ return hasParticipated;
343
+ }
301
344
  return true;
345
+ }
302
346
  if (project.filters?.participated) {
303
347
  const { participated } = project.filters;
304
348
  let hasParticipated = false;
@@ -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(),
@@ -158,6 +166,12 @@ export const OrganizationSettingsSchema = z.object({
158
166
  'allowed-jira-projects': z.array(ProjectSettingSchema).nullish().transform(val => val || ['all']),
159
167
  'allowed-commands': z.array(z.string()).nullish().transform(val => val || DEFAULT_ALLOWED_COMMANDS),
160
168
  'allowed-confluence-spaces': z.array(z.string()).nullish().transform(val => val || ['all']),
169
+ globalParticipationFilter: z.object({
170
+ was_assignee: z.boolean().optional(),
171
+ was_reporter: z.boolean().optional(),
172
+ was_commenter: z.boolean().optional(),
173
+ is_watcher: z.boolean().optional(),
174
+ }).optional(),
161
175
  });
162
176
  export const SavedQueryNameSchema = z.string().regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/, 'Query name must be lowercase alphanumeric with hyphens (e.g., "my-query"), cannot start or end with a hyphen');
163
177
  export const SettingsSchema = z.object({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jira-ai",
3
- "version": "1.6.0",
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",