jira-ai 1.6.0 → 1.7.1

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
@@ -365,15 +365,134 @@ jira-ai issue search --query overdue-tasks --limit 10
365
365
 
366
366
  Saved queries are mutually exclusive with raw JQL — you cannot provide both a positional JQL argument and `--query` at the same time.
367
367
 
368
+ ## Presets
369
+
370
+ 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.
371
+
372
+ The `--preset`, `--list-presets`, `--detect-preset`, `--apply`, `--validate`, and `--reset` flags are mutually exclusive — only one can be used at a time.
373
+
374
+ ### Available Presets
375
+
376
+ | Preset | Description |
377
+ | :--- | :--- |
378
+ | `read-only` | AI can only observe. No create, update, delete, or transition operations. |
379
+ | `standard` | AI can perform common productive actions but cannot do destructive operations (delete, sprint management). |
380
+ | `my-tasks` | AI has full command access but is restricted to issues where the current user participated (assignee, reporter, commenter, or watcher). |
381
+ | `yolo` | Unrestricted access. The AI can do everything. The name explicitly signals risk. |
382
+
383
+ #### What each preset allows
384
+
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`
386
+ - **`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
+ - **`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
+ - **`yolo`** — All commands, all projects, all Confluence spaces. No restrictions.
389
+
390
+ ### Usage
391
+
392
+ Apply a preset:
393
+
394
+ ```bash
395
+ jira-ai settings --preset read-only
396
+ jira-ai settings --preset standard
397
+ jira-ai settings --preset my-tasks
398
+ jira-ai settings --preset yolo
399
+ ```
400
+
401
+ List all available presets with their full configuration details:
402
+
403
+ ```bash
404
+ jira-ai settings --list-presets
405
+ ```
406
+
407
+ Detect which preset (if any) your current settings match:
408
+
409
+ ```bash
410
+ jira-ai settings --detect-preset
411
+ ```
412
+
413
+ 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.
414
+
415
+ Reset settings to defaults:
416
+
417
+ ```bash
418
+ jira-ai settings --reset
419
+ ```
420
+
421
+ Validate a settings file without applying it:
422
+
423
+ ```bash
424
+ jira-ai settings --validate my-settings.yaml
425
+ ```
426
+
427
+ Apply a settings file:
428
+
429
+ ```bash
430
+ jira-ai settings --apply my-settings.yaml
431
+ ```
432
+
433
+ View current settings:
434
+
435
+ ```bash
436
+ jira-ai settings
437
+ ```
438
+
439
+ After applying a preset, you can further customize permissions by editing `~/.jira-ai/settings.yaml`. Saved queries are preserved when switching presets.
440
+
441
+ ### globalParticipationFilter
442
+
443
+ 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:
444
+
445
+ ```yaml
446
+ defaults:
447
+ globalParticipationFilter:
448
+ was_assignee: true
449
+ was_reporter: true
450
+ was_commenter: true
451
+ is_watcher: true
452
+ ```
453
+
454
+ | Field | JQL equivalent | Meaning |
455
+ | :--- | :--- | :--- |
456
+ | `was_assignee` | `assignee was currentUser()` | User was ever assigned to the issue |
457
+ | `was_reporter` | `reporter = currentUser()` | User is the issue reporter |
458
+ | `was_commenter` | `issue in issueHistory()` | User commented on the issue |
459
+ | `is_watcher` | `issue in watchedIssues()` | User is watching the issue |
460
+
461
+ 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.
462
+
368
463
  ## Configuration & Restrictions
369
464
 
370
465
  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:
466
+
467
+ ### Quick setup with presets
468
+
469
+ The fastest way to configure permissions is with a preset (see [Presets](#presets) above):
470
+
471
+ ```bash
472
+ jira-ai settings --preset standard
473
+ ```
474
+
475
+ ### Manual setup
476
+
477
+ Use this command to start setup:
372
478
 
373
479
  ```bash
374
480
  jira-ai settings --help
375
481
  ```
376
482
 
483
+ You can also validate or apply settings from a YAML file:
484
+
485
+ ```bash
486
+ jira-ai settings --validate my-settings.yaml
487
+ jira-ai settings --apply my-settings.yaml
488
+ ```
489
+
490
+ To revert to default settings:
491
+
492
+ ```bash
493
+ jira-ai settings --reset
494
+ ```
495
+
377
496
  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
497
 
379
498
  ## Links
package/dist/cli.js CHANGED
@@ -763,12 +763,19 @@ program
763
763
  .option('--apply <path>', 'Validate and apply settings from a YAML file')
764
764
  .option('--validate <path>', 'Perform schema and deep validation of a settings YAML file')
765
765
  .option('--reset', 'Revert settings to default')
766
+ .option('--preset <name>', 'Apply a predefined configuration preset (read-only, standard, my-tasks, yolo)')
767
+ .option('--list-presets', 'List all available predefined presets with their details')
768
+ .option('--detect-preset', 'Detect which preset (if any) matches current settings')
766
769
  .addHelpText('after', `
767
770
  Examples:
768
771
  $ jira-ai settings
769
772
  $ jira-ai settings --validate my-settings.yaml
770
773
  $ jira-ai settings --apply my-settings.yaml
771
774
  $ jira-ai settings --reset
775
+ $ jira-ai settings --preset read-only
776
+ $ jira-ai settings --preset standard
777
+ $ jira-ai settings --list-presets
778
+ $ jira-ai settings --detect-preset
772
779
 
773
780
  Settings File Structure:
774
781
  defaults:
@@ -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,213 @@
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
+ 'confl.get',
25
+ 'confl.spaces',
26
+ 'confl.pages',
27
+ 'confl.search',
28
+ 'epic.list',
29
+ 'epic.get',
30
+ 'epic.issues',
31
+ 'epic.progress',
32
+ 'board.list',
33
+ 'board.get',
34
+ 'board.config',
35
+ 'board.issues',
36
+ 'sprint.list',
37
+ 'sprint.get',
38
+ 'sprint.issues',
39
+ 'sprint.tree',
40
+ ],
41
+ 'allowed-confluence-spaces': ['all'],
42
+ },
43
+ },
44
+ 'standard': {
45
+ description: 'AI can perform common productive actions but cannot do destructive operations.',
46
+ defaults: {
47
+ 'allowed-jira-projects': ['all'],
48
+ 'allowed-commands': [
49
+ 'issue.get',
50
+ 'issue.create',
51
+ 'issue.search',
52
+ 'issue.transition',
53
+ 'issue.update',
54
+ 'issue.comment',
55
+ 'issue.stats',
56
+ 'issue.assign',
57
+ 'issue.label.add',
58
+ 'issue.label.remove',
59
+ 'issue.link.list',
60
+ 'issue.link.create',
61
+ 'issue.link.types',
62
+ 'issue.attach.upload',
63
+ 'issue.attach.list',
64
+ 'issue.attach.download',
65
+ 'issue.comments',
66
+ 'issue.activity',
67
+ 'issue.tree',
68
+ 'issue.worklog.list',
69
+ 'issue.worklog.add',
70
+ 'issue.worklog.update',
71
+ 'project.list',
72
+ 'project.statuses',
73
+ 'project.types',
74
+ 'project.fields',
75
+ 'user.me',
76
+ 'user.search',
77
+ 'user.worklog',
78
+ 'confl.get',
79
+ 'confl.spaces',
80
+ 'confl.pages',
81
+ 'confl.create',
82
+ 'confl.comment',
83
+ 'confl.update',
84
+ 'confl.search',
85
+ 'epic.list',
86
+ 'epic.get',
87
+ 'epic.create',
88
+ 'epic.update',
89
+ 'epic.issues',
90
+ 'epic.link',
91
+ 'epic.unlink',
92
+ 'epic.progress',
93
+ 'board.list',
94
+ 'board.get',
95
+ 'board.config',
96
+ 'board.issues',
97
+ 'sprint.list',
98
+ 'sprint.get',
99
+ 'sprint.issues',
100
+ 'sprint.tree',
101
+ 'sprint.update',
102
+ ],
103
+ 'allowed-confluence-spaces': ['all'],
104
+ },
105
+ },
106
+ 'my-tasks': {
107
+ description: 'AI has full command access but restricted to issues where the current user participated.',
108
+ defaults: {
109
+ 'allowed-jira-projects': ['all'],
110
+ 'allowed-commands': [
111
+ 'issue',
112
+ 'project',
113
+ 'user',
114
+ 'confl',
115
+ 'epic',
116
+ 'board',
117
+ 'sprint',
118
+ 'backlog',
119
+ ],
120
+ 'allowed-confluence-spaces': ['all'],
121
+ },
122
+ globalParticipationFilter: {
123
+ was_assignee: true,
124
+ was_reporter: true,
125
+ was_commenter: true,
126
+ is_watcher: true,
127
+ },
128
+ },
129
+ 'yolo': {
130
+ description: 'Unrestricted access. The AI can do everything. The name explicitly signals risk.',
131
+ defaults: {
132
+ 'allowed-jira-projects': ['all'],
133
+ 'allowed-commands': ['all'],
134
+ 'allowed-confluence-spaces': ['all'],
135
+ },
136
+ },
137
+ };
138
+ export function getPreset(name) {
139
+ const preset = PRESETS[name];
140
+ if (!preset) {
141
+ const available = Object.keys(PRESETS).join(', ');
142
+ throw new Error(`Unknown preset "${name}". Available presets: ${available}`);
143
+ }
144
+ return preset;
145
+ }
146
+ export function listPresets() {
147
+ const result = {};
148
+ for (const [name, preset] of Object.entries(PRESETS)) {
149
+ result[name] = {
150
+ description: preset.description,
151
+ 'allowed-commands': preset.defaults['allowed-commands'],
152
+ 'allowed-jira-projects': preset.defaults['allowed-jira-projects'],
153
+ 'allowed-confluence-spaces': preset.defaults['allowed-confluence-spaces'],
154
+ ...(preset.globalParticipationFilter !== undefined && { globalParticipationFilter: preset.globalParticipationFilter }),
155
+ };
156
+ }
157
+ return result;
158
+ }
159
+ export function detectPreset(settings) {
160
+ for (const [name, preset] of Object.entries(PRESETS)) {
161
+ if (settingsMatchPreset(settings, preset.defaults, preset.globalParticipationFilter)) {
162
+ return {
163
+ current: name,
164
+ description: `Your settings match the '${name}' preset.`,
165
+ };
166
+ }
167
+ }
168
+ // Find closest match
169
+ let closestMatch;
170
+ let minDiff = Infinity;
171
+ for (const [name, preset] of Object.entries(PRESETS)) {
172
+ const currentCmds = new Set(settings['allowed-commands']);
173
+ const presetCmds = new Set(preset.defaults['allowed-commands']);
174
+ const added = [...currentCmds].filter(c => !presetCmds.has(c));
175
+ const removed = [...presetCmds].filter(c => !currentCmds.has(c));
176
+ const diff = added.length + removed.length;
177
+ if (diff < minDiff) {
178
+ minDiff = diff;
179
+ closestMatch = name;
180
+ }
181
+ }
182
+ const closestPreset = closestMatch ? PRESETS[closestMatch] : undefined;
183
+ const currentCmds = new Set(settings['allowed-commands']);
184
+ const presetCmds = closestPreset ? new Set(closestPreset.defaults['allowed-commands']) : new Set();
185
+ const addedCommands = [...currentCmds].filter(c => !presetCmds.has(c));
186
+ const removedCommands = [...presetCmds].filter(c => !currentCmds.has(c));
187
+ return {
188
+ current: 'custom',
189
+ description: 'Your settings do not match any predefined preset.',
190
+ closestMatch,
191
+ differences: {
192
+ addedCommands,
193
+ removedCommands,
194
+ },
195
+ };
196
+ }
197
+ function settingsMatchPreset(settings, presetDefaults, presetGlobalFilter) {
198
+ const settingsCmds = [...settings['allowed-commands']].sort();
199
+ const presetCmds = [...presetDefaults['allowed-commands']].sort();
200
+ if (JSON.stringify(settingsCmds) !== JSON.stringify(presetCmds))
201
+ return false;
202
+ const settingsProjects = [...settings['allowed-jira-projects']].map(p => typeof p === 'string' ? p : JSON.stringify(p)).sort();
203
+ const presetProjects = [...presetDefaults['allowed-jira-projects']].map(p => typeof p === 'string' ? p : JSON.stringify(p)).sort();
204
+ if (JSON.stringify(settingsProjects) !== JSON.stringify(presetProjects))
205
+ return false;
206
+ const settingsSpaces = [...settings['allowed-confluence-spaces']].sort();
207
+ const presetSpaces = [...presetDefaults['allowed-confluence-spaces']].sort();
208
+ if (JSON.stringify(settingsSpaces) !== JSON.stringify(presetSpaces))
209
+ return false;
210
+ if (JSON.stringify(settings.globalParticipationFilter ?? null) !== JSON.stringify(presetGlobalFilter ?? null))
211
+ return false;
212
+ return true;
213
+ }
@@ -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;
@@ -158,6 +158,12 @@ export const OrganizationSettingsSchema = z.object({
158
158
  'allowed-jira-projects': z.array(ProjectSettingSchema).nullish().transform(val => val || ['all']),
159
159
  'allowed-commands': z.array(z.string()).nullish().transform(val => val || DEFAULT_ALLOWED_COMMANDS),
160
160
  'allowed-confluence-spaces': z.array(z.string()).nullish().transform(val => val || ['all']),
161
+ globalParticipationFilter: z.object({
162
+ was_assignee: z.boolean().optional(),
163
+ was_reporter: z.boolean().optional(),
164
+ was_commenter: z.boolean().optional(),
165
+ is_watcher: z.boolean().optional(),
166
+ }).optional(),
161
167
  });
162
168
  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
169
  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.7.1",
4
4
  "description": "AI friendly Jira CLI to save context",
5
5
  "type": "module",
6
6
  "main": "dist/cli.js",