jira-ai 0.2.2 → 0.2.6
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/package.json +5 -1
- package/src/cli.ts +7 -0
- package/src/commands/about.ts +5 -0
- package/src/commands/list-issue-types.ts +18 -0
- package/src/lib/formatters.ts +50 -1
- package/src/lib/jira-client.ts +28 -0
- package/tests/settings.test.ts +90 -21
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jira-ai",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.6",
|
|
4
4
|
"description": "CLI tool for interacting with Atlassian Jira",
|
|
5
5
|
"main": "dist/cli.js",
|
|
6
6
|
"bin": {
|
|
@@ -43,5 +43,9 @@
|
|
|
43
43
|
"ts-jest": "^29.4.6",
|
|
44
44
|
"ts-node": "^10.9.2",
|
|
45
45
|
"typescript": "^5.9.3"
|
|
46
|
+
},
|
|
47
|
+
"overrides": {
|
|
48
|
+
"adf-builder": "npm:@atlaskit/adf-utils@^19.26.4",
|
|
49
|
+
"uuid": "^10.0.0"
|
|
46
50
|
}
|
|
47
51
|
}
|
package/src/cli.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { meCommand } from './commands/me';
|
|
|
8
8
|
import { projectsCommand } from './commands/projects';
|
|
9
9
|
import { taskWithDetailsCommand } from './commands/task-with-details';
|
|
10
10
|
import { projectStatusesCommand } from './commands/project-statuses';
|
|
11
|
+
import { listIssueTypesCommand } from './commands/list-issue-types';
|
|
11
12
|
import { runJqlCommand } from './commands/run-jql';
|
|
12
13
|
import { updateDescriptionCommand } from './commands/update-description';
|
|
13
14
|
import { addCommentCommand } from './commands/add-comment';
|
|
@@ -78,6 +79,12 @@ program
|
|
|
78
79
|
.description('Show all possible statuses for a project')
|
|
79
80
|
.action(withPermission('project-statuses', projectStatusesCommand));
|
|
80
81
|
|
|
82
|
+
// List issue types command
|
|
83
|
+
program
|
|
84
|
+
.command('list-issue-types <project-key>')
|
|
85
|
+
.description('Show all issue types for a project')
|
|
86
|
+
.action(withPermission('list-issue-types', listIssueTypesCommand));
|
|
87
|
+
|
|
81
88
|
// Run JQL command
|
|
82
89
|
program
|
|
83
90
|
.command('run-jql <jql-query>')
|
package/src/commands/about.ts
CHANGED
|
@@ -28,6 +28,11 @@ const ALL_COMMANDS: CommandInfo[] = [
|
|
|
28
28
|
description: 'Show all possible statuses for a project',
|
|
29
29
|
usage: 'jira-ai project-statuses --help'
|
|
30
30
|
},
|
|
31
|
+
{
|
|
32
|
+
name: 'list-issue-types',
|
|
33
|
+
description: 'Show all issue types for a project (e.g., Epic, Task, Subtask)',
|
|
34
|
+
usage: 'jira-ai list-issue-types <project-key>'
|
|
35
|
+
},
|
|
31
36
|
{
|
|
32
37
|
name: 'run-jql',
|
|
33
38
|
description: 'Execute JQL query and display results',
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import ora from 'ora';
|
|
3
|
+
import { getProjectIssueTypes } from '../lib/jira-client';
|
|
4
|
+
import { formatProjectIssueTypes } from '../lib/formatters';
|
|
5
|
+
|
|
6
|
+
export async function listIssueTypesCommand(projectKey: string): Promise<void> {
|
|
7
|
+
const spinner = ora(`Fetching issue types for project ${projectKey}...`).start();
|
|
8
|
+
|
|
9
|
+
try {
|
|
10
|
+
const issueTypes = await getProjectIssueTypes(projectKey);
|
|
11
|
+
spinner.succeed(chalk.green('Issue types retrieved'));
|
|
12
|
+
console.log(formatProjectIssueTypes(projectKey, issueTypes));
|
|
13
|
+
} catch (error) {
|
|
14
|
+
spinner.fail(chalk.red('Failed to fetch issue types'));
|
|
15
|
+
console.error(chalk.red('\nError: ' + (error instanceof Error ? error.message : 'Unknown error')));
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
}
|
package/src/lib/formatters.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import Table from 'cli-table3';
|
|
3
|
-
import { UserInfo, Project, TaskDetails, Status, JqlIssue } from './jira-client';
|
|
3
|
+
import { UserInfo, Project, TaskDetails, Status, JqlIssue, IssueType } from './jira-client';
|
|
4
4
|
import { formatTimestamp, truncate } from './utils';
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -222,3 +222,52 @@ export function formatJqlResults(issues: JqlIssue[]): string {
|
|
|
222
222
|
|
|
223
223
|
return output;
|
|
224
224
|
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Format project issue types list
|
|
228
|
+
*/
|
|
229
|
+
export function formatProjectIssueTypes(projectKey: string, issueTypes: IssueType[]): string {
|
|
230
|
+
if (issueTypes.length === 0) {
|
|
231
|
+
return chalk.yellow('No issue types found for this project.');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Separate standard issue types and subtasks
|
|
235
|
+
const standardTypes = issueTypes.filter(type => !type.subtask);
|
|
236
|
+
const subtaskTypes = issueTypes.filter(type => type.subtask);
|
|
237
|
+
|
|
238
|
+
let output = '\n' + chalk.bold(`Project ${projectKey} - Issue Types (${issueTypes.length} total)`) + '\n\n';
|
|
239
|
+
|
|
240
|
+
// Display standard issue types
|
|
241
|
+
if (standardTypes.length > 0) {
|
|
242
|
+
output += chalk.bold('Standard Issue Types:') + '\n';
|
|
243
|
+
const table = createTable(['Name', 'Type', 'Description'], [20, 15, 55]);
|
|
244
|
+
|
|
245
|
+
standardTypes.forEach((issueType) => {
|
|
246
|
+
table.push([
|
|
247
|
+
chalk.cyan(issueType.name),
|
|
248
|
+
issueType.subtask ? chalk.yellow('Subtask') : chalk.green('Standard'),
|
|
249
|
+
truncate(issueType.description || chalk.gray('No description'), 55),
|
|
250
|
+
]);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
output += table.toString() + '\n';
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Display subtask types separately if they exist
|
|
257
|
+
if (subtaskTypes.length > 0) {
|
|
258
|
+
output += '\n' + chalk.bold('Subtask Types:') + '\n';
|
|
259
|
+
const subtaskTable = createTable(['Name', 'Type', 'Description'], [20, 15, 55]);
|
|
260
|
+
|
|
261
|
+
subtaskTypes.forEach((issueType) => {
|
|
262
|
+
subtaskTable.push([
|
|
263
|
+
chalk.cyan(issueType.name),
|
|
264
|
+
chalk.yellow('Subtask'),
|
|
265
|
+
truncate(issueType.description || chalk.gray('No description'), 55),
|
|
266
|
+
]);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
output += subtaskTable.toString() + '\n';
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return output;
|
|
273
|
+
}
|
package/src/lib/jira-client.ts
CHANGED
|
@@ -87,6 +87,14 @@ export interface JqlIssue {
|
|
|
87
87
|
} | null;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
export interface IssueType {
|
|
91
|
+
id: string;
|
|
92
|
+
name: string;
|
|
93
|
+
description?: string;
|
|
94
|
+
subtask: boolean;
|
|
95
|
+
hierarchyLevel: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
90
98
|
let jiraClient: Version3Client | null = null;
|
|
91
99
|
|
|
92
100
|
/**
|
|
@@ -355,3 +363,23 @@ export async function addIssueComment(
|
|
|
355
363
|
comment: adfContent,
|
|
356
364
|
});
|
|
357
365
|
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Get all issue types for a project
|
|
369
|
+
*/
|
|
370
|
+
export async function getProjectIssueTypes(projectIdOrKey: string): Promise<IssueType[]> {
|
|
371
|
+
const client = getJiraClient();
|
|
372
|
+
|
|
373
|
+
const project = await client.projects.getProject({
|
|
374
|
+
projectIdOrKey,
|
|
375
|
+
expand: 'issueTypes',
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
return project.issueTypes?.map((issueType: any) => ({
|
|
379
|
+
id: issueType.id || '',
|
|
380
|
+
name: issueType.name || '',
|
|
381
|
+
description: issueType.description,
|
|
382
|
+
subtask: issueType.subtask || false,
|
|
383
|
+
hierarchyLevel: issueType.hierarchyLevel || 0,
|
|
384
|
+
})) || [];
|
|
385
|
+
}
|
package/tests/settings.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
3
4
|
import {
|
|
4
5
|
loadSettings,
|
|
5
6
|
isProjectAllowed,
|
|
@@ -14,7 +15,9 @@ jest.mock('fs');
|
|
|
14
15
|
const mockFs = fs as jest.Mocked<typeof fs>;
|
|
15
16
|
|
|
16
17
|
describe('Settings Module', () => {
|
|
17
|
-
const
|
|
18
|
+
const mockConfigDir = path.join(os.homedir(), '.jira-ai');
|
|
19
|
+
const mockSettingsPath = path.join(mockConfigDir, 'settings.yaml');
|
|
20
|
+
const mockLocalSettingsPath = path.join(process.cwd(), 'settings.yaml');
|
|
18
21
|
|
|
19
22
|
beforeEach(() => {
|
|
20
23
|
jest.clearAllMocks();
|
|
@@ -33,28 +36,38 @@ commands:
|
|
|
33
36
|
- me
|
|
34
37
|
- projects
|
|
35
38
|
`;
|
|
36
|
-
|
|
39
|
+
// Mock that config dir exists and settings file exists
|
|
40
|
+
mockFs.existsSync.mockImplementation((path) => {
|
|
41
|
+
if (path === mockConfigDir) return true;
|
|
42
|
+
if (path === mockSettingsPath) return true;
|
|
43
|
+
return false;
|
|
44
|
+
});
|
|
37
45
|
mockFs.readFileSync.mockReturnValue(mockYaml);
|
|
38
46
|
|
|
39
47
|
const settings = loadSettings();
|
|
40
48
|
|
|
41
49
|
expect(settings.projects).toEqual(['BP', 'PM', 'PS']);
|
|
42
50
|
expect(settings.commands).toEqual(['me', 'projects']);
|
|
43
|
-
expect(mockFs.existsSync).toHaveBeenCalledWith(mockSettingsPath);
|
|
44
51
|
expect(mockFs.readFileSync).toHaveBeenCalledWith(mockSettingsPath, 'utf8');
|
|
45
52
|
});
|
|
46
53
|
|
|
47
54
|
it('should return default settings when file does not exist', () => {
|
|
55
|
+
// Mock that neither config dir nor settings files exist
|
|
48
56
|
mockFs.existsSync.mockReturnValue(false);
|
|
57
|
+
mockFs.mkdirSync.mockReturnValue(undefined);
|
|
58
|
+
mockFs.writeFileSync.mockReturnValue(undefined);
|
|
49
59
|
|
|
50
|
-
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
|
|
51
60
|
const settings = loadSettings();
|
|
52
61
|
|
|
53
62
|
expect(settings.projects).toEqual(['all']);
|
|
54
63
|
expect(settings.commands).toEqual(['all']);
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
64
|
+
// Should create the config directory
|
|
65
|
+
expect(mockFs.mkdirSync).toHaveBeenCalledWith(mockConfigDir, { recursive: true });
|
|
66
|
+
// Should create default settings file
|
|
67
|
+
expect(mockFs.writeFileSync).toHaveBeenCalledWith(
|
|
68
|
+
mockSettingsPath,
|
|
69
|
+
expect.stringContaining('projects')
|
|
70
|
+
);
|
|
58
71
|
});
|
|
59
72
|
|
|
60
73
|
it('should handle null/undefined projects/commands by defaulting to all', () => {
|
|
@@ -62,7 +75,11 @@ commands:
|
|
|
62
75
|
projects:
|
|
63
76
|
commands:
|
|
64
77
|
`;
|
|
65
|
-
mockFs.existsSync.
|
|
78
|
+
mockFs.existsSync.mockImplementation((path) => {
|
|
79
|
+
if (path === mockConfigDir) return true;
|
|
80
|
+
if (path === mockSettingsPath) return true;
|
|
81
|
+
return false;
|
|
82
|
+
});
|
|
66
83
|
mockFs.readFileSync.mockReturnValue(mockYaml);
|
|
67
84
|
|
|
68
85
|
const settings = loadSettings();
|
|
@@ -72,7 +89,11 @@ commands:
|
|
|
72
89
|
});
|
|
73
90
|
|
|
74
91
|
it('should exit process on invalid YAML', () => {
|
|
75
|
-
mockFs.existsSync.
|
|
92
|
+
mockFs.existsSync.mockImplementation((path) => {
|
|
93
|
+
if (path === mockConfigDir) return true;
|
|
94
|
+
if (path === mockSettingsPath) return true;
|
|
95
|
+
return false;
|
|
96
|
+
});
|
|
76
97
|
mockFs.readFileSync.mockReturnValue('invalid: yaml: content:');
|
|
77
98
|
|
|
78
99
|
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
|
@@ -97,7 +118,11 @@ projects:
|
|
|
97
118
|
commands:
|
|
98
119
|
- me
|
|
99
120
|
`;
|
|
100
|
-
mockFs.existsSync.
|
|
121
|
+
mockFs.existsSync.mockImplementation((path) => {
|
|
122
|
+
if (path === mockConfigDir) return true;
|
|
123
|
+
if (path === mockSettingsPath) return true;
|
|
124
|
+
return false;
|
|
125
|
+
});
|
|
101
126
|
mockFs.readFileSync.mockReturnValue(mockYaml);
|
|
102
127
|
|
|
103
128
|
expect(isProjectAllowed('BP')).toBe(true);
|
|
@@ -114,7 +139,11 @@ projects:
|
|
|
114
139
|
commands:
|
|
115
140
|
- me
|
|
116
141
|
`;
|
|
117
|
-
mockFs.existsSync.
|
|
142
|
+
mockFs.existsSync.mockImplementation((path) => {
|
|
143
|
+
if (path === mockConfigDir) return true;
|
|
144
|
+
if (path === mockSettingsPath) return true;
|
|
145
|
+
return false;
|
|
146
|
+
});
|
|
118
147
|
mockFs.readFileSync.mockReturnValue(mockYaml);
|
|
119
148
|
|
|
120
149
|
expect(isProjectAllowed('BP')).toBe(true);
|
|
@@ -131,7 +160,11 @@ projects:
|
|
|
131
160
|
commands:
|
|
132
161
|
- me
|
|
133
162
|
`;
|
|
134
|
-
mockFs.existsSync.
|
|
163
|
+
mockFs.existsSync.mockImplementation((path) => {
|
|
164
|
+
if (path === mockConfigDir) return true;
|
|
165
|
+
if (path === mockSettingsPath) return true;
|
|
166
|
+
return false;
|
|
167
|
+
});
|
|
135
168
|
mockFs.readFileSync.mockReturnValue(mockYaml);
|
|
136
169
|
|
|
137
170
|
expect(isProjectAllowed('XYZ')).toBe(false);
|
|
@@ -146,7 +179,11 @@ projects:
|
|
|
146
179
|
commands:
|
|
147
180
|
- me
|
|
148
181
|
`;
|
|
149
|
-
mockFs.existsSync.
|
|
182
|
+
mockFs.existsSync.mockImplementation((path) => {
|
|
183
|
+
if (path === mockConfigDir) return true;
|
|
184
|
+
if (path === mockSettingsPath) return true;
|
|
185
|
+
return false;
|
|
186
|
+
});
|
|
150
187
|
mockFs.readFileSync.mockReturnValue(mockYaml);
|
|
151
188
|
|
|
152
189
|
expect(isProjectAllowed('BP')).toBe(true);
|
|
@@ -163,7 +200,11 @@ projects:
|
|
|
163
200
|
commands:
|
|
164
201
|
- all
|
|
165
202
|
`;
|
|
166
|
-
mockFs.existsSync.
|
|
203
|
+
mockFs.existsSync.mockImplementation((path) => {
|
|
204
|
+
if (path === mockConfigDir) return true;
|
|
205
|
+
if (path === mockSettingsPath) return true;
|
|
206
|
+
return false;
|
|
207
|
+
});
|
|
167
208
|
mockFs.readFileSync.mockReturnValue(mockYaml);
|
|
168
209
|
|
|
169
210
|
expect(isCommandAllowed('me')).toBe(true);
|
|
@@ -179,7 +220,11 @@ commands:
|
|
|
179
220
|
- me
|
|
180
221
|
- projects
|
|
181
222
|
`;
|
|
182
|
-
mockFs.existsSync.
|
|
223
|
+
mockFs.existsSync.mockImplementation((path) => {
|
|
224
|
+
if (path === mockConfigDir) return true;
|
|
225
|
+
if (path === mockSettingsPath) return true;
|
|
226
|
+
return false;
|
|
227
|
+
});
|
|
183
228
|
mockFs.readFileSync.mockReturnValue(mockYaml);
|
|
184
229
|
|
|
185
230
|
expect(isCommandAllowed('me')).toBe(true);
|
|
@@ -194,7 +239,11 @@ commands:
|
|
|
194
239
|
- me
|
|
195
240
|
- projects
|
|
196
241
|
`;
|
|
197
|
-
mockFs.existsSync.
|
|
242
|
+
mockFs.existsSync.mockImplementation((path) => {
|
|
243
|
+
if (path === mockConfigDir) return true;
|
|
244
|
+
if (path === mockSettingsPath) return true;
|
|
245
|
+
return false;
|
|
246
|
+
});
|
|
198
247
|
mockFs.readFileSync.mockReturnValue(mockYaml);
|
|
199
248
|
|
|
200
249
|
expect(isCommandAllowed('task-with-details')).toBe(false);
|
|
@@ -212,7 +261,11 @@ projects:
|
|
|
212
261
|
commands:
|
|
213
262
|
- me
|
|
214
263
|
`;
|
|
215
|
-
mockFs.existsSync.
|
|
264
|
+
mockFs.existsSync.mockImplementation((path) => {
|
|
265
|
+
if (path === mockConfigDir) return true;
|
|
266
|
+
if (path === mockSettingsPath) return true;
|
|
267
|
+
return false;
|
|
268
|
+
});
|
|
216
269
|
mockFs.readFileSync.mockReturnValue(mockYaml);
|
|
217
270
|
|
|
218
271
|
const projects = getAllowedProjects();
|
|
@@ -226,7 +279,11 @@ projects:
|
|
|
226
279
|
commands:
|
|
227
280
|
- me
|
|
228
281
|
`;
|
|
229
|
-
mockFs.existsSync.
|
|
282
|
+
mockFs.existsSync.mockImplementation((path) => {
|
|
283
|
+
if (path === mockConfigDir) return true;
|
|
284
|
+
if (path === mockSettingsPath) return true;
|
|
285
|
+
return false;
|
|
286
|
+
});
|
|
230
287
|
mockFs.readFileSync.mockReturnValue(mockYaml);
|
|
231
288
|
|
|
232
289
|
const projects = getAllowedProjects();
|
|
@@ -243,7 +300,11 @@ commands:
|
|
|
243
300
|
- me
|
|
244
301
|
- projects
|
|
245
302
|
`;
|
|
246
|
-
mockFs.existsSync.
|
|
303
|
+
mockFs.existsSync.mockImplementation((path) => {
|
|
304
|
+
if (path === mockConfigDir) return true;
|
|
305
|
+
if (path === mockSettingsPath) return true;
|
|
306
|
+
return false;
|
|
307
|
+
});
|
|
247
308
|
mockFs.readFileSync.mockReturnValue(mockYaml);
|
|
248
309
|
|
|
249
310
|
const commands = getAllowedCommands();
|
|
@@ -257,7 +318,11 @@ projects:
|
|
|
257
318
|
commands:
|
|
258
319
|
- all
|
|
259
320
|
`;
|
|
260
|
-
mockFs.existsSync.
|
|
321
|
+
mockFs.existsSync.mockImplementation((path) => {
|
|
322
|
+
if (path === mockConfigDir) return true;
|
|
323
|
+
if (path === mockSettingsPath) return true;
|
|
324
|
+
return false;
|
|
325
|
+
});
|
|
261
326
|
mockFs.readFileSync.mockReturnValue(mockYaml);
|
|
262
327
|
|
|
263
328
|
const commands = getAllowedCommands();
|
|
@@ -273,7 +338,11 @@ projects:
|
|
|
273
338
|
commands:
|
|
274
339
|
- me
|
|
275
340
|
`;
|
|
276
|
-
mockFs.existsSync.
|
|
341
|
+
mockFs.existsSync.mockImplementation((path) => {
|
|
342
|
+
if (path === mockConfigDir) return true;
|
|
343
|
+
if (path === mockSettingsPath) return true;
|
|
344
|
+
return false;
|
|
345
|
+
});
|
|
277
346
|
mockFs.readFileSync.mockReturnValue(mockYaml);
|
|
278
347
|
|
|
279
348
|
// Call multiple times
|