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 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 { validateOptions, CreateTaskSchema, AddCommentSchema, UpdateDescriptionSchema, RunJqlSchema, GetPersonWorklogSchema, TimeframeSchema, IssueKeySchema, ProjectKeySchema } from './lib/validation.js';
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
- .action(withPermission('get-issue-statistics', getIssueStatisticsCommand));
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');
@@ -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
- const table = createTable([
285
- 'Key',
286
- 'Summary',
287
- 'Time Logged',
288
- 'Estimate',
289
- 'Status Breakdown'
290
- ], [12, 30, 15, 15, 40]);
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
- // Breakdown of time spent in each unique status
293
- const breakdown = Object.entries(stats.statusDurations)
294
- .map(([status, seconds]) => `${status}: ${formatDuration(seconds, 24)}`)
295
- .join('\n');
296
- const timeSpentStr = formatDuration(stats.timeSpentSeconds, 8);
297
- const estimateStr = formatDuration(stats.originalEstimateSeconds, 8);
298
- // Highlight if time spent exceeds estimate
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
- table.push([
364
+ const row = [
303
365
  chalk.cyan(stats.key),
304
- truncate(stats.summary, 30),
366
+ truncate(decode(stats.summary), 25),
305
367
  timeSpentFormatted,
306
- estimateStr,
307
- breakdown
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
- let output = '\n' + chalk.bold('Issue Statistics') + '\n\n';
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);
@@ -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.12",
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",