jira-ai 0.4.5 → 0.4.10
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/dist/cli.js +53 -20
- package/dist/commands/add-comment.js +6 -1
- package/dist/commands/add-label.js +8 -2
- package/dist/commands/create-task.js +9 -0
- package/dist/commands/delete-label.js +8 -2
- package/dist/commands/get-issue-statistics.js +16 -3
- package/dist/commands/get-person-worklog.js +3 -10
- package/dist/commands/list-colleagues.js +10 -0
- package/dist/commands/list-issue-types.js +10 -0
- package/dist/commands/project-statuses.js +21 -5
- package/dist/commands/projects.js +5 -3
- package/dist/commands/settings.js +87 -0
- package/dist/commands/task-with-details.js +24 -1
- package/dist/commands/transition.js +4 -1
- package/dist/commands/update-description.js +8 -2
- package/dist/lib/formatters.js +54 -0
- package/dist/lib/jira-client.js +41 -5
- package/dist/lib/settings.js +115 -12
- package/dist/lib/ui.js +5 -0
- package/dist/lib/validation.js +22 -0
- package/package.json +1 -1
- package/settings.yaml +5 -15
package/dist/cli.js
CHANGED
|
@@ -20,6 +20,7 @@ import { getIssueStatisticsCommand } from './commands/get-issue-statistics.js';
|
|
|
20
20
|
import { getPersonWorklogCommand } from './commands/get-person-worklog.js';
|
|
21
21
|
import { aboutCommand } from './commands/about.js';
|
|
22
22
|
import { authCommand } from './commands/auth.js';
|
|
23
|
+
import { settingsCommand } from './commands/settings.js';
|
|
23
24
|
import { listOrganizations, useOrganizationCommand, removeOrganizationCommand } from './commands/organization.js';
|
|
24
25
|
import { isCommandAllowed, getAllowedCommands } from './lib/settings.js';
|
|
25
26
|
import { setOrganizationOverride } from './lib/jira-client.js';
|
|
@@ -78,7 +79,7 @@ function withPermission(commandName, commandFn, config = {}) {
|
|
|
78
79
|
// Auth command (always allowed, skips validation)
|
|
79
80
|
program
|
|
80
81
|
.command('auth')
|
|
81
|
-
.description('Set up Jira authentication credentials')
|
|
82
|
+
.description('Set up Jira authentication credentials. Supports interactive input, raw JSON string via --from-json, or .env file via --from-file.')
|
|
82
83
|
.option('--from-json <json_string>', 'Accepts a raw JSON string with credentials')
|
|
83
84
|
.option('--from-file <path>', 'Accepts a path to a file (typically .env) with credentials')
|
|
84
85
|
.option('--alias <alias>', 'Alias for this organization')
|
|
@@ -90,34 +91,34 @@ const org = program
|
|
|
90
91
|
.description('Manage Jira organization profiles');
|
|
91
92
|
org
|
|
92
93
|
.command('list')
|
|
93
|
-
.description('
|
|
94
|
+
.description('List all saved Jira organization profiles, showing their aliases and associated host URLs.')
|
|
94
95
|
.action(() => listOrganizations());
|
|
95
96
|
org
|
|
96
97
|
.command('use <alias>')
|
|
97
|
-
.description('Switch the active organization')
|
|
98
|
+
.description('Switch the active Jira organization profile to the one specified by the alias.')
|
|
98
99
|
.action((alias) => useOrganizationCommand(alias));
|
|
99
100
|
org
|
|
100
101
|
.command('remove <alias>')
|
|
101
|
-
.description('Delete
|
|
102
|
+
.description('Delete the saved credentials and profile for the specified organization alias.')
|
|
102
103
|
.action((alias) => removeOrganizationCommand(alias));
|
|
103
104
|
org
|
|
104
105
|
.command('add <alias>')
|
|
105
|
-
.description('
|
|
106
|
+
.description('Interactive prompt to add a new Jira organization profile with the given alias.')
|
|
106
107
|
.action((alias) => authCommand({ alias }));
|
|
107
108
|
// Me command
|
|
108
109
|
program
|
|
109
110
|
.command('me')
|
|
110
|
-
.description('Show
|
|
111
|
+
.description('Show profile details for the currently authenticated user, including Jira host, display name, email, account ID, status, and time zone.')
|
|
111
112
|
.action(withPermission('me', meCommand));
|
|
112
113
|
// Projects command
|
|
113
114
|
program
|
|
114
115
|
.command('projects')
|
|
115
|
-
.description('
|
|
116
|
+
.description('List all accessible Jira projects showing their key, name, ID, type, and project lead.')
|
|
116
117
|
.action(withPermission('projects', projectsCommand));
|
|
117
118
|
// List colleagues command
|
|
118
119
|
program
|
|
119
120
|
.command('list-colleagues [project-key]')
|
|
120
|
-
.description('
|
|
121
|
+
.description('Search and list users within the organization or a specific project (if project-key is provided). Returns display name, email, and account ID.')
|
|
121
122
|
.action(withPermission('list-colleagues', listColleaguesCommand, {
|
|
122
123
|
validateArgs: (args) => {
|
|
123
124
|
if (args[0]) {
|
|
@@ -128,7 +129,7 @@ program
|
|
|
128
129
|
// Task with details command
|
|
129
130
|
program
|
|
130
131
|
.command('task-with-details <task-id>')
|
|
131
|
-
.description('
|
|
132
|
+
.description('Retrieve comprehensive issue data including key, summary, status (name, category), assignee, reporter, creation/update dates, due date, labels, parent/subtasks, description, and comments. Use --include-detailed-history to fetch a chronological log of all changes including field updates and status transitions.')
|
|
132
133
|
.option('--include-detailed-history', 'Include the full history of task actions')
|
|
133
134
|
.option('--history-limit <number>', 'Number of history entries to show (default: 50)', '50')
|
|
134
135
|
.option('--history-offset <number>', 'Number of history entries to skip (default: 0)', '0')
|
|
@@ -138,21 +139,21 @@ program
|
|
|
138
139
|
// Project statuses command
|
|
139
140
|
program
|
|
140
141
|
.command('project-statuses <project-id>')
|
|
141
|
-
.description('
|
|
142
|
+
.description('Fetch all available workflow statuses for a given project. Returns status name, ID, category (To Do, In Progress, Done), and description.')
|
|
142
143
|
.action(withPermission('project-statuses', projectStatusesCommand, {
|
|
143
144
|
validateArgs: (args) => validateOptions(ProjectKeySchema, args[0])
|
|
144
145
|
}));
|
|
145
146
|
// List issue types command
|
|
146
147
|
program
|
|
147
148
|
.command('list-issue-types <project-key>')
|
|
148
|
-
.description('
|
|
149
|
+
.description('List all issue types (Standard and Subtask) available for a project, providing their name, ID, hierarchy level, and description.')
|
|
149
150
|
.action(withPermission('list-issue-types', listIssueTypesCommand, {
|
|
150
151
|
validateArgs: (args) => validateOptions(ProjectKeySchema, args[0])
|
|
151
152
|
}));
|
|
152
153
|
// Run JQL command
|
|
153
154
|
program
|
|
154
155
|
.command('run-jql <jql-query>')
|
|
155
|
-
.description('Execute JQL
|
|
156
|
+
.description('Execute a Jira Query Language (JQL) search. Returns a list of issues with their key, summary, status, assignee, and priority. Supports limiting results via --limit (default 50).')
|
|
156
157
|
.option('-l, --limit <number>', 'Maximum number of results (default: 50)', '50')
|
|
157
158
|
.action(withPermission('run-jql', runJqlCommand, {
|
|
158
159
|
schema: RunJqlSchema,
|
|
@@ -165,7 +166,7 @@ program
|
|
|
165
166
|
// Update description command
|
|
166
167
|
program
|
|
167
168
|
.command('update-description <task-id>')
|
|
168
|
-
.description('Update task description from a Markdown file')
|
|
169
|
+
.description('Update a Jira task\'s description using content from a local Markdown file. Requires the task ID and a valid file path.')
|
|
169
170
|
.requiredOption('--from-file <path>', 'Path to Markdown file')
|
|
170
171
|
.action(withPermission('update-description', updateDescriptionCommand, {
|
|
171
172
|
schema: UpdateDescriptionSchema,
|
|
@@ -174,14 +175,14 @@ program
|
|
|
174
175
|
// Add comment command
|
|
175
176
|
program
|
|
176
177
|
.command('add-comment')
|
|
177
|
-
.description('Add a comment to a Jira issue from a Markdown file')
|
|
178
|
+
.description('Add a new comment to a Jira issue using content from a local Markdown file. Requires the issue key and a valid file path.')
|
|
178
179
|
.requiredOption('--file-path <path>', 'Path to Markdown file')
|
|
179
180
|
.requiredOption('--issue-key <key>', 'Jira issue key (e.g., PS-123)')
|
|
180
181
|
.action(withPermission('add-comment', addCommentCommand, { schema: AddCommentSchema }));
|
|
181
182
|
// Add label command
|
|
182
183
|
program
|
|
183
184
|
.command('add-label-to-issue <task-id> <labels>')
|
|
184
|
-
.description('Add one or more labels to a Jira issue
|
|
185
|
+
.description('Add one or more labels (comma-separated) to a specific Jira issue.')
|
|
185
186
|
.action(withPermission('add-label-to-issue', addLabelCommand, {
|
|
186
187
|
validateArgs: (args) => {
|
|
187
188
|
validateOptions(IssueKeySchema, args[0]);
|
|
@@ -193,7 +194,7 @@ program
|
|
|
193
194
|
// Delete label command
|
|
194
195
|
program
|
|
195
196
|
.command('delete-label-from-issue <task-id> <labels>')
|
|
196
|
-
.description('Remove one or more labels from a Jira issue
|
|
197
|
+
.description('Remove one or more labels (comma-separated) from a specific Jira issue.')
|
|
197
198
|
.action(withPermission('delete-label-from-issue', deleteLabelCommand, {
|
|
198
199
|
validateArgs: (args) => {
|
|
199
200
|
validateOptions(IssueKeySchema, args[0]);
|
|
@@ -205,7 +206,7 @@ program
|
|
|
205
206
|
// Create task command
|
|
206
207
|
program
|
|
207
208
|
.command('create-task')
|
|
208
|
-
.description('Create a new Jira issue')
|
|
209
|
+
.description('Create a new Jira issue with specified title, project key, and issue type. Optional --parent key for subtasks. Returns the key of the newly created issue.')
|
|
209
210
|
.requiredOption('--title <title>', 'Issue title/summary')
|
|
210
211
|
.requiredOption('--project <project>', 'Project key (e.g., PROJ)')
|
|
211
212
|
.requiredOption('--issue-type <type>', 'Issue type (e.g., Task, Epic, Subtask)')
|
|
@@ -214,19 +215,19 @@ program
|
|
|
214
215
|
// Transition command
|
|
215
216
|
program
|
|
216
217
|
.command('transition <task-id> <to-status>')
|
|
217
|
-
.description('
|
|
218
|
+
.description('Change the status of a Jira task. The <to-status> can be either the status name or ID.')
|
|
218
219
|
.action(withPermission('transition', transitionCommand, {
|
|
219
220
|
validateArgs: (args) => validateOptions(IssueKeySchema, args[0])
|
|
220
221
|
}));
|
|
221
222
|
// Get issue statistics command
|
|
222
223
|
program
|
|
223
224
|
.command('get-issue-statistics <task-ids>')
|
|
224
|
-
.description('
|
|
225
|
+
.description('Calculate and display time-based metrics for one or more issues (comma-separated). Returns a table containing key, summary, total time logged, original estimate, and a detailed breakdown of duration spent in each status.')
|
|
225
226
|
.action(withPermission('get-issue-statistics', getIssueStatisticsCommand));
|
|
226
227
|
// Get person worklog command
|
|
227
228
|
program
|
|
228
229
|
.command('get-person-worklog <person> <timeframe>')
|
|
229
|
-
.description('Retrieve
|
|
230
|
+
.description('Retrieve worklogs for a specific user over a timeframe (e.g., \'7d\', \'2w\'). Returns a list of entries with date, issue key, summary, time spent, and comments. Supports --group-by-issue.')
|
|
230
231
|
.option('--group-by-issue', 'Group the output by issue')
|
|
231
232
|
.action(withPermission('get-person-worklog', getPersonWorklogCommand, {
|
|
232
233
|
schema: GetPersonWorklogSchema,
|
|
@@ -239,6 +240,38 @@ program
|
|
|
239
240
|
.command('about')
|
|
240
241
|
.description('Show information about the tool')
|
|
241
242
|
.action(aboutCommand);
|
|
243
|
+
// Settings command
|
|
244
|
+
program
|
|
245
|
+
.command('settings')
|
|
246
|
+
.description('View, validate, or apply configuration settings. Use `settings` to view active config, `--validate <file>` to check a YAML file, or `--apply <file>` to update `~/.jira-ai/settings.yaml`.')
|
|
247
|
+
.option('--apply <path>', 'Validate and apply settings from a YAML file')
|
|
248
|
+
.option('--validate <path>', 'Perform schema and deep validation of a settings YAML file')
|
|
249
|
+
.addHelpText('after', `
|
|
250
|
+
Examples:
|
|
251
|
+
$ jira-ai settings
|
|
252
|
+
$ jira-ai settings --validate my-settings.yaml
|
|
253
|
+
$ jira-ai settings --apply my-settings.yaml
|
|
254
|
+
|
|
255
|
+
Settings File Structure:
|
|
256
|
+
projects:
|
|
257
|
+
- all # Allow all projects
|
|
258
|
+
- PROJ # Allow specific project by key
|
|
259
|
+
- key: PM # Project-specific configuration
|
|
260
|
+
commands: # Limit commands for this project
|
|
261
|
+
- task-with-details
|
|
262
|
+
filters:
|
|
263
|
+
participated: # Filter by user participation
|
|
264
|
+
was_assignee: true
|
|
265
|
+
was_reporter: true
|
|
266
|
+
was_commenter: true
|
|
267
|
+
is_watcher: true
|
|
268
|
+
jql: "issuetype = Bug" # Custom JQL filter
|
|
269
|
+
commands:
|
|
270
|
+
- all # Allow all commands globally
|
|
271
|
+
- me
|
|
272
|
+
- projects
|
|
273
|
+
`)
|
|
274
|
+
.action((options) => settingsCommand(options));
|
|
242
275
|
/**
|
|
243
276
|
* Configure command visibility based on auth status and settings
|
|
244
277
|
*/
|
|
@@ -2,7 +2,7 @@ import chalk from 'chalk';
|
|
|
2
2
|
import * as fs from 'fs';
|
|
3
3
|
import * as path from 'path';
|
|
4
4
|
import { markdownToAdf } from 'marklassian';
|
|
5
|
-
import { addIssueComment } from '../lib/jira-client.js';
|
|
5
|
+
import { addIssueComment, validateIssuePermissions } from '../lib/jira-client.js';
|
|
6
6
|
import { CommandError } from '../lib/errors.js';
|
|
7
7
|
import { ui } from '../lib/ui.js';
|
|
8
8
|
import { validateOptions, AddCommentSchema } from '../lib/validation.js';
|
|
@@ -36,6 +36,9 @@ export async function addCommentCommand(options) {
|
|
|
36
36
|
hints: ['Ensure the Markdown content is valid.']
|
|
37
37
|
});
|
|
38
38
|
}
|
|
39
|
+
// Check permissions and filters
|
|
40
|
+
ui.startSpinner(`Validating permissions for ${issueKey}...`);
|
|
41
|
+
await validateIssuePermissions(issueKey, 'add-comment');
|
|
39
42
|
// Add comment with spinner
|
|
40
43
|
ui.startSpinner(`Adding comment to ${issueKey}...`);
|
|
41
44
|
try {
|
|
@@ -44,6 +47,8 @@ export async function addCommentCommand(options) {
|
|
|
44
47
|
console.log(chalk.gray(`\nFile: ${absolutePath}`));
|
|
45
48
|
}
|
|
46
49
|
catch (error) {
|
|
50
|
+
if (error instanceof CommandError)
|
|
51
|
+
throw error;
|
|
47
52
|
const errorMsg = error.message?.toLowerCase() || '';
|
|
48
53
|
const hints = [];
|
|
49
54
|
if (errorMsg.includes('404')) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
-
import { addIssueLabels } from '../lib/jira-client.js';
|
|
2
|
+
import { addIssueLabels, validateIssuePermissions } from '../lib/jira-client.js';
|
|
3
3
|
import { CommandError } from '../lib/errors.js';
|
|
4
4
|
import { ui } from '../lib/ui.js';
|
|
5
5
|
import { validateOptions, IssueKeySchema } from '../lib/validation.js';
|
|
@@ -14,13 +14,19 @@ export async function addLabelCommand(taskId, labelsString) {
|
|
|
14
14
|
if (labels.length === 0) {
|
|
15
15
|
throw new CommandError('No valid labels provided');
|
|
16
16
|
}
|
|
17
|
+
// Check permissions and filters
|
|
18
|
+
ui.startSpinner(`Validating permissions for ${taskId}...`);
|
|
19
|
+
await validateIssuePermissions(taskId, 'add-label-to-issue');
|
|
17
20
|
ui.startSpinner(`Adding labels to ${taskId}...`);
|
|
18
21
|
try {
|
|
19
22
|
await addIssueLabels(taskId, labels);
|
|
20
23
|
ui.succeedSpinner(chalk.green(`Labels added successfully to ${taskId}`));
|
|
21
|
-
console.log(chalk.gray(
|
|
24
|
+
console.log(chalk.gray(`
|
|
25
|
+
Labels: ${labels.join(', ')}`));
|
|
22
26
|
}
|
|
23
27
|
catch (error) {
|
|
28
|
+
if (error instanceof CommandError)
|
|
29
|
+
throw error;
|
|
24
30
|
const errorMsg = error.message?.toLowerCase() || '';
|
|
25
31
|
const hints = [];
|
|
26
32
|
if (errorMsg.includes('404')) {
|
|
@@ -3,10 +3,19 @@ import { createIssue } from '../lib/jira-client.js';
|
|
|
3
3
|
import { CommandError } from '../lib/errors.js';
|
|
4
4
|
import { ui } from '../lib/ui.js';
|
|
5
5
|
import { validateOptions, CreateTaskSchema } from '../lib/validation.js';
|
|
6
|
+
import { isCommandAllowed, isProjectAllowed } from '../lib/settings.js';
|
|
6
7
|
export async function createTaskCommand(options) {
|
|
7
8
|
// Validate options
|
|
8
9
|
validateOptions(CreateTaskSchema, options);
|
|
9
10
|
const { title, project, issueType, parent } = options;
|
|
11
|
+
// Check if project is allowed
|
|
12
|
+
if (!isProjectAllowed(project)) {
|
|
13
|
+
throw new CommandError(`Project '${project}' is not allowed by your settings.`);
|
|
14
|
+
}
|
|
15
|
+
// Check if command is allowed for this project
|
|
16
|
+
if (!isCommandAllowed('create-task', project)) {
|
|
17
|
+
throw new CommandError(`Command 'create-task' is not allowed for project ${project}.`);
|
|
18
|
+
}
|
|
10
19
|
// Create issue with spinner
|
|
11
20
|
ui.startSpinner(`Creating ${issueType} in project ${project}...`);
|
|
12
21
|
try {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
-
import { removeIssueLabels } from '../lib/jira-client.js';
|
|
2
|
+
import { removeIssueLabels, validateIssuePermissions } from '../lib/jira-client.js';
|
|
3
3
|
import { CommandError } from '../lib/errors.js';
|
|
4
4
|
import { ui } from '../lib/ui.js';
|
|
5
5
|
import { validateOptions, IssueKeySchema } from '../lib/validation.js';
|
|
@@ -14,13 +14,19 @@ export async function deleteLabelCommand(taskId, labelsString) {
|
|
|
14
14
|
if (labels.length === 0) {
|
|
15
15
|
throw new CommandError('No valid labels provided');
|
|
16
16
|
}
|
|
17
|
+
// Check permissions and filters
|
|
18
|
+
ui.startSpinner(`Validating permissions for ${taskId}...`);
|
|
19
|
+
await validateIssuePermissions(taskId, 'delete-label-from-issue');
|
|
17
20
|
ui.startSpinner(`Removing labels from ${taskId}...`);
|
|
18
21
|
try {
|
|
19
22
|
await removeIssueLabels(taskId, labels);
|
|
20
23
|
ui.succeedSpinner(chalk.green(`Labels removed successfully from ${taskId}`));
|
|
21
|
-
console.log(chalk.gray(
|
|
24
|
+
console.log(chalk.gray(`
|
|
25
|
+
Labels: ${labels.join(', ')}`));
|
|
22
26
|
}
|
|
23
27
|
catch (error) {
|
|
28
|
+
if (error instanceof CommandError)
|
|
29
|
+
throw error;
|
|
24
30
|
const errorMsg = error.message?.toLowerCase() || '';
|
|
25
31
|
const hints = [];
|
|
26
32
|
if (errorMsg.includes('404')) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
-
import { getIssueStatistics } from '../lib/jira-client.js';
|
|
2
|
+
import { getIssueStatistics, validateIssuePermissions } from '../lib/jira-client.js';
|
|
3
3
|
import { formatIssueStatistics } from '../lib/formatters.js';
|
|
4
4
|
import { ui } from '../lib/ui.js';
|
|
5
5
|
export async function getIssueStatisticsCommand(taskIds) {
|
|
@@ -12,11 +12,24 @@ export async function getIssueStatisticsCommand(taskIds) {
|
|
|
12
12
|
const results = [];
|
|
13
13
|
for (const id of ids) {
|
|
14
14
|
try {
|
|
15
|
+
// Validate permissions for each issue
|
|
16
|
+
await validateIssuePermissions(id, 'get-issue-statistics');
|
|
15
17
|
const stats = await getIssueStatistics(id);
|
|
16
18
|
results.push(stats);
|
|
17
19
|
}
|
|
18
20
|
catch (error) {
|
|
19
|
-
|
|
21
|
+
// Skip unauthorized or not found issues, but log a message if not already handled
|
|
22
|
+
if (!(error instanceof Error))
|
|
23
|
+
continue;
|
|
24
|
+
const isPermissionError = error.message.includes('not allowed') || error.message.includes('restricted');
|
|
25
|
+
if (isPermissionError) {
|
|
26
|
+
console.warn(chalk.yellow(`
|
|
27
|
+
Skipping ${id}: ${error.message}`));
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
console.error(chalk.red(`
|
|
31
|
+
Failed to fetch statistics for ${id}: ${error.message}`));
|
|
32
|
+
}
|
|
20
33
|
}
|
|
21
34
|
}
|
|
22
35
|
if (results.length > 0) {
|
|
@@ -24,6 +37,6 @@ export async function getIssueStatisticsCommand(taskIds) {
|
|
|
24
37
|
console.log(formatIssueStatistics(results));
|
|
25
38
|
}
|
|
26
39
|
else {
|
|
27
|
-
ui.failSpinner('Failed to retrieve statistics');
|
|
40
|
+
ui.failSpinner('Failed to retrieve statistics or all issues were filtered out');
|
|
28
41
|
}
|
|
29
42
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import { ui } from '../lib/ui.js';
|
|
3
|
-
import {
|
|
3
|
+
import { searchIssuesByJql, getIssueWorklogs } from '../lib/jira-client.js';
|
|
4
4
|
import { parseTimeframe, formatDateForJql } from '../lib/utils.js';
|
|
5
5
|
import { formatWorklogs } from '../lib/formatters.js';
|
|
6
6
|
import { CommandError } from '../lib/errors.js';
|
|
@@ -13,14 +13,7 @@ export async function getPersonWorklogCommand(person, timeframe, options) {
|
|
|
13
13
|
// 1. Search for issues where the person has tracked time in the timeframe
|
|
14
14
|
// We use a broader search first to find relevant issues
|
|
15
15
|
const jql = `worklogAuthor = "${person}" AND worklogDate >= "${startJql}" AND worklogDate <= "${endJql}"`;
|
|
16
|
-
|
|
17
|
-
const client = getJiraClient();
|
|
18
|
-
const issueResponse = await client.issueSearch.searchForIssuesUsingJqlEnhancedSearch({
|
|
19
|
-
jql,
|
|
20
|
-
fields: ['summary'],
|
|
21
|
-
maxResults: 100,
|
|
22
|
-
});
|
|
23
|
-
const issues = issueResponse.issues || [];
|
|
16
|
+
const issues = await searchIssuesByJql(jql, 100);
|
|
24
17
|
if (issues.length === 0) {
|
|
25
18
|
ui.stopSpinner();
|
|
26
19
|
console.log(chalk.yellow(`
|
|
@@ -41,7 +34,7 @@ No worklogs found for ${person} between ${startJql} and ${endJql}.
|
|
|
41
34
|
filteredWorklogs.forEach(w => {
|
|
42
35
|
allWorklogs.push({
|
|
43
36
|
...w,
|
|
44
|
-
summary: issue.
|
|
37
|
+
summary: issue.summary || '',
|
|
45
38
|
});
|
|
46
39
|
});
|
|
47
40
|
}
|
|
@@ -2,7 +2,17 @@ import chalk from 'chalk';
|
|
|
2
2
|
import { getUsers } from '../lib/jira-client.js';
|
|
3
3
|
import { formatUsers } from '../lib/formatters.js';
|
|
4
4
|
import { ui } from '../lib/ui.js';
|
|
5
|
+
import { isCommandAllowed, isProjectAllowed } from '../lib/settings.js';
|
|
6
|
+
import { CommandError } from '../lib/errors.js';
|
|
5
7
|
export async function listColleaguesCommand(projectKey) {
|
|
8
|
+
if (projectKey) {
|
|
9
|
+
if (!isProjectAllowed(projectKey)) {
|
|
10
|
+
throw new CommandError(`Project '${projectKey}' is not allowed by your settings.`);
|
|
11
|
+
}
|
|
12
|
+
if (!isCommandAllowed('list-colleagues', projectKey)) {
|
|
13
|
+
throw new CommandError(`Command 'list-colleagues' is not allowed for project ${projectKey}.`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
6
16
|
const message = projectKey
|
|
7
17
|
? `Fetching colleagues for project ${projectKey}...`
|
|
8
18
|
: 'Fetching all active colleagues...';
|
|
@@ -2,7 +2,17 @@ import chalk from 'chalk';
|
|
|
2
2
|
import { getProjectIssueTypes } from '../lib/jira-client.js';
|
|
3
3
|
import { formatProjectIssueTypes } from '../lib/formatters.js';
|
|
4
4
|
import { ui } from '../lib/ui.js';
|
|
5
|
+
import { isCommandAllowed, isProjectAllowed } from '../lib/settings.js';
|
|
6
|
+
import { CommandError } from '../lib/errors.js';
|
|
5
7
|
export async function listIssueTypesCommand(projectKey) {
|
|
8
|
+
// Check if project is allowed
|
|
9
|
+
if (!isProjectAllowed(projectKey)) {
|
|
10
|
+
throw new CommandError(`Project '${projectKey}' is not allowed by your settings.`);
|
|
11
|
+
}
|
|
12
|
+
// Check if command is allowed for this project
|
|
13
|
+
if (!isCommandAllowed('list-issue-types', projectKey)) {
|
|
14
|
+
throw new CommandError(`Command 'list-issue-types' is not allowed for project ${projectKey}.`);
|
|
15
|
+
}
|
|
6
16
|
ui.startSpinner(`Fetching issue types for project ${projectKey}...`);
|
|
7
17
|
const issueTypes = await getProjectIssueTypes(projectKey);
|
|
8
18
|
ui.succeedSpinner(chalk.green('Issue types retrieved'));
|
|
@@ -2,9 +2,25 @@ import chalk from 'chalk';
|
|
|
2
2
|
import { getProjectStatuses } from '../lib/jira-client.js';
|
|
3
3
|
import { formatProjectStatuses } from '../lib/formatters.js';
|
|
4
4
|
import { ui } from '../lib/ui.js';
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
import { isCommandAllowed, isProjectAllowed } from '../lib/settings.js';
|
|
6
|
+
import { CommandError } from '../lib/errors.js';
|
|
7
|
+
export async function projectStatusesCommand(projectIdOrKey) {
|
|
8
|
+
// Check if project is allowed
|
|
9
|
+
if (!isProjectAllowed(projectIdOrKey)) {
|
|
10
|
+
throw new CommandError(`Project '${projectIdOrKey}' is not allowed by your settings.`);
|
|
11
|
+
}
|
|
12
|
+
// Check if command is allowed for this project
|
|
13
|
+
if (!isCommandAllowed('project-statuses', projectIdOrKey)) {
|
|
14
|
+
throw new CommandError(`Command 'project-statuses' is not allowed for project ${projectIdOrKey}.`);
|
|
15
|
+
}
|
|
16
|
+
ui.startSpinner(`Fetching statuses for project ${projectIdOrKey}...`);
|
|
17
|
+
try {
|
|
18
|
+
const statuses = await getProjectStatuses(projectIdOrKey);
|
|
19
|
+
ui.succeedSpinner(chalk.green('Project statuses retrieved'));
|
|
20
|
+
console.log(formatProjectStatuses(projectIdOrKey, statuses));
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
ui.failSpinner(chalk.red('Failed to fetch project statuses'));
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
10
26
|
}
|
|
@@ -6,15 +6,17 @@ import { ui } from '../lib/ui.js';
|
|
|
6
6
|
export async function projectsCommand() {
|
|
7
7
|
ui.startSpinner('Fetching projects...');
|
|
8
8
|
const allProjects = await getProjects();
|
|
9
|
-
const
|
|
9
|
+
const allowedProjects = getAllowedProjects();
|
|
10
10
|
// Filter projects based on settings
|
|
11
|
-
const
|
|
11
|
+
const hasAll = allowedProjects.some(p => p === 'all');
|
|
12
|
+
const filteredProjects = hasAll
|
|
12
13
|
? allProjects
|
|
13
14
|
: allProjects.filter(project => isProjectAllowed(project.key));
|
|
14
15
|
ui.succeedSpinner(chalk.green('Projects retrieved'));
|
|
15
16
|
if (filteredProjects.length === 0) {
|
|
16
17
|
console.log(chalk.yellow('\nNo projects match your settings configuration.'));
|
|
17
|
-
|
|
18
|
+
const displayKeys = allowedProjects.map(p => typeof p === 'string' ? p : p.key);
|
|
19
|
+
console.log(chalk.gray('Allowed projects: ' + displayKeys.join(', ')));
|
|
18
20
|
}
|
|
19
21
|
else {
|
|
20
22
|
console.log(formatProjects(filteredProjects));
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import yaml from 'js-yaml';
|
|
4
|
+
import { loadSettings, saveSettings } from '../lib/settings.js';
|
|
5
|
+
import { formatSettings } from '../lib/formatters.js';
|
|
6
|
+
import { ui } from '../lib/ui.js';
|
|
7
|
+
import { SettingsSchema } from '../lib/validation.js';
|
|
8
|
+
import { getProjects } from '../lib/jira-client.js';
|
|
9
|
+
import { CommandError } from '../lib/errors.js';
|
|
10
|
+
import { validateEnvVars } from '../lib/utils.js';
|
|
11
|
+
export async function settingsCommand(options) {
|
|
12
|
+
if (options.apply) {
|
|
13
|
+
await applySettings(options.apply);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
if (options.validate) {
|
|
17
|
+
await validateSettingsFile(options.validate);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
// Default: Show current settings
|
|
21
|
+
const settings = loadSettings();
|
|
22
|
+
console.log(formatSettings(settings));
|
|
23
|
+
}
|
|
24
|
+
async function validateSettingsFile(filePath) {
|
|
25
|
+
ui.startSpinner(`Validating ${filePath}...`);
|
|
26
|
+
if (!fs.existsSync(filePath)) {
|
|
27
|
+
ui.failSpinner(`File not found: ${filePath}`);
|
|
28
|
+
throw new CommandError(`File not found: ${filePath}`);
|
|
29
|
+
}
|
|
30
|
+
let rawSettings;
|
|
31
|
+
try {
|
|
32
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
33
|
+
rawSettings = yaml.load(content);
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
ui.failSpinner(`Error parsing YAML: ${error instanceof Error ? error.message : String(error)}`);
|
|
37
|
+
throw new CommandError(`Error parsing YAML in ${filePath}`);
|
|
38
|
+
}
|
|
39
|
+
// Schema Validation
|
|
40
|
+
const result = SettingsSchema.safeParse(rawSettings);
|
|
41
|
+
if (!result.success) {
|
|
42
|
+
ui.failSpinner('Schema validation failed');
|
|
43
|
+
const messages = result.error.issues
|
|
44
|
+
.map((err) => `${err.path.join('.')}: ${err.message}`)
|
|
45
|
+
.join('\n');
|
|
46
|
+
throw new CommandError(`Invalid settings structure:\n${messages}`);
|
|
47
|
+
}
|
|
48
|
+
const settings = result.data;
|
|
49
|
+
// Deep Validation
|
|
50
|
+
ui.updateSpinner('Performing deep validation against Jira...');
|
|
51
|
+
try {
|
|
52
|
+
validateEnvVars();
|
|
53
|
+
const projects = await getProjects();
|
|
54
|
+
const projectKeys = new Set(projects.map(p => p.key));
|
|
55
|
+
for (const p of settings.projects) {
|
|
56
|
+
const key = typeof p === 'string' ? p : p.key;
|
|
57
|
+
if (key === 'all')
|
|
58
|
+
continue;
|
|
59
|
+
if (!projectKeys.has(key)) {
|
|
60
|
+
ui.failSpinner(`Deep validation failed: Project "${key}" not found in Jira.`);
|
|
61
|
+
throw new CommandError(`Project "${key}" not found in Jira.`);
|
|
62
|
+
}
|
|
63
|
+
// If project has specific commands, we could validate them too,
|
|
64
|
+
// but they are just strings matched against command names.
|
|
65
|
+
}
|
|
66
|
+
ui.succeedSpinner(chalk.green('Settings are valid!'));
|
|
67
|
+
return settings;
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (error instanceof CommandError)
|
|
71
|
+
throw error;
|
|
72
|
+
ui.failSpinner(`Deep validation failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
73
|
+
throw new CommandError(`Failed to connect to Jira for validation: ${error instanceof Error ? error.message : String(error)}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async function applySettings(filePath) {
|
|
77
|
+
const settings = await validateSettingsFile(filePath);
|
|
78
|
+
ui.startSpinner('Applying settings...');
|
|
79
|
+
try {
|
|
80
|
+
saveSettings(settings);
|
|
81
|
+
ui.succeedSpinner(chalk.green('Settings applied successfully!'));
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
ui.failSpinner(`Error applying settings: ${error instanceof Error ? error.message : String(error)}`);
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
-
import { getTaskWithDetails } from '../lib/jira-client.js';
|
|
2
|
+
import { getTaskWithDetails, getCurrentUser } from '../lib/jira-client.js';
|
|
3
3
|
import { formatTaskDetails } from '../lib/formatters.js';
|
|
4
4
|
import { CommandError } from '../lib/errors.js';
|
|
5
5
|
import { ui } from '../lib/ui.js';
|
|
6
|
+
import { isCommandAllowed, validateIssueAgainstFilters } from '../lib/settings.js';
|
|
6
7
|
export async function taskWithDetailsCommand(taskId, options = {}) {
|
|
7
8
|
ui.startSpinner(`Fetching details for ${taskId}...`);
|
|
8
9
|
try {
|
|
@@ -11,10 +12,32 @@ export async function taskWithDetailsCommand(taskId, options = {}) {
|
|
|
11
12
|
historyLimit: options.historyLimit ? parseInt(options.historyLimit, 10) : undefined,
|
|
12
13
|
historyOffset: options.historyOffset ? parseInt(options.historyOffset, 10) : undefined,
|
|
13
14
|
});
|
|
15
|
+
const projectKey = task.key.split('-')[0];
|
|
16
|
+
// Check if command is allowed for this project
|
|
17
|
+
if (!isCommandAllowed('task-with-details', projectKey)) {
|
|
18
|
+
ui.failSpinner(chalk.red('Command not allowed for this project'));
|
|
19
|
+
throw new CommandError(`Command 'task-with-details' is not allowed for project ${projectKey}.`, {
|
|
20
|
+
hints: [
|
|
21
|
+
`Update settings.yaml to enable this command for this project.`
|
|
22
|
+
]
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
// Check granular filters
|
|
26
|
+
const currentUser = await getCurrentUser();
|
|
27
|
+
if (!validateIssueAgainstFilters(task, currentUser.accountId)) {
|
|
28
|
+
ui.failSpinner(chalk.red('Access denied by project filters'));
|
|
29
|
+
throw new CommandError(`Access to issue ${taskId} is restricted by project filters.`, {
|
|
30
|
+
hints: [
|
|
31
|
+
`This project has filters that you do not meet (e.g., participated roles).`
|
|
32
|
+
]
|
|
33
|
+
});
|
|
34
|
+
}
|
|
14
35
|
ui.succeedSpinner(chalk.green('Task details retrieved'));
|
|
15
36
|
console.log(formatTaskDetails(task));
|
|
16
37
|
}
|
|
17
38
|
catch (error) {
|
|
39
|
+
if (error instanceof CommandError)
|
|
40
|
+
throw error;
|
|
18
41
|
const errorMsg = error.message?.toLowerCase() || '';
|
|
19
42
|
const hints = [];
|
|
20
43
|
if (error.response?.status === 404 || errorMsg.includes('404')) {
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
-
import { getIssueTransitions, transitionIssue } from '../lib/jira-client.js';
|
|
2
|
+
import { getIssueTransitions, transitionIssue, validateIssuePermissions } from '../lib/jira-client.js';
|
|
3
3
|
import { CommandError } from '../lib/errors.js';
|
|
4
4
|
import { ui } from '../lib/ui.js';
|
|
5
5
|
export async function transitionCommand(taskId, toStatus) {
|
|
6
|
+
// Check permissions and filters
|
|
7
|
+
ui.startSpinner(`Validating permissions for ${taskId}...`);
|
|
8
|
+
await validateIssuePermissions(taskId, 'transition');
|
|
6
9
|
ui.startSpinner(`Fetching available transitions for ${taskId}...`);
|
|
7
10
|
try {
|
|
8
11
|
const transitions = await getIssueTransitions(taskId);
|
|
@@ -2,7 +2,7 @@ import chalk from 'chalk';
|
|
|
2
2
|
import * as fs from 'fs';
|
|
3
3
|
import * as path from 'path';
|
|
4
4
|
import { markdownToAdf } from 'marklassian';
|
|
5
|
-
import { updateIssueDescription } from '../lib/jira-client.js';
|
|
5
|
+
import { updateIssueDescription, validateIssuePermissions } from '../lib/jira-client.js';
|
|
6
6
|
import { CommandError } from '../lib/errors.js';
|
|
7
7
|
import { ui } from '../lib/ui.js';
|
|
8
8
|
import { validateOptions, UpdateDescriptionSchema, IssueKeySchema } from '../lib/validation.js';
|
|
@@ -38,14 +38,20 @@ export async function updateDescriptionCommand(taskId, options) {
|
|
|
38
38
|
hints: ['Ensure the Markdown content is valid.']
|
|
39
39
|
});
|
|
40
40
|
}
|
|
41
|
+
// Check permissions and filters
|
|
42
|
+
ui.startSpinner(`Validating permissions for ${taskId}...`);
|
|
43
|
+
await validateIssuePermissions(taskId, 'update-description');
|
|
41
44
|
// Update issue description with spinner
|
|
42
45
|
ui.startSpinner(`Updating description for ${taskId}...`);
|
|
43
46
|
try {
|
|
44
47
|
await updateIssueDescription(taskId, adfContent);
|
|
45
48
|
ui.succeedSpinner(chalk.green(`Description updated successfully for ${taskId}`));
|
|
46
|
-
console.log(chalk.gray(
|
|
49
|
+
console.log(chalk.gray(`
|
|
50
|
+
File: ${absolutePath}`));
|
|
47
51
|
}
|
|
48
52
|
catch (error) {
|
|
53
|
+
if (error instanceof CommandError)
|
|
54
|
+
throw error;
|
|
49
55
|
const errorMsg = error.message?.toLowerCase() || '';
|
|
50
56
|
const hints = [];
|
|
51
57
|
if (errorMsg.includes('404')) {
|
package/dist/lib/formatters.js
CHANGED
|
@@ -360,3 +360,57 @@ export function formatWorklogs(worklogs, groupByIssue = false) {
|
|
|
360
360
|
output += chalk.bold(`Total time tracked: ${totalHours}h`) + '\n';
|
|
361
361
|
return output;
|
|
362
362
|
}
|
|
363
|
+
/**
|
|
364
|
+
* Format settings
|
|
365
|
+
*/
|
|
366
|
+
export function formatSettings(settings) {
|
|
367
|
+
let output = '\n' + chalk.bold.cyan('Active Configuration') + '\n\n';
|
|
368
|
+
// Global Commands
|
|
369
|
+
output += chalk.bold('Global Commands:') + '\n';
|
|
370
|
+
output += ` ${settings.commands.join(', ')}\n\n`;
|
|
371
|
+
// Projects
|
|
372
|
+
output += chalk.bold(`Projects (${settings.projects.length}):`) + '\n';
|
|
373
|
+
const table = createTable(['Project', 'Commands', 'Filters'], [15, 30, 50]);
|
|
374
|
+
settings.projects.forEach((p) => {
|
|
375
|
+
let key;
|
|
376
|
+
let commands = 'global';
|
|
377
|
+
let filters = 'none';
|
|
378
|
+
if (typeof p === 'string') {
|
|
379
|
+
key = p;
|
|
380
|
+
}
|
|
381
|
+
else {
|
|
382
|
+
key = p.key;
|
|
383
|
+
if (p.commands) {
|
|
384
|
+
commands = p.commands.join(', ');
|
|
385
|
+
}
|
|
386
|
+
if (p.filters) {
|
|
387
|
+
const parts = [];
|
|
388
|
+
if (p.filters.jql) {
|
|
389
|
+
parts.push(`JQL: ${p.filters.jql}`);
|
|
390
|
+
}
|
|
391
|
+
if (p.filters.participated) {
|
|
392
|
+
const roles = [];
|
|
393
|
+
if (p.filters.participated.was_assignee)
|
|
394
|
+
roles.push('Assignee');
|
|
395
|
+
if (p.filters.participated.was_reporter)
|
|
396
|
+
roles.push('Reporter');
|
|
397
|
+
if (p.filters.participated.was_commenter)
|
|
398
|
+
roles.push('Commenter');
|
|
399
|
+
if (p.filters.participated.is_watcher)
|
|
400
|
+
roles.push('Watcher');
|
|
401
|
+
if (roles.length > 0) {
|
|
402
|
+
parts.push(`Roles: ${roles.join(', ')}`);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
filters = parts.join('\n') || 'none';
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
table.push([
|
|
409
|
+
chalk.cyan(key),
|
|
410
|
+
commands,
|
|
411
|
+
filters
|
|
412
|
+
]);
|
|
413
|
+
});
|
|
414
|
+
output += table.toString() + '\n';
|
|
415
|
+
return output;
|
|
416
|
+
}
|
package/dist/lib/jira-client.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { Version3Client } from 'jira.js';
|
|
2
2
|
import { calculateStatusStatistics, convertADFToMarkdown } from './utils.js';
|
|
3
3
|
import { loadCredentials } from './auth-storage.js';
|
|
4
|
+
import { applyGlobalFilters, isProjectAllowed, isCommandAllowed, validateIssueAgainstFilters } from './settings.js';
|
|
5
|
+
import { CommandError } from './errors.js';
|
|
4
6
|
let jiraClient = null;
|
|
5
7
|
let organizationOverride = undefined;
|
|
6
8
|
/**
|
|
@@ -125,12 +127,14 @@ export async function getTaskWithDetails(taskId, options = {}) {
|
|
|
125
127
|
'parent',
|
|
126
128
|
'subtasks',
|
|
127
129
|
'labels',
|
|
130
|
+
'watches',
|
|
128
131
|
],
|
|
129
132
|
});
|
|
130
133
|
// Extract comments
|
|
131
134
|
const comments = issue.fields.comment?.comments?.map((comment) => ({
|
|
132
135
|
id: comment.id,
|
|
133
136
|
author: {
|
|
137
|
+
accountId: comment.author?.accountId || '',
|
|
134
138
|
displayName: comment.author?.displayName || 'Unknown',
|
|
135
139
|
emailAddress: comment.author?.emailAddress,
|
|
136
140
|
},
|
|
@@ -186,6 +190,14 @@ export async function getTaskWithDetails(taskId, options = {}) {
|
|
|
186
190
|
// Apply offset and limit
|
|
187
191
|
history = history.slice(historyOffset, historyOffset + historyLimit);
|
|
188
192
|
}
|
|
193
|
+
// Extract watchers
|
|
194
|
+
const watchers = [];
|
|
195
|
+
if (issue.fields.watches?.isWatching) {
|
|
196
|
+
// If we only need to know if the current user is watching, isWatching is enough.
|
|
197
|
+
// But the requirement might mean "any of these roles".
|
|
198
|
+
// For now, if isWatching is true, we add current user's accountId (placeholder)
|
|
199
|
+
// Actually, we can fetch watchers detail if needed, but Jira's getIssue returns watches info for current user.
|
|
200
|
+
}
|
|
189
201
|
return {
|
|
190
202
|
id: issue.id || '',
|
|
191
203
|
key: issue.key || '',
|
|
@@ -196,9 +208,11 @@ export async function getTaskWithDetails(taskId, options = {}) {
|
|
|
196
208
|
category: issue.fields.status?.statusCategory?.key || 'unknown',
|
|
197
209
|
},
|
|
198
210
|
assignee: issue.fields.assignee ? {
|
|
211
|
+
accountId: issue.fields.assignee.accountId || '',
|
|
199
212
|
displayName: issue.fields.assignee.displayName || 'Unknown',
|
|
200
213
|
} : undefined,
|
|
201
214
|
reporter: issue.fields.reporter ? {
|
|
215
|
+
accountId: issue.fields.reporter.accountId || '',
|
|
202
216
|
displayName: issue.fields.reporter.displayName || 'Unknown',
|
|
203
217
|
} : undefined,
|
|
204
218
|
created: issue.fields.created || '',
|
|
@@ -209,6 +223,7 @@ export async function getTaskWithDetails(taskId, options = {}) {
|
|
|
209
223
|
parent,
|
|
210
224
|
subtasks,
|
|
211
225
|
history,
|
|
226
|
+
watchers: issue.fields.watches?.isWatching ? ['CURRENT_USER'] : [], // Simple flag for now
|
|
212
227
|
};
|
|
213
228
|
}
|
|
214
229
|
/**
|
|
@@ -245,8 +260,9 @@ export async function getProjectStatuses(projectIdOrKey) {
|
|
|
245
260
|
*/
|
|
246
261
|
export async function searchIssuesByJql(jqlQuery, maxResults) {
|
|
247
262
|
const client = getJiraClient();
|
|
263
|
+
const filteredJql = applyGlobalFilters(jqlQuery);
|
|
248
264
|
const response = await client.issueSearch.searchForIssuesUsingJqlEnhancedSearch({
|
|
249
|
-
jql:
|
|
265
|
+
jql: filteredJql,
|
|
250
266
|
maxResults,
|
|
251
267
|
fields: ['summary', 'status', 'assignee', 'priority'],
|
|
252
268
|
});
|
|
@@ -335,13 +351,33 @@ export async function createIssue(projectKey, summary, issueTypeName, parentKey)
|
|
|
335
351
|
const response = await client.issues.createIssue({
|
|
336
352
|
fields,
|
|
337
353
|
});
|
|
338
|
-
return {
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
354
|
+
return { key: response.key || '', id: response.id || '' };
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Validate that the current user has permission to perform a command on an issue
|
|
358
|
+
*/
|
|
359
|
+
export async function validateIssuePermissions(issueKey, commandName) {
|
|
360
|
+
const task = await getTaskWithDetails(issueKey);
|
|
361
|
+
const projectKey = task.key.split('-')[0];
|
|
362
|
+
if (!isProjectAllowed(projectKey)) {
|
|
363
|
+
throw new CommandError(`Project '${projectKey}' is not allowed by your settings.`);
|
|
364
|
+
}
|
|
365
|
+
if (!isCommandAllowed(commandName, projectKey)) {
|
|
366
|
+
throw new CommandError(`Command '${commandName}' is not allowed for project ${projectKey}.`, {
|
|
367
|
+
hints: [`Update settings.yaml to enable this command for this project.`]
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
const currentUser = await getCurrentUser();
|
|
371
|
+
if (!validateIssueAgainstFilters(task, currentUser.accountId)) {
|
|
372
|
+
throw new CommandError(`Access to issue ${issueKey} is restricted by project filters.`, {
|
|
373
|
+
hints: [`This project has filters that you do not meet (e.g., participated roles).`]
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
return task;
|
|
342
377
|
}
|
|
343
378
|
/**
|
|
344
379
|
* Add labels to a Jira issue
|
|
380
|
+
|
|
345
381
|
* @param taskId - The issue key (e.g., "PROJ-123")
|
|
346
382
|
* @param labels - Array of labels to add
|
|
347
383
|
*/
|
package/dist/lib/settings.js
CHANGED
|
@@ -4,6 +4,7 @@ import os from 'os';
|
|
|
4
4
|
import yaml from 'js-yaml';
|
|
5
5
|
import chalk from 'chalk';
|
|
6
6
|
import { CliError } from '../types/errors.js';
|
|
7
|
+
import { SettingsSchema } from './validation.js';
|
|
7
8
|
const CONFIG_DIR = path.join(os.homedir(), '.jira-ai');
|
|
8
9
|
const SETTINGS_FILE = path.join(CONFIG_DIR, 'settings.yaml');
|
|
9
10
|
let cachedSettings = null;
|
|
@@ -31,7 +32,7 @@ export function loadSettings() {
|
|
|
31
32
|
console.error('Error migrating settings.yaml:', error);
|
|
32
33
|
const defaultSettings = {
|
|
33
34
|
projects: ['all'],
|
|
34
|
-
commands: ['
|
|
35
|
+
commands: ['me', 'projects', 'task-with-details', 'run-jql', 'list-issue-types', 'project-statuses', 'create-task', 'list-colleagues', 'add-comment', 'add-label', 'delete-label', 'get-issue-statistics', 'get-person-worklog', 'organization', 'transition', 'update-description']
|
|
35
36
|
};
|
|
36
37
|
cachedSettings = defaultSettings;
|
|
37
38
|
return cachedSettings;
|
|
@@ -41,7 +42,7 @@ export function loadSettings() {
|
|
|
41
42
|
// Create default settings.yaml if it doesn't exist anywhere
|
|
42
43
|
const defaultSettings = {
|
|
43
44
|
projects: ['all'],
|
|
44
|
-
commands: ['
|
|
45
|
+
commands: ['me', 'projects', 'task-with-details', 'run-jql', 'list-issue-types', 'project-statuses', 'create-task', 'list-colleagues', 'add-comment', 'add-label', 'delete-label', 'get-issue-statistics', 'get-person-worklog', 'organization', 'transition', 'update-description']
|
|
45
46
|
};
|
|
46
47
|
try {
|
|
47
48
|
const yamlStr = yaml.dump(defaultSettings);
|
|
@@ -56,26 +57,77 @@ export function loadSettings() {
|
|
|
56
57
|
}
|
|
57
58
|
try {
|
|
58
59
|
const fileContents = fs.readFileSync(SETTINGS_FILE, 'utf8');
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
60
|
+
const rawSettings = yaml.load(fileContents);
|
|
61
|
+
const result = SettingsSchema.safeParse(rawSettings);
|
|
62
|
+
if (!result.success) {
|
|
63
|
+
console.warn(chalk.yellow(`Warning: ${SETTINGS_FILE} has validation errors:`));
|
|
64
|
+
result.error.issues.forEach(issue => {
|
|
65
|
+
console.warn(chalk.yellow(` - ${issue.path.join('.')}: ${issue.message}`));
|
|
66
|
+
});
|
|
67
|
+
// Fallback to raw settings or default if parsing fails completely
|
|
68
|
+
const settings = rawSettings;
|
|
69
|
+
cachedSettings = {
|
|
70
|
+
projects: settings?.projects || ['all'],
|
|
71
|
+
commands: settings?.commands || ['me', 'projects', 'task-with-details', 'run-jql', 'list-issue-types', 'project-statuses', 'create-task', 'list-colleagues', 'add-comment', 'add-label', 'delete-label', 'get-issue-statistics', 'get-person-worklog', 'organization', 'transition', 'update-description']
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
cachedSettings = result.data;
|
|
76
|
+
}
|
|
64
77
|
return cachedSettings;
|
|
65
78
|
}
|
|
66
79
|
catch (error) {
|
|
67
80
|
throw new CliError(`Error loading ${SETTINGS_FILE}: ${error instanceof Error ? error.message : String(error)}`);
|
|
68
81
|
}
|
|
69
82
|
}
|
|
83
|
+
export function saveSettings(settings) {
|
|
84
|
+
// Ensure config directory exists
|
|
85
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
86
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
const yamlStr = yaml.dump(settings);
|
|
90
|
+
fs.writeFileSync(SETTINGS_FILE, yamlStr);
|
|
91
|
+
cachedSettings = settings;
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
throw new CliError(`Error saving ${SETTINGS_FILE}: ${error instanceof Error ? error.message : String(error)}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
70
97
|
export function isProjectAllowed(projectKey) {
|
|
71
98
|
const settings = loadSettings();
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
99
|
+
const isAllowed = settings.projects.some(p => {
|
|
100
|
+
if (typeof p === 'string') {
|
|
101
|
+
return p === 'all' || p === projectKey;
|
|
102
|
+
}
|
|
103
|
+
return p.key === projectKey;
|
|
104
|
+
});
|
|
105
|
+
return isAllowed;
|
|
76
106
|
}
|
|
77
|
-
export function isCommandAllowed(commandName) {
|
|
107
|
+
export function isCommandAllowed(commandName, projectKey) {
|
|
78
108
|
const settings = loadSettings();
|
|
109
|
+
// about, auth, and settings are always allowed
|
|
110
|
+
if (['about', 'auth', 'settings'].includes(commandName)) {
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
if (projectKey) {
|
|
114
|
+
const project = settings.projects.find(p => (typeof p === 'string' ? p : p.key) === projectKey);
|
|
115
|
+
if (project && typeof project !== 'string' && project.commands) {
|
|
116
|
+
return project.commands.includes(commandName);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
// For visibility/global check: allowed if in global list OR in any project-specific list
|
|
121
|
+
const allowedGlobally = settings.commands.includes('all') || settings.commands.includes(commandName);
|
|
122
|
+
if (allowedGlobally) {
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
const allowedInAnyProject = settings.projects.some(p => typeof p !== 'string' && p.commands && p.commands.includes(commandName));
|
|
126
|
+
if (allowedInAnyProject) {
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
79
131
|
if (settings.commands.includes('all')) {
|
|
80
132
|
return true;
|
|
81
133
|
}
|
|
@@ -89,6 +141,57 @@ export function getAllowedCommands() {
|
|
|
89
141
|
const settings = loadSettings();
|
|
90
142
|
return settings.commands;
|
|
91
143
|
}
|
|
144
|
+
export function applyGlobalFilters(jql) {
|
|
145
|
+
const settings = loadSettings();
|
|
146
|
+
const allAllowed = settings.projects.some(p => p === 'all');
|
|
147
|
+
if (allAllowed) {
|
|
148
|
+
return jql;
|
|
149
|
+
}
|
|
150
|
+
const projectFilters = settings.projects.map(p => {
|
|
151
|
+
const key = typeof p === 'string' ? p : p.key;
|
|
152
|
+
const projectJql = typeof p === 'string' ? null : p.filters?.jql;
|
|
153
|
+
if (projectJql) {
|
|
154
|
+
return `(project = "${key}" AND (${projectJql}))`;
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
return `project = "${key}"`;
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
if (projectFilters.length === 0) {
|
|
161
|
+
return `project = "NONE" AND (${jql})`;
|
|
162
|
+
}
|
|
163
|
+
const combinedProjectFilter = `(${projectFilters.join(' OR ')})`;
|
|
164
|
+
return `(${combinedProjectFilter}) AND (${jql})`;
|
|
165
|
+
}
|
|
166
|
+
export function validateIssueAgainstFilters(issue, currentUserId) {
|
|
167
|
+
const settings = loadSettings();
|
|
168
|
+
const projectKey = issue.key.split('-')[0];
|
|
169
|
+
const project = settings.projects.find(p => {
|
|
170
|
+
if (typeof p === 'string')
|
|
171
|
+
return p === 'all' || p === projectKey;
|
|
172
|
+
return p.key === projectKey;
|
|
173
|
+
});
|
|
174
|
+
if (!project) {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
if (typeof project === 'string')
|
|
178
|
+
return true;
|
|
179
|
+
if (project.filters?.participated) {
|
|
180
|
+
const { participated } = project.filters;
|
|
181
|
+
let hasParticipated = false;
|
|
182
|
+
if (participated.was_assignee && issue.assignee?.accountId === currentUserId)
|
|
183
|
+
hasParticipated = true;
|
|
184
|
+
if (participated.was_reporter && issue.reporter?.accountId === currentUserId)
|
|
185
|
+
hasParticipated = true;
|
|
186
|
+
if (participated.was_commenter && issue.comments?.some((c) => c.author?.accountId === currentUserId))
|
|
187
|
+
hasParticipated = true;
|
|
188
|
+
if (participated.is_watcher && issue.watchers?.includes('CURRENT_USER'))
|
|
189
|
+
hasParticipated = true;
|
|
190
|
+
if (!hasParticipated)
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
92
195
|
// For testing purposes only
|
|
93
196
|
export function __resetCache__() {
|
|
94
197
|
cachedSettings = null;
|
package/dist/lib/ui.js
CHANGED
package/dist/lib/validation.js
CHANGED
|
@@ -72,3 +72,25 @@ export const TimeframeSchema = z.string().regex(/^\d+d$/, 'Timeframe must be in
|
|
|
72
72
|
export const GetPersonWorklogSchema = z.object({
|
|
73
73
|
groupByIssue: z.boolean().optional(),
|
|
74
74
|
});
|
|
75
|
+
export const ProjectFiltersSchema = z.object({
|
|
76
|
+
participated: z.object({
|
|
77
|
+
was_assignee: z.boolean().optional(),
|
|
78
|
+
was_reporter: z.boolean().optional(),
|
|
79
|
+
was_commenter: z.boolean().optional(),
|
|
80
|
+
is_watcher: z.boolean().optional(),
|
|
81
|
+
}).optional(),
|
|
82
|
+
jql: z.string().optional(),
|
|
83
|
+
});
|
|
84
|
+
export const ProjectConfigSchema = z.object({
|
|
85
|
+
key: z.string().trim().min(1),
|
|
86
|
+
commands: z.array(z.string()).optional(),
|
|
87
|
+
filters: ProjectFiltersSchema.optional(),
|
|
88
|
+
});
|
|
89
|
+
export const ProjectSettingSchema = z.union([
|
|
90
|
+
z.string().trim().min(1),
|
|
91
|
+
ProjectConfigSchema
|
|
92
|
+
]);
|
|
93
|
+
export const SettingsSchema = z.object({
|
|
94
|
+
projects: z.array(ProjectSettingSchema).nullish().transform(val => val || ['all']),
|
|
95
|
+
commands: z.array(z.string()).nullish().transform(val => val || ['me', 'projects', 'task-with-details', 'run-jql', 'list-issue-types', 'project-statuses', 'create-task', 'list-colleagues', 'add-comment', 'add-label', 'delete-label', 'get-issue-statistics', 'get-person-worklog', 'organization', 'transition', 'update-description']),
|
|
96
|
+
});
|
package/package.json
CHANGED
package/settings.yaml
CHANGED
|
@@ -4,23 +4,13 @@
|
|
|
4
4
|
# Examples: BP, PM, PS
|
|
5
5
|
projects:
|
|
6
6
|
- all
|
|
7
|
-
#- BP
|
|
8
|
-
#- PM
|
|
9
|
-
#- PS
|
|
10
|
-
# Uncomment below to allow all projects
|
|
11
|
-
# - all
|
|
12
7
|
|
|
13
8
|
# Commands: List of allowed commands (use "all" to allow all commands)
|
|
14
9
|
# Available commands: me, projects, task-with-details, project-statuses, list-issue-types, run-jql, update-description, add-comment, create-task, get-issue-statistics, about, transition, add-label-to-issue, delete-label-from-issue, list-colleagues, get-person-worklog
|
|
15
10
|
commands:
|
|
16
11
|
- me
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
# - project-statuses
|
|
23
|
-
# - list-issue-types
|
|
24
|
-
# - update-description
|
|
25
|
-
# - add-comment
|
|
26
|
-
# - create-task
|
|
12
|
+
- projects
|
|
13
|
+
- task-with-details
|
|
14
|
+
- run-jql
|
|
15
|
+
- list-issue-types
|
|
16
|
+
- project-statuses
|