jira-ai 0.4.12 → 0.4.13
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 +5 -2
- package/dist/commands/get-issue-statistics.js +2 -2
- package/dist/lib/formatters.js +130 -30
- package/dist/lib/validation.js +3 -0
- package/package.json +2 -1
package/dist/cli.js
CHANGED
|
@@ -28,7 +28,7 @@ import { hasCredentials } from './lib/auth-storage.js';
|
|
|
28
28
|
import { CliError } from './types/errors.js';
|
|
29
29
|
import { CommandError } from './lib/errors.js';
|
|
30
30
|
import { ui } from './lib/ui.js';
|
|
31
|
-
import {
|
|
31
|
+
import { CreateTaskSchema, AddCommentSchema, UpdateDescriptionSchema, RunJqlSchema, GetPersonWorklogSchema, GetIssueStatisticsSchema, validateOptions, IssueKeySchema, ProjectKeySchema, TimeframeSchema } from './lib/validation.js';
|
|
32
32
|
import { realpathSync } from 'fs';
|
|
33
33
|
// Load environment variables
|
|
34
34
|
// @ts-ignore - quiet option exists but is not in types
|
|
@@ -223,7 +223,10 @@ program
|
|
|
223
223
|
program
|
|
224
224
|
.command('get-issue-statistics <task-ids>')
|
|
225
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.')
|
|
226
|
-
.
|
|
226
|
+
.option('--full-breakdown', 'Display each status in its own column')
|
|
227
|
+
.action(withPermission('get-issue-statistics', getIssueStatisticsCommand, {
|
|
228
|
+
schema: GetIssueStatisticsSchema
|
|
229
|
+
}));
|
|
227
230
|
// Get person worklog command
|
|
228
231
|
program
|
|
229
232
|
.command('get-person-worklog <person> <timeframe>')
|
|
@@ -2,7 +2,7 @@ import chalk from 'chalk';
|
|
|
2
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
|
-
export async function getIssueStatisticsCommand(taskIds) {
|
|
5
|
+
export async function getIssueStatisticsCommand(taskIds, options = {}) {
|
|
6
6
|
const ids = taskIds.split(',').map(id => id.trim()).filter(id => id !== '');
|
|
7
7
|
if (ids.length === 0) {
|
|
8
8
|
console.error(chalk.red('Please provide at least one issue ID.'));
|
|
@@ -34,7 +34,7 @@ Failed to fetch statistics for ${id}: ${error.message}`));
|
|
|
34
34
|
}
|
|
35
35
|
if (results.length > 0) {
|
|
36
36
|
ui.succeedSpinner(chalk.green('Statistics retrieved'));
|
|
37
|
-
console.log(formatIssueStatistics(results));
|
|
37
|
+
console.log(formatIssueStatistics(results, options.fullBreakdown));
|
|
38
38
|
}
|
|
39
39
|
else {
|
|
40
40
|
ui.failSpinner('Failed to retrieve statistics or all issues were filtered out');
|
package/dist/lib/formatters.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import Table from 'cli-table3';
|
|
3
|
+
import { decode } from 'html-entities';
|
|
3
4
|
import { formatTimestamp, truncate, formatDuration } from './utils.js';
|
|
4
5
|
/**
|
|
5
6
|
* Create a styled table
|
|
@@ -47,7 +48,7 @@ export function formatProjects(projects) {
|
|
|
47
48
|
* Format task details with comments
|
|
48
49
|
*/
|
|
49
50
|
export function formatTaskDetails(task) {
|
|
50
|
-
let output = '\n' + chalk.bold.cyan(`${task.key}: ${task.summary}`) + '\n\n';
|
|
51
|
+
let output = '\n' + chalk.bold.cyan(`${task.key}: ${decode(task.summary)}`) + '\n\n';
|
|
51
52
|
// Basic info table
|
|
52
53
|
const infoTable = createTable(['Property', 'Value'], [15, 65]);
|
|
53
54
|
infoTable.push(['Status', chalk.green(task.status.name)], ['Assignee', task.assignee?.displayName || chalk.gray('Unassigned')], ['Reporter', task.reporter?.displayName || chalk.gray('N/A')], ['Created', formatTimestamp(task.created)], ['Updated', formatTimestamp(task.updated)]);
|
|
@@ -81,7 +82,7 @@ export function formatTaskDetails(task) {
|
|
|
81
82
|
const parentTable = createTable(['Key', 'Summary', 'Status'], [12, 50, 18]);
|
|
82
83
|
parentTable.push([
|
|
83
84
|
chalk.cyan(task.parent.key),
|
|
84
|
-
truncate(task.parent.summary, 50),
|
|
85
|
+
truncate(decode(task.parent.summary), 50),
|
|
85
86
|
task.parent.status.name,
|
|
86
87
|
]);
|
|
87
88
|
output += parentTable.toString() + '\n\n';
|
|
@@ -93,7 +94,7 @@ export function formatTaskDetails(task) {
|
|
|
93
94
|
task.subtasks.forEach((subtask) => {
|
|
94
95
|
subtasksTable.push([
|
|
95
96
|
chalk.cyan(subtask.key),
|
|
96
|
-
truncate(subtask.summary, 50),
|
|
97
|
+
truncate(decode(subtask.summary), 50),
|
|
97
98
|
subtask.status.name,
|
|
98
99
|
]);
|
|
99
100
|
});
|
|
@@ -103,7 +104,7 @@ export function formatTaskDetails(task) {
|
|
|
103
104
|
if (task.description) {
|
|
104
105
|
output += chalk.bold('Description:') + '\n';
|
|
105
106
|
output += chalk.dim('─'.repeat(80)) + '\n';
|
|
106
|
-
output += task.description + '\n';
|
|
107
|
+
output += decode(task.description) + '\n';
|
|
107
108
|
output += chalk.dim('─'.repeat(80)) + '\n\n';
|
|
108
109
|
}
|
|
109
110
|
// Comments
|
|
@@ -113,7 +114,7 @@ export function formatTaskDetails(task) {
|
|
|
113
114
|
output += chalk.cyan(`${index + 1}. ${comment.author.displayName}`) +
|
|
114
115
|
chalk.gray(` - ${formatTimestamp(comment.created)}`) + '\n';
|
|
115
116
|
output += chalk.dim('─'.repeat(80)) + '\n';
|
|
116
|
-
output += comment.body + '\n';
|
|
117
|
+
output += decode(comment.body) + '\n';
|
|
117
118
|
output += chalk.dim('─'.repeat(80)) + '\n\n';
|
|
118
119
|
});
|
|
119
120
|
}
|
|
@@ -221,7 +222,7 @@ export function formatJqlResults(issues) {
|
|
|
221
222
|
}
|
|
222
223
|
table.push([
|
|
223
224
|
chalk.cyan(issue.key),
|
|
224
|
-
truncate(issue.summary, 40),
|
|
225
|
+
truncate(decode(issue.summary), 40),
|
|
225
226
|
statusColor(issue.status.name),
|
|
226
227
|
issue.assignee ? truncate(issue.assignee.displayName, 20) : chalk.gray('Unassigned'),
|
|
227
228
|
typeof priorityName === 'string' ? priorityColor(priorityName) : priorityName,
|
|
@@ -277,38 +278,137 @@ export function formatProjectIssueTypes(projectKey, issueTypes) {
|
|
|
277
278
|
/**
|
|
278
279
|
* Format issue statistics
|
|
279
280
|
*/
|
|
280
|
-
export function formatIssueStatistics(statsList) {
|
|
281
|
+
export function formatIssueStatistics(statsList, fullBreakdown = false) {
|
|
281
282
|
if (statsList.length === 0) {
|
|
282
283
|
return chalk.yellow('No statistics to display.');
|
|
283
284
|
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
285
|
+
if (!fullBreakdown) {
|
|
286
|
+
const table = createTable([
|
|
287
|
+
'Key',
|
|
288
|
+
'Summary',
|
|
289
|
+
'Time Logged',
|
|
290
|
+
'Estimate',
|
|
291
|
+
'Status Breakdown'
|
|
292
|
+
], [12, 30, 15, 15, 40]);
|
|
293
|
+
statsList.forEach((stats) => {
|
|
294
|
+
// Breakdown of time spent in each unique status
|
|
295
|
+
const breakdown = Object.entries(stats.statusDurations)
|
|
296
|
+
.map(([status, seconds]) => `${status}: ${formatDuration(seconds, 24)}`)
|
|
297
|
+
.join('\n');
|
|
298
|
+
const timeSpentStr = formatDuration(stats.timeSpentSeconds, 8);
|
|
299
|
+
const estimateStr = formatDuration(stats.originalEstimateSeconds, 8);
|
|
300
|
+
// Highlight if time spent exceeds estimate
|
|
301
|
+
const timeSpentFormatted = stats.timeSpentSeconds > stats.originalEstimateSeconds && stats.originalEstimateSeconds > 0
|
|
302
|
+
? chalk.red(timeSpentStr)
|
|
303
|
+
: chalk.green(timeSpentStr);
|
|
304
|
+
table.push([
|
|
305
|
+
chalk.cyan(stats.key),
|
|
306
|
+
truncate(decode(stats.summary), 30),
|
|
307
|
+
timeSpentFormatted,
|
|
308
|
+
estimateStr,
|
|
309
|
+
breakdown
|
|
310
|
+
]);
|
|
311
|
+
});
|
|
312
|
+
let output = '\n' + chalk.bold('Issue Statistics') + '\n\n';
|
|
313
|
+
output += table.toString() + '\n';
|
|
314
|
+
return output;
|
|
315
|
+
}
|
|
316
|
+
// Full breakdown logic
|
|
317
|
+
// 1. Identify all unique statuses and their total durations
|
|
318
|
+
const statusTotals = {};
|
|
319
|
+
statsList.forEach(stats => {
|
|
320
|
+
Object.entries(stats.statusDurations).forEach(([status, duration]) => {
|
|
321
|
+
statusTotals[status] = (statusTotals[status] || 0) + duration;
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
// 2. Filter out statuses with 0 total time
|
|
325
|
+
const activeStatuses = Object.keys(statusTotals)
|
|
326
|
+
.filter(status => statusTotals[status] > 0)
|
|
327
|
+
.sort(); // Sort for consistent column order
|
|
328
|
+
const omittedStatuses = Object.keys(statusTotals)
|
|
329
|
+
.filter(status => statusTotals[status] === 0)
|
|
330
|
+
.sort();
|
|
331
|
+
// 3. Define headers and column widths
|
|
332
|
+
const baseHeaders = ['Key', 'Summary', 'Time Logged', 'Estimate'];
|
|
333
|
+
const headers = [...baseHeaders, ...activeStatuses];
|
|
334
|
+
// Calculate column widths - 12 for Key, 25 for Summary, 12 for others
|
|
335
|
+
const colWidths = [12, 25, 12, 12, ...activeStatuses.map(() => 15)];
|
|
336
|
+
const table = createTable(headers, colWidths);
|
|
337
|
+
// 4. Helper for median calculation
|
|
338
|
+
const calculateMedian = (values) => {
|
|
339
|
+
if (values.length === 0)
|
|
340
|
+
return 0;
|
|
341
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
342
|
+
const mid = Math.floor(sorted.length / 2);
|
|
343
|
+
return sorted.length % 2 !== 0
|
|
344
|
+
? sorted[mid]
|
|
345
|
+
: (sorted[mid - 1] + sorted[mid]) / 2;
|
|
346
|
+
};
|
|
347
|
+
// 5. Track values for summary rows
|
|
348
|
+
const columnValues = {
|
|
349
|
+
'Time Logged': [],
|
|
350
|
+
'Estimate': []
|
|
351
|
+
};
|
|
352
|
+
activeStatuses.forEach(status => { columnValues[status] = []; });
|
|
353
|
+
// 6. Add rows for each issue
|
|
291
354
|
statsList.forEach((stats) => {
|
|
292
|
-
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
const timeSpentStr = formatDuration(
|
|
297
|
-
const estimateStr = formatDuration(
|
|
298
|
-
|
|
299
|
-
const timeSpentFormatted = stats.timeSpentSeconds > stats.originalEstimateSeconds && stats.originalEstimateSeconds > 0
|
|
355
|
+
const timeSpentSeconds = stats.timeSpentSeconds;
|
|
356
|
+
const originalEstimateSeconds = stats.originalEstimateSeconds;
|
|
357
|
+
columnValues['Time Logged'].push(timeSpentSeconds);
|
|
358
|
+
columnValues['Estimate'].push(originalEstimateSeconds);
|
|
359
|
+
const timeSpentStr = formatDuration(timeSpentSeconds, 8);
|
|
360
|
+
const estimateStr = formatDuration(originalEstimateSeconds, 8);
|
|
361
|
+
const timeSpentFormatted = timeSpentSeconds > originalEstimateSeconds && originalEstimateSeconds > 0
|
|
300
362
|
? chalk.red(timeSpentStr)
|
|
301
363
|
: chalk.green(timeSpentStr);
|
|
302
|
-
|
|
364
|
+
const row = [
|
|
303
365
|
chalk.cyan(stats.key),
|
|
304
|
-
truncate(stats.summary,
|
|
366
|
+
truncate(decode(stats.summary), 25),
|
|
305
367
|
timeSpentFormatted,
|
|
306
|
-
estimateStr
|
|
307
|
-
|
|
308
|
-
|
|
368
|
+
estimateStr
|
|
369
|
+
];
|
|
370
|
+
activeStatuses.forEach(status => {
|
|
371
|
+
const duration = stats.statusDurations[status] || 0;
|
|
372
|
+
columnValues[status].push(duration);
|
|
373
|
+
row.push(formatDuration(duration, 8));
|
|
374
|
+
});
|
|
375
|
+
table.push(row);
|
|
309
376
|
});
|
|
310
|
-
|
|
377
|
+
// 7. Add summary rows
|
|
378
|
+
const summaryTypes = ['Total', 'Mean', 'Median'];
|
|
379
|
+
summaryTypes.forEach(type => {
|
|
380
|
+
const row = [chalk.bold(type), '', '', ''];
|
|
381
|
+
// Fill basic metrics
|
|
382
|
+
['Time Logged', 'Estimate'].forEach((col, idx) => {
|
|
383
|
+
const values = columnValues[col];
|
|
384
|
+
let val = 0;
|
|
385
|
+
if (type === 'Total')
|
|
386
|
+
val = values.reduce((a, b) => a + b, 0);
|
|
387
|
+
else if (type === 'Mean')
|
|
388
|
+
val = values.reduce((a, b) => a + b, 0) / values.length;
|
|
389
|
+
else if (type === 'Median')
|
|
390
|
+
val = calculateMedian(values);
|
|
391
|
+
row[idx + 2] = chalk.bold(formatDuration(val, 8));
|
|
392
|
+
});
|
|
393
|
+
// Fill status columns
|
|
394
|
+
activeStatuses.forEach(status => {
|
|
395
|
+
const values = columnValues[status];
|
|
396
|
+
let val = 0;
|
|
397
|
+
if (type === 'Total')
|
|
398
|
+
val = values.reduce((a, b) => a + b, 0);
|
|
399
|
+
else if (type === 'Mean')
|
|
400
|
+
val = values.reduce((a, b) => a + b, 0) / values.length;
|
|
401
|
+
else if (type === 'Median')
|
|
402
|
+
val = calculateMedian(values);
|
|
403
|
+
row.push(chalk.bold(formatDuration(val, 8)));
|
|
404
|
+
});
|
|
405
|
+
table.push(row);
|
|
406
|
+
});
|
|
407
|
+
let output = '\n' + chalk.bold('Issue Statistics (Full Breakdown)') + '\n\n';
|
|
311
408
|
output += table.toString() + '\n';
|
|
409
|
+
if (omittedStatuses.length > 0) {
|
|
410
|
+
output += chalk.gray(`Note: The following statuses were omitted because they had 0 total time: ${omittedStatuses.join(', ')}`) + '\n';
|
|
411
|
+
}
|
|
312
412
|
return output;
|
|
313
413
|
}
|
|
314
414
|
/**
|
|
@@ -349,9 +449,9 @@ export function formatWorklogs(worklogs, groupByIssue = false) {
|
|
|
349
449
|
table.push([
|
|
350
450
|
w.started.split('T')[0],
|
|
351
451
|
chalk.cyan(w.issueKey),
|
|
352
|
-
truncate(w.summary, 30),
|
|
452
|
+
truncate(decode(w.summary), 30),
|
|
353
453
|
w.timeSpent,
|
|
354
|
-
truncate(w.comment || '', 45),
|
|
454
|
+
truncate(decode(w.comment || ''), 45),
|
|
355
455
|
]);
|
|
356
456
|
});
|
|
357
457
|
const totalHours = (totalSeconds / 3600).toFixed(2);
|
package/dist/lib/validation.js
CHANGED
|
@@ -72,6 +72,9 @@ 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 GetIssueStatisticsSchema = z.object({
|
|
76
|
+
fullBreakdown: z.boolean().optional(),
|
|
77
|
+
});
|
|
75
78
|
export const ProjectFiltersSchema = z.object({
|
|
76
79
|
participated: z.object({
|
|
77
80
|
was_assignee: z.boolean().optional(),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jira-ai",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.13",
|
|
4
4
|
"description": "AI friendly Jira CLI to save context",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/cli.js",
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
"cli-table3": "^0.6.5",
|
|
53
53
|
"commander": "^11.0.0",
|
|
54
54
|
"dotenv": "^17.2.3",
|
|
55
|
+
"html-entities": "^2.6.0",
|
|
55
56
|
"inquirer": "^9.3.8",
|
|
56
57
|
"jira.js": "^5.2.2",
|
|
57
58
|
"js-yaml": "^4.1.1",
|