jira-ai 1.5.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 +191 -3
- package/dist/cli.js +90 -0
- package/dist/commands/issue-worklog.js +162 -0
- package/dist/commands/settings.js +29 -1
- package/dist/lib/jira-client.js +138 -0
- package/dist/lib/presets.js +213 -0
- package/dist/lib/settings.js +45 -1
- package/dist/lib/utils.js +39 -0
- package/dist/lib/validation.js +43 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -51,12 +51,13 @@ Errors are returned as structured JSON to stdout:
|
|
|
51
51
|
|
|
52
52
|
### Dry-Run / Preview Mode
|
|
53
53
|
|
|
54
|
-
Preview write operations without executing them. The `--dry-run` flag is available on `issue create`, `issue update`, and `issue
|
|
54
|
+
Preview write operations without executing them. The `--dry-run` flag is available on `issue create`, `issue update`, `issue transition`, and `issue worklog add/update/delete`. No Jira API write calls are made — output is purely a preview.
|
|
55
55
|
|
|
56
56
|
```bash
|
|
57
57
|
jira-ai issue update PROJ-123 --priority High --dry-run
|
|
58
58
|
jira-ai issue transition PROJ-123 Done --resolution Fixed --dry-run
|
|
59
59
|
jira-ai issue create --project PROJ --type Bug --title "Fix crash" --dry-run
|
|
60
|
+
jira-ai issue worklog add PROJ-123 --time 2h --comment "Debugging" --dry-run
|
|
60
61
|
```
|
|
61
62
|
|
|
62
63
|
Dry-run output follows a consistent JSON structure:
|
|
@@ -74,7 +75,7 @@ Dry-run output follows a consistent JSON structure:
|
|
|
74
75
|
}
|
|
75
76
|
```
|
|
76
77
|
|
|
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`
|
|
78
|
+
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`. Worklog dry-run: `issue worklog add`, `issue worklog update`, `issue worklog delete`.
|
|
78
79
|
|
|
79
80
|
### Issue Hierarchy Tree
|
|
80
81
|
|
|
@@ -208,6 +209,74 @@ jira-ai issue comments PROJ-123 --since 2026-01-01T00:00:00Z --reverse --limit 2
|
|
|
208
209
|
|
|
209
210
|
**Activity types:** `status_change`, `field_change`, `comment_added`, `comment_updated`, `attachment_added`, `attachment_removed`, `link_added`, `link_removed`
|
|
210
211
|
|
|
212
|
+
### Worklog Management
|
|
213
|
+
|
|
214
|
+
Log time against issues with full CRUD support:
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
jira-ai issue worklog add PROJ-123 --time 2h
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Add a comment and specify when the work started:
|
|
221
|
+
|
|
222
|
+
```bash
|
|
223
|
+
jira-ai issue worklog add PROJ-123 --time 1d2h30m --comment "Backend refactor" --started "2026-04-15T09:00:00+02:00"
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
The `--started` flag accepts any standard ISO 8601 timestamp. Timezone offsets are automatically normalized to Jira's required format (`yyyy-MM-dd'T'HH:mm:ss.SSS±HHMM`):
|
|
227
|
+
|
|
228
|
+
- `2026-04-15T07:00:00Z` → `2026-04-15T07:00:00.000+0000`
|
|
229
|
+
- `2026-04-15T10:00:00+03:00` → `2026-04-15T10:00:00.000+0300`
|
|
230
|
+
- `2026-04-15T07:00:00-05:30` → `2026-04-15T07:00:00.000-0530`
|
|
231
|
+
|
|
232
|
+
When omitted, `--started` defaults to the current time.
|
|
233
|
+
|
|
234
|
+
Log time with estimate adjustment (`--adjust-estimate` accepts `auto`, `new`, `leave`, or `manual`):
|
|
235
|
+
|
|
236
|
+
```bash
|
|
237
|
+
jira-ai issue worklog add PROJ-123 --time 4h --adjust-estimate new --new-estimate 2d
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Use `--reduce-by` with `--adjust-estimate manual` to decrease the remaining estimate:
|
|
241
|
+
|
|
242
|
+
```bash
|
|
243
|
+
jira-ai issue worklog add PROJ-123 --time 3h --adjust-estimate manual --reduce-by 2h
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
List worklogs for an issue:
|
|
247
|
+
|
|
248
|
+
```bash
|
|
249
|
+
jira-ai issue worklog list PROJ-123
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
Filter by time range or author:
|
|
253
|
+
|
|
254
|
+
```bash
|
|
255
|
+
jira-ai issue worklog list PROJ-123 --started-after 1713139200000 --started-before 1715731200000
|
|
256
|
+
jira-ai issue worklog list PROJ-123 --author-account-id 557058:abc123-def456-ghi789
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Update an existing worklog:
|
|
260
|
+
|
|
261
|
+
```bash
|
|
262
|
+
jira-ai issue worklog update PROJ-123 --id 12345 --time 3h --comment "Updated after review"
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Delete a worklog (use `--increase-by` with `--adjust-estimate manual` to restore estimate):
|
|
266
|
+
|
|
267
|
+
```bash
|
|
268
|
+
jira-ai issue worklog delete PROJ-123 --id 12345
|
|
269
|
+
jira-ai issue worklog delete PROJ-123 --id 12345 --adjust-estimate manual --increase-by 2h
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
Preview any write operation with `--dry-run`:
|
|
273
|
+
|
|
274
|
+
```bash
|
|
275
|
+
jira-ai issue worklog add PROJ-123 --time 2h --dry-run
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
Duration format uses Jira-style notation: `1w` (5 working days), `1d` (8 hours), `1h`, `30m`, or combinations like `1d2h30m`.
|
|
279
|
+
|
|
211
280
|
## Service Account Authentication
|
|
212
281
|
|
|
213
282
|
Atlassian service accounts use scoped API tokens that must authenticate through the `api.atlassian.com` gateway rather than direct site URLs.
|
|
@@ -296,15 +365,134 @@ jira-ai issue search --query overdue-tasks --limit 10
|
|
|
296
365
|
|
|
297
366
|
Saved queries are mutually exclusive with raw JQL — you cannot provide both a positional JQL argument and `--query` at the same time.
|
|
298
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
|
+
|
|
299
463
|
## Configuration & Restrictions
|
|
300
464
|
|
|
301
465
|
Tool allows you to have very complex configutations of what Projects/Jira commands/Issue types you would have acess to thought the tool.
|
|
302
|
-
|
|
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:
|
|
303
478
|
|
|
304
479
|
```bash
|
|
305
480
|
jira-ai settings --help
|
|
306
481
|
```
|
|
307
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
|
+
|
|
308
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)
|
|
309
497
|
|
|
310
498
|
## Links
|
package/dist/cli.js
CHANGED
|
@@ -33,6 +33,7 @@ import { sprintListCommand, sprintGetCommand, sprintCreateCommand, sprintStartCo
|
|
|
33
33
|
import { backlogMoveCommand } from './commands/backlog.js';
|
|
34
34
|
import { issueCommentsCommand } from './commands/issue-comments.js';
|
|
35
35
|
import { issueActivityCommand } from './commands/issue-activity.js';
|
|
36
|
+
import { issueWorklogListCommand, issueWorklogAddCommand, issueWorklogUpdateCommand, issueWorklogDeleteCommand, } from './commands/issue-worklog.js';
|
|
36
37
|
import { aboutCommand } from './commands/about.js';
|
|
37
38
|
import { authCommand } from './commands/auth.js';
|
|
38
39
|
import { settingsCommand } from './commands/settings.js';
|
|
@@ -245,6 +246,88 @@ issue
|
|
|
245
246
|
}, {
|
|
246
247
|
validateArgs: (args) => validateOptions(IssueKeySchema, args[0])
|
|
247
248
|
}));
|
|
249
|
+
// Issue worklog subcommands
|
|
250
|
+
const worklog = issue
|
|
251
|
+
.command('worklog')
|
|
252
|
+
.description('Manage worklogs for a Jira issue');
|
|
253
|
+
worklog
|
|
254
|
+
.command('list <issue-id>')
|
|
255
|
+
.description('List all worklogs for a Jira issue.')
|
|
256
|
+
.option('--started-after <timestamp>', 'Only return worklogs started at or after this UNIX timestamp (ms)')
|
|
257
|
+
.option('--started-before <timestamp>', 'Only return worklogs started before this UNIX timestamp (ms)')
|
|
258
|
+
.option('--author-account-id <accountId>', 'Filter by author account ID')
|
|
259
|
+
.action(withPermission('issue.worklog.list', (issueKey, options) => {
|
|
260
|
+
return issueWorklogListCommand({
|
|
261
|
+
issueKey,
|
|
262
|
+
startedAfter: options.startedAfter ? parseInt(options.startedAfter, 10) : undefined,
|
|
263
|
+
startedBefore: options.startedBefore ? parseInt(options.startedBefore, 10) : undefined,
|
|
264
|
+
authorAccountId: options.authorAccountId,
|
|
265
|
+
});
|
|
266
|
+
}, {
|
|
267
|
+
validateArgs: (args) => validateOptions(IssueKeySchema, args[0])
|
|
268
|
+
}));
|
|
269
|
+
worklog
|
|
270
|
+
.command('add <issue-id>')
|
|
271
|
+
.description('Log time against a Jira issue.')
|
|
272
|
+
.requiredOption('--time <duration>', 'Time to log (e.g. 1h, 30m, 1d2h30m, 1w)')
|
|
273
|
+
.option('--comment <text>', 'Optional comment for this worklog entry')
|
|
274
|
+
.option('--started <datetime>', 'When the work started (ISO 8601, defaults to now). Timezone offsets are auto-normalized.')
|
|
275
|
+
.option('--adjust-estimate <method>', 'Estimate adjustment: auto, new, leave, manual')
|
|
276
|
+
.option('--new-estimate <duration>', 'New remaining estimate (use with --adjust-estimate new or manual)')
|
|
277
|
+
.option('--reduce-by <duration>', 'Reduce remaining estimate by this amount (use with --adjust-estimate manual)')
|
|
278
|
+
.action(withPermission('issue.worklog.add', (issueKey, options) => {
|
|
279
|
+
return issueWorklogAddCommand({
|
|
280
|
+
issueKey,
|
|
281
|
+
time: options.time,
|
|
282
|
+
comment: options.comment,
|
|
283
|
+
started: options.started,
|
|
284
|
+
adjustEstimate: options.adjustEstimate,
|
|
285
|
+
newEstimate: options.newEstimate,
|
|
286
|
+
reduceBy: options.reduceBy,
|
|
287
|
+
});
|
|
288
|
+
}, {
|
|
289
|
+
validateArgs: (args) => validateOptions(IssueKeySchema, args[0])
|
|
290
|
+
}));
|
|
291
|
+
worklog
|
|
292
|
+
.command('update <issue-id>')
|
|
293
|
+
.description('Update an existing worklog entry.')
|
|
294
|
+
.requiredOption('--id <worklog-id>', 'ID of the worklog to update')
|
|
295
|
+
.option('--time <duration>', 'New time spent (e.g. 1h, 30m, 1d)')
|
|
296
|
+
.option('--comment <text>', 'New comment for this worklog')
|
|
297
|
+
.option('--started <datetime>', 'New start time (ISO 8601). Timezone offsets are auto-normalized.')
|
|
298
|
+
.option('--adjust-estimate <method>', 'Estimate adjustment: auto, new, leave, manual')
|
|
299
|
+
.option('--new-estimate <duration>', 'New remaining estimate')
|
|
300
|
+
.action(withPermission('issue.worklog.update', (issueKey, options) => {
|
|
301
|
+
return issueWorklogUpdateCommand({
|
|
302
|
+
issueKey,
|
|
303
|
+
id: options.id,
|
|
304
|
+
time: options.time,
|
|
305
|
+
comment: options.comment,
|
|
306
|
+
started: options.started,
|
|
307
|
+
adjustEstimate: options.adjustEstimate,
|
|
308
|
+
newEstimate: options.newEstimate,
|
|
309
|
+
});
|
|
310
|
+
}, {
|
|
311
|
+
validateArgs: (args) => validateOptions(IssueKeySchema, args[0])
|
|
312
|
+
}));
|
|
313
|
+
worklog
|
|
314
|
+
.command('delete <issue-id>')
|
|
315
|
+
.description('Delete a worklog entry from a Jira issue.')
|
|
316
|
+
.requiredOption('--id <worklog-id>', 'ID of the worklog to delete')
|
|
317
|
+
.option('--adjust-estimate <method>', 'Estimate adjustment: auto, new, leave, manual')
|
|
318
|
+
.option('--new-estimate <duration>', 'New remaining estimate (use with --adjust-estimate new)')
|
|
319
|
+
.option('--increase-by <duration>', 'Increase remaining estimate by this amount')
|
|
320
|
+
.action(withPermission('issue.worklog.delete', (issueKey, options) => {
|
|
321
|
+
return issueWorklogDeleteCommand({
|
|
322
|
+
issueKey,
|
|
323
|
+
id: options.id,
|
|
324
|
+
adjustEstimate: options.adjustEstimate,
|
|
325
|
+
newEstimate: options.newEstimate,
|
|
326
|
+
increaseBy: options.increaseBy,
|
|
327
|
+
});
|
|
328
|
+
}, {
|
|
329
|
+
validateArgs: (args) => validateOptions(IssueKeySchema, args[0])
|
|
330
|
+
}));
|
|
248
331
|
issue
|
|
249
332
|
.command('stats <issue-ids>')
|
|
250
333
|
.description('Calculate time-based metrics for one or more issues (comma-separated). Shows time logged, estimates, and status breakdown.')
|
|
@@ -680,12 +763,19 @@ program
|
|
|
680
763
|
.option('--apply <path>', 'Validate and apply settings from a YAML file')
|
|
681
764
|
.option('--validate <path>', 'Perform schema and deep validation of a settings YAML file')
|
|
682
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')
|
|
683
769
|
.addHelpText('after', `
|
|
684
770
|
Examples:
|
|
685
771
|
$ jira-ai settings
|
|
686
772
|
$ jira-ai settings --validate my-settings.yaml
|
|
687
773
|
$ jira-ai settings --apply my-settings.yaml
|
|
688
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
|
|
689
779
|
|
|
690
780
|
Settings File Structure:
|
|
691
781
|
defaults:
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { getIssueWorklogsList, addWorklogEntry, updateWorklogEntry, deleteWorklogEntry, } from '../lib/jira-client.js';
|
|
2
|
+
import { CommandError } from '../lib/errors.js';
|
|
3
|
+
import { outputResult } from '../lib/json-mode.js';
|
|
4
|
+
import { isDryRun, formatDryRunResult } from '../lib/dry-run.js';
|
|
5
|
+
import { parseDuration, normalizeJiraTimestamp } from '../lib/utils.js';
|
|
6
|
+
export async function issueWorklogListCommand(options) {
|
|
7
|
+
const { issueKey, startedAfter, startedBefore, authorAccountId } = options;
|
|
8
|
+
const filterOptions = {};
|
|
9
|
+
if (startedAfter !== undefined)
|
|
10
|
+
filterOptions.startedAfter = startedAfter;
|
|
11
|
+
if (startedBefore !== undefined)
|
|
12
|
+
filterOptions.startedBefore = startedBefore;
|
|
13
|
+
if (authorAccountId !== undefined)
|
|
14
|
+
filterOptions.authorAccountId = authorAccountId;
|
|
15
|
+
try {
|
|
16
|
+
const result = await getIssueWorklogsList(issueKey, filterOptions);
|
|
17
|
+
outputResult(result);
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
if (error instanceof CommandError)
|
|
21
|
+
throw error;
|
|
22
|
+
const errorMsg = error.message?.toLowerCase() || '';
|
|
23
|
+
const hints = [];
|
|
24
|
+
if (errorMsg.includes('404')) {
|
|
25
|
+
hints.push('Check that the issue key is correct');
|
|
26
|
+
}
|
|
27
|
+
else if (errorMsg.includes('403')) {
|
|
28
|
+
hints.push('You may not have permission to view worklogs on this issue');
|
|
29
|
+
}
|
|
30
|
+
throw new CommandError(`Failed to list worklogs: ${error.message}`, { hints });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export async function issueWorklogAddCommand(options) {
|
|
34
|
+
const { issueKey, time, comment, started, adjustEstimate, newEstimate, reduceBy } = options;
|
|
35
|
+
const timeSpentSeconds = parseDuration(time);
|
|
36
|
+
if (timeSpentSeconds === null) {
|
|
37
|
+
throw new CommandError(`Invalid duration: "${time}". Use Jira format e.g. 1h, 30m, 1d2h30m, 1w.`, { hints: ['Examples: 1h, 30m, 1d, 1w, 1d2h30m'] });
|
|
38
|
+
}
|
|
39
|
+
if (adjustEstimate === 'new' && !newEstimate) {
|
|
40
|
+
throw new CommandError('--new-estimate is required when --adjust-estimate is "new"', {
|
|
41
|
+
hints: ['Example: --adjust-estimate new --new-estimate 5h']
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
if (adjustEstimate === 'manual' && !newEstimate && !reduceBy) {
|
|
45
|
+
throw new CommandError('--new-estimate or --reduce-by is required when --adjust-estimate is "manual"', {
|
|
46
|
+
hints: ['Example: --adjust-estimate manual --reduce-by 1h']
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
if (isDryRun()) {
|
|
50
|
+
formatDryRunResult('issue worklog add', issueKey, { timeSpentSeconds, comment, started, adjustEstimate, newEstimate, reduceBy });
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
const result = await addWorklogEntry(issueKey, {
|
|
55
|
+
timeSpentSeconds,
|
|
56
|
+
comment,
|
|
57
|
+
started: started !== undefined ? normalizeJiraTimestamp(started) : undefined,
|
|
58
|
+
adjustEstimate,
|
|
59
|
+
newEstimate,
|
|
60
|
+
reduceBy,
|
|
61
|
+
});
|
|
62
|
+
outputResult(result);
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (error instanceof CommandError)
|
|
66
|
+
throw error;
|
|
67
|
+
const errorMsg = error.message?.toLowerCase() || '';
|
|
68
|
+
const hints = [];
|
|
69
|
+
if (errorMsg.includes('404')) {
|
|
70
|
+
hints.push('Check that the issue key is correct');
|
|
71
|
+
}
|
|
72
|
+
else if (errorMsg.includes('403')) {
|
|
73
|
+
hints.push('You may not have permission to log work on this issue');
|
|
74
|
+
}
|
|
75
|
+
throw new CommandError(`Failed to add worklog: ${error.message}`, { hints });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export async function issueWorklogUpdateCommand(options) {
|
|
79
|
+
const { issueKey, id, time, comment, started, adjustEstimate, newEstimate } = options;
|
|
80
|
+
if (time === undefined && comment === undefined && started === undefined) {
|
|
81
|
+
throw new CommandError('At least one of --time, --comment, or --started must be provided.');
|
|
82
|
+
}
|
|
83
|
+
let timeSpentSeconds;
|
|
84
|
+
if (time !== undefined) {
|
|
85
|
+
const parsed = parseDuration(time);
|
|
86
|
+
if (parsed === null) {
|
|
87
|
+
throw new CommandError(`Invalid duration: "${time}". Use Jira format e.g. 1h, 30m, 1d2h30m, 1w.`, { hints: ['Examples: 1h, 30m, 1d, 1w, 1d2h30m'] });
|
|
88
|
+
}
|
|
89
|
+
timeSpentSeconds = parsed;
|
|
90
|
+
}
|
|
91
|
+
if (adjustEstimate === 'new' && !newEstimate) {
|
|
92
|
+
throw new CommandError('--new-estimate is required when --adjust-estimate is "new"', {
|
|
93
|
+
hints: ['Example: --adjust-estimate new --new-estimate 5h']
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
if (adjustEstimate === 'manual' && !newEstimate) {
|
|
97
|
+
throw new CommandError('--new-estimate is required when --adjust-estimate is "manual"', {
|
|
98
|
+
hints: ['Example: --adjust-estimate manual --new-estimate 5h']
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
if (isDryRun()) {
|
|
102
|
+
formatDryRunResult('issue worklog update', `${issueKey} / worklog ${id}`, { timeSpentSeconds, comment, started, adjustEstimate, newEstimate });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
const result = await updateWorklogEntry(issueKey, id, {
|
|
107
|
+
timeSpentSeconds,
|
|
108
|
+
comment,
|
|
109
|
+
started: started !== undefined ? normalizeJiraTimestamp(started) : undefined,
|
|
110
|
+
adjustEstimate,
|
|
111
|
+
newEstimate,
|
|
112
|
+
});
|
|
113
|
+
outputResult(result);
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
if (error instanceof CommandError)
|
|
117
|
+
throw error;
|
|
118
|
+
const errorMsg = error.message?.toLowerCase() || '';
|
|
119
|
+
const hints = [];
|
|
120
|
+
if (errorMsg.includes('404')) {
|
|
121
|
+
hints.push('Check that the issue key and worklog ID are correct');
|
|
122
|
+
}
|
|
123
|
+
else if (errorMsg.includes('403')) {
|
|
124
|
+
hints.push('You may not have permission to update this worklog');
|
|
125
|
+
}
|
|
126
|
+
throw new CommandError(`Failed to update worklog: ${error.message}`, { hints });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
export async function issueWorklogDeleteCommand(options) {
|
|
130
|
+
const { issueKey, id, adjustEstimate, newEstimate, increaseBy } = options;
|
|
131
|
+
if (adjustEstimate === 'new' && !newEstimate) {
|
|
132
|
+
throw new CommandError('--new-estimate is required when --adjust-estimate is "new"', {
|
|
133
|
+
hints: ['Example: --adjust-estimate new --new-estimate 5h']
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
if (adjustEstimate === 'manual' && !newEstimate && !increaseBy) {
|
|
137
|
+
throw new CommandError('--new-estimate or --increase-by is required when --adjust-estimate is "manual"', {
|
|
138
|
+
hints: ['Example: --adjust-estimate manual --increase-by 1h']
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
if (isDryRun()) {
|
|
142
|
+
formatDryRunResult('issue worklog delete', `${issueKey} / worklog ${id}`, { id, adjustEstimate, newEstimate, increaseBy });
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
await deleteWorklogEntry(issueKey, id, { adjustEstimate, newEstimate, increaseBy });
|
|
147
|
+
outputResult({ deleted: true, issueKey, id });
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
if (error instanceof CommandError)
|
|
151
|
+
throw error;
|
|
152
|
+
const errorMsg = error.message?.toLowerCase() || '';
|
|
153
|
+
const hints = [];
|
|
154
|
+
if (errorMsg.includes('404')) {
|
|
155
|
+
hints.push('Check that the issue key and worklog ID are correct');
|
|
156
|
+
}
|
|
157
|
+
else if (errorMsg.includes('403')) {
|
|
158
|
+
hints.push('You may not have permission to delete this worklog');
|
|
159
|
+
}
|
|
160
|
+
throw new CommandError(`Failed to delete worklog: ${error.message}`, { hints });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -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);
|
package/dist/lib/jira-client.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Version3Client } from 'jira.js';
|
|
2
|
+
import { markdownToAdf } from 'marklassian';
|
|
2
3
|
import { calculateStatusStatistics, convertADFToMarkdown } from './utils.js';
|
|
3
4
|
import { loadCredentials } from './auth-storage.js';
|
|
4
5
|
import { applyGlobalFilters, isProjectAllowed, isCommandAllowed, validateIssueAgainstFilters, getAllowedProjects } from './settings.js';
|
|
@@ -1228,3 +1229,140 @@ export async function deleteAttachment(issueKey, attachmentId) {
|
|
|
1228
1229
|
const client = getJiraClient();
|
|
1229
1230
|
await client.issueAttachments.removeAttachment(attachmentId);
|
|
1230
1231
|
}
|
|
1232
|
+
const WORKLOG_PAGE_SIZE = 5000;
|
|
1233
|
+
/**
|
|
1234
|
+
* List all worklogs for an issue (returns structured result).
|
|
1235
|
+
* Paginates automatically through all pages and supports optional filtering.
|
|
1236
|
+
*/
|
|
1237
|
+
export async function getIssueWorklogsList(issueIdOrKey, options = {}) {
|
|
1238
|
+
const { startedAfter, startedBefore, authorAccountId } = options;
|
|
1239
|
+
const client = getJiraClient();
|
|
1240
|
+
const allWorklogs = [];
|
|
1241
|
+
let startAt = 0;
|
|
1242
|
+
while (true) {
|
|
1243
|
+
const params = { issueIdOrKey, startAt, maxResults: WORKLOG_PAGE_SIZE };
|
|
1244
|
+
if (startedAfter !== undefined)
|
|
1245
|
+
params.startedAfter = startedAfter;
|
|
1246
|
+
if (startedBefore !== undefined)
|
|
1247
|
+
params.startedBefore = startedBefore;
|
|
1248
|
+
const response = await client.issueWorklogs.getIssueWorklog(params);
|
|
1249
|
+
const page = (response.worklogs || []).map((w) => ({
|
|
1250
|
+
id: w.id || '',
|
|
1251
|
+
author: {
|
|
1252
|
+
accountId: w.author?.accountId || '',
|
|
1253
|
+
displayName: w.author?.displayName || 'Unknown',
|
|
1254
|
+
emailAddress: w.author?.emailAddress,
|
|
1255
|
+
},
|
|
1256
|
+
comment: convertADFToMarkdown(w.comment),
|
|
1257
|
+
created: w.created || '',
|
|
1258
|
+
updated: w.updated || '',
|
|
1259
|
+
started: w.started || '',
|
|
1260
|
+
timeSpent: w.timeSpent || '',
|
|
1261
|
+
timeSpentSeconds: w.timeSpentSeconds || 0,
|
|
1262
|
+
issueKey: issueIdOrKey,
|
|
1263
|
+
}));
|
|
1264
|
+
allWorklogs.push(...page);
|
|
1265
|
+
const totalOnServer = typeof response.total === 'number' ? response.total : page.length;
|
|
1266
|
+
if (allWorklogs.length >= totalOnServer || page.length < WORKLOG_PAGE_SIZE)
|
|
1267
|
+
break;
|
|
1268
|
+
startAt += page.length;
|
|
1269
|
+
}
|
|
1270
|
+
const filtered = authorAccountId
|
|
1271
|
+
? allWorklogs.filter(w => w.author.accountId === authorAccountId)
|
|
1272
|
+
: allWorklogs;
|
|
1273
|
+
return {
|
|
1274
|
+
issueKey: issueIdOrKey,
|
|
1275
|
+
worklogs: filtered,
|
|
1276
|
+
total: filtered.length,
|
|
1277
|
+
};
|
|
1278
|
+
}
|
|
1279
|
+
/**
|
|
1280
|
+
* Add a worklog entry to an issue
|
|
1281
|
+
*/
|
|
1282
|
+
export async function addWorklogEntry(issueIdOrKey, options) {
|
|
1283
|
+
const client = getJiraClient();
|
|
1284
|
+
const { timeSpentSeconds, comment, started, adjustEstimate, newEstimate, reduceBy } = options;
|
|
1285
|
+
const params = {
|
|
1286
|
+
issueIdOrKey,
|
|
1287
|
+
timeSpentSeconds,
|
|
1288
|
+
comment: comment ? markdownToAdf(comment) : undefined,
|
|
1289
|
+
started: started || new Date().toISOString().replace('Z', '+0000'),
|
|
1290
|
+
};
|
|
1291
|
+
if (adjustEstimate)
|
|
1292
|
+
params.adjustEstimate = adjustEstimate;
|
|
1293
|
+
if (newEstimate)
|
|
1294
|
+
params.newEstimate = newEstimate;
|
|
1295
|
+
if (reduceBy)
|
|
1296
|
+
params.reduceBy = reduceBy;
|
|
1297
|
+
const w = await client.issueWorklogs.addWorklog(params);
|
|
1298
|
+
return {
|
|
1299
|
+
id: w.id || '',
|
|
1300
|
+
author: {
|
|
1301
|
+
accountId: w.author?.accountId || '',
|
|
1302
|
+
displayName: w.author?.displayName || 'Unknown',
|
|
1303
|
+
emailAddress: w.author?.emailAddress,
|
|
1304
|
+
},
|
|
1305
|
+
comment: convertADFToMarkdown(w.comment),
|
|
1306
|
+
created: w.created || '',
|
|
1307
|
+
updated: w.updated || '',
|
|
1308
|
+
started: w.started || '',
|
|
1309
|
+
timeSpent: w.timeSpent || '',
|
|
1310
|
+
timeSpentSeconds: w.timeSpentSeconds || 0,
|
|
1311
|
+
issueKey: issueIdOrKey,
|
|
1312
|
+
};
|
|
1313
|
+
}
|
|
1314
|
+
/**
|
|
1315
|
+
* Update an existing worklog entry
|
|
1316
|
+
*/
|
|
1317
|
+
export async function updateWorklogEntry(issueIdOrKey, worklogId, options) {
|
|
1318
|
+
const client = getJiraClient();
|
|
1319
|
+
const { timeSpentSeconds, comment, started, adjustEstimate, newEstimate } = options;
|
|
1320
|
+
const params = {
|
|
1321
|
+
issueIdOrKey,
|
|
1322
|
+
id: worklogId,
|
|
1323
|
+
};
|
|
1324
|
+
if (timeSpentSeconds !== undefined)
|
|
1325
|
+
params.timeSpentSeconds = timeSpentSeconds;
|
|
1326
|
+
if (comment !== undefined)
|
|
1327
|
+
params.comment = markdownToAdf(comment);
|
|
1328
|
+
if (started !== undefined)
|
|
1329
|
+
params.started = started;
|
|
1330
|
+
if (adjustEstimate)
|
|
1331
|
+
params.adjustEstimate = adjustEstimate;
|
|
1332
|
+
if (newEstimate)
|
|
1333
|
+
params.newEstimate = newEstimate;
|
|
1334
|
+
const w = await client.issueWorklogs.updateWorklog(params);
|
|
1335
|
+
return {
|
|
1336
|
+
id: w.id || worklogId,
|
|
1337
|
+
author: {
|
|
1338
|
+
accountId: w.author?.accountId || '',
|
|
1339
|
+
displayName: w.author?.displayName || 'Unknown',
|
|
1340
|
+
emailAddress: w.author?.emailAddress,
|
|
1341
|
+
},
|
|
1342
|
+
comment: convertADFToMarkdown(w.comment),
|
|
1343
|
+
created: w.created || '',
|
|
1344
|
+
updated: w.updated || '',
|
|
1345
|
+
started: w.started || '',
|
|
1346
|
+
timeSpent: w.timeSpent || '',
|
|
1347
|
+
timeSpentSeconds: w.timeSpentSeconds || 0,
|
|
1348
|
+
issueKey: issueIdOrKey,
|
|
1349
|
+
};
|
|
1350
|
+
}
|
|
1351
|
+
/**
|
|
1352
|
+
* Delete a worklog entry
|
|
1353
|
+
*/
|
|
1354
|
+
export async function deleteWorklogEntry(issueIdOrKey, worklogId, options = {}) {
|
|
1355
|
+
const client = getJiraClient();
|
|
1356
|
+
const { adjustEstimate, newEstimate, increaseBy } = options;
|
|
1357
|
+
const params = {
|
|
1358
|
+
issueIdOrKey,
|
|
1359
|
+
id: worklogId,
|
|
1360
|
+
};
|
|
1361
|
+
if (adjustEstimate)
|
|
1362
|
+
params.adjustEstimate = adjustEstimate;
|
|
1363
|
+
if (newEstimate)
|
|
1364
|
+
params.newEstimate = newEstimate;
|
|
1365
|
+
if (increaseBy)
|
|
1366
|
+
params.increaseBy = increaseBy;
|
|
1367
|
+
await client.issueWorklogs.deleteWorklog(params);
|
|
1368
|
+
}
|
|
@@ -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
|
+
}
|
package/dist/lib/settings.js
CHANGED
|
@@ -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;
|
package/dist/lib/utils.js
CHANGED
|
@@ -152,3 +152,42 @@ export function parseTimeframe(timeframe) {
|
|
|
152
152
|
export function formatDateForJql(date) {
|
|
153
153
|
return date.toISOString().split('T')[0];
|
|
154
154
|
}
|
|
155
|
+
/**
|
|
156
|
+
* Normalize an ISO-8601 timestamp to the format Jira accepts: yyyy-MM-dd'T'HH:mm:ss.SSSZ
|
|
157
|
+
* - No colon in timezone offset, no Z suffix, milliseconds always present.
|
|
158
|
+
* - "2026-04-15T07:00:00.000Z" → "2026-04-15T07:00:00.000+0000"
|
|
159
|
+
* - "2026-04-15T10:00:00+03:00" → "2026-04-15T10:00:00.000+0300"
|
|
160
|
+
* - "2026-04-15T07:00:00Z" → "2026-04-15T07:00:00.000+0000"
|
|
161
|
+
* - "2026-04-15T07:00:00.000+0000" → unchanged
|
|
162
|
+
*/
|
|
163
|
+
export function normalizeJiraTimestamp(timestamp) {
|
|
164
|
+
// Replace Z suffix with +0000
|
|
165
|
+
let normalized = timestamp.replace(/Z$/, '+0000');
|
|
166
|
+
// Remove colon from timezone offset: +HH:MM → +HHMM or -HH:MM → -HHMM
|
|
167
|
+
normalized = normalized.replace(/([+-])(\d{2}):(\d{2})$/, '$1$2$3');
|
|
168
|
+
// Add .000 milliseconds if missing (before the timezone offset)
|
|
169
|
+
normalized = normalized.replace(/(\d{2}:\d{2}:\d{2})([+-]\d{4})$/, '$1.000$2');
|
|
170
|
+
return normalized;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Parse a Jira-style duration string into total seconds.
|
|
174
|
+
* Supports: 1w, 2d, 3h, 30m and combinations like 1d2h30m.
|
|
175
|
+
* Conversion: 1w = 5d, 1d = 8h.
|
|
176
|
+
* Returns null for invalid input.
|
|
177
|
+
*/
|
|
178
|
+
export function parseDuration(duration) {
|
|
179
|
+
const match = duration.match(/^(?:(\d+)w)?(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?$/);
|
|
180
|
+
if (!match || match[0] === '')
|
|
181
|
+
return null;
|
|
182
|
+
const weeks = parseInt(match[1] || '0', 10);
|
|
183
|
+
const days = parseInt(match[2] || '0', 10);
|
|
184
|
+
const hours = parseInt(match[3] || '0', 10);
|
|
185
|
+
const minutes = parseInt(match[4] || '0', 10);
|
|
186
|
+
const totalSeconds = weeks * 5 * 8 * 3600 +
|
|
187
|
+
days * 8 * 3600 +
|
|
188
|
+
hours * 3600 +
|
|
189
|
+
minutes * 60;
|
|
190
|
+
if (totalSeconds === 0)
|
|
191
|
+
return null;
|
|
192
|
+
return totalSeconds;
|
|
193
|
+
}
|
package/dist/lib/validation.js
CHANGED
|
@@ -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({
|
|
@@ -271,3 +277,40 @@ export const ActivityFeedSchema = z.object({
|
|
|
271
277
|
author: z.string().optional(),
|
|
272
278
|
compact: z.boolean().optional(),
|
|
273
279
|
});
|
|
280
|
+
// =============================================================================
|
|
281
|
+
// WORKLOG SCHEMAS
|
|
282
|
+
// =============================================================================
|
|
283
|
+
export const DurationSchema = z
|
|
284
|
+
.string()
|
|
285
|
+
.regex(/^(?:(\d+)w)?(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?$/, 'Duration must be in Jira format: e.g. 1h, 30m, 1d2h30m, 1w');
|
|
286
|
+
export const WorklogListSchema = z.object({
|
|
287
|
+
issueKey: z.string().trim().min(1, 'Issue key is required').pipe(IssueKeySchema),
|
|
288
|
+
startedAfter: z.number().int().positive().optional(),
|
|
289
|
+
startedBefore: z.number().int().positive().optional(),
|
|
290
|
+
authorAccountId: z.string().optional(),
|
|
291
|
+
});
|
|
292
|
+
export const WorklogAddSchema = z.object({
|
|
293
|
+
issueKey: z.string().trim().min(1, 'Issue key is required').pipe(IssueKeySchema),
|
|
294
|
+
time: DurationSchema,
|
|
295
|
+
comment: z.string().optional(),
|
|
296
|
+
started: z.string().optional(),
|
|
297
|
+
adjustEstimate: z.enum(['auto', 'new', 'leave', 'manual']).optional(),
|
|
298
|
+
newEstimate: z.string().optional(),
|
|
299
|
+
reduceBy: z.string().optional(),
|
|
300
|
+
});
|
|
301
|
+
export const WorklogUpdateSchema = z.object({
|
|
302
|
+
issueKey: z.string().trim().min(1, 'Issue key is required').pipe(IssueKeySchema),
|
|
303
|
+
id: z.string().min(1, 'Worklog ID is required'),
|
|
304
|
+
time: DurationSchema.optional(),
|
|
305
|
+
comment: z.string().optional(),
|
|
306
|
+
started: z.string().optional(),
|
|
307
|
+
adjustEstimate: z.enum(['auto', 'new', 'leave', 'manual']).optional(),
|
|
308
|
+
newEstimate: z.string().optional(),
|
|
309
|
+
});
|
|
310
|
+
export const WorklogDeleteSchema = z.object({
|
|
311
|
+
issueKey: z.string().trim().min(1, 'Issue key is required').pipe(IssueKeySchema),
|
|
312
|
+
id: z.string().min(1, 'Worklog ID is required'),
|
|
313
|
+
adjustEstimate: z.enum(['auto', 'new', 'leave', 'manual']).optional(),
|
|
314
|
+
newEstimate: z.string().optional(),
|
|
315
|
+
increaseBy: z.string().optional(),
|
|
316
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jira-ai",
|
|
3
|
-
"version": "1.
|
|
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",
|
|
@@ -48,12 +48,13 @@
|
|
|
48
48
|
"node": ">=18.0.0"
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
+
"@modelcontextprotocol/sdk": "^1.28.0",
|
|
51
52
|
"adf-to-markdown": "^1.0.1",
|
|
52
53
|
"commander": "^11.0.0",
|
|
53
54
|
"confluence.js": "^2.1.0",
|
|
54
55
|
"dotenv": "^17.2.3",
|
|
55
56
|
"html-entities": "^2.6.0",
|
|
56
|
-
"jira.js": "^5.2.2",
|
|
57
|
+
"jira.js": "^5.2.2",
|
|
57
58
|
"js-yaml": "^4.1.1",
|
|
58
59
|
"marklassian": "^1.0.0",
|
|
59
60
|
"zod": "^4.3.5"
|