ticketlens 0.7.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -0
- package/bin/ticketlens.mjs +11 -1
- package/package.json +1 -1
- package/skills/jtb/SKILL.md +4 -1
- package/skills/jtb/scripts/fetch-my-tickets.mjs +43 -0
- package/skills/jtb/scripts/lib/cli.mjs +4 -0
- package/skills/jtb/scripts/lib/help.mjs +31 -0
- package/skills/jtb/scripts/lib/run-stats.mjs +122 -0
- package/skills/jtb/scripts/lib/ticket-payload.mjs +3 -0
package/README.md
CHANGED
|
@@ -355,6 +355,29 @@ Requires a Pro license. No network call — reads local snapshots only.
|
|
|
355
355
|
|
|
356
356
|
---
|
|
357
357
|
|
|
358
|
+
### Response-Time Stats
|
|
359
|
+
|
|
360
|
+
```bash
|
|
361
|
+
ticketlens stats # Personal metrics from local triage history
|
|
362
|
+
ticketlens stats --profile=acme # Metrics for a specific profile
|
|
363
|
+
ticketlens stats --days=14 # Extend lookback window [Pro, max 30]
|
|
364
|
+
ticketlens stats --format=json # JSON output for scripting
|
|
365
|
+
ticketlens stats --format=json | jq '.avgResponseHours'
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
Shows avg/median response time, clear rate (resolved within 24h), triage run count, and week-over-week trend — all computed from local `~/.ticketlens/triage-history/` snapshots. No network call.
|
|
369
|
+
|
|
370
|
+
- **Free**: last 7 days (fixed)
|
|
371
|
+
- **Pro**: `--days=N` up to 30 days
|
|
372
|
+
|
|
373
|
+
A one-line summary footer is also appended automatically to `ticketlens triage` output once you have 2 or more triage runs:
|
|
374
|
+
|
|
375
|
+
```
|
|
376
|
+
── This week: avg 3.2h response · 80% cleared within 24h (5 runs) ──
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
---
|
|
380
|
+
|
|
358
381
|
### Custom Attention Rules
|
|
359
382
|
|
|
360
383
|
Add an `attentionRules` array to any profile in `~/.ticketlens/profiles.json` to override how `ticketlens triage` scores specific tickets:
|
|
@@ -589,6 +612,12 @@ ticketlens schedule --local # Local-only cron/LaunchAgent —
|
|
|
589
612
|
# ── History ───────────────────────────────────────────────────────────────────
|
|
590
613
|
ticketlens history <TICKET-KEY> # Show urgency timeline for a ticket [Pro]
|
|
591
614
|
|
|
615
|
+
# ── Stats ──────────────────────────────────────────────────────────────────────
|
|
616
|
+
ticketlens stats # Response-time metrics from local history
|
|
617
|
+
ticketlens stats --profile=acme # Metrics for a specific profile
|
|
618
|
+
ticketlens stats --days=14 # Extend lookback window (Pro, max 30)
|
|
619
|
+
ticketlens stats --format=json # JSON output for scripting
|
|
620
|
+
|
|
592
621
|
# ── Compliance ────────────────────────────────────────────────────────────────
|
|
593
622
|
ticketlens compliance <TICKET-KEY> # Check ticket requirements against local diff [Pro/Free 3/mo]
|
|
594
623
|
ticketlens ledger # View local compliance audit ledger [Pro]
|
package/bin/ticketlens.mjs
CHANGED
|
@@ -24,8 +24,9 @@ import {
|
|
|
24
24
|
printProfilesHelp, printScheduleHelp,
|
|
25
25
|
printInitHelp, printSwitchHelp, printConfigHelp,
|
|
26
26
|
printReviewHelp, printStandupHelp, printUpdateSkillHelp,
|
|
27
|
-
printCollisionsHelp,
|
|
27
|
+
printCollisionsHelp, printStatsHelp,
|
|
28
28
|
} from '../skills/jtb/scripts/lib/help.mjs';
|
|
29
|
+
import { runStats } from '../skills/jtb/scripts/lib/run-stats.mjs';
|
|
29
30
|
import { createStyler } from '../skills/jtb/scripts/lib/ansi.mjs';
|
|
30
31
|
import { readCliToken, saveCliToken, deleteCliToken } from '../skills/jtb/scripts/lib/cli-auth.mjs';
|
|
31
32
|
import { browserLogin } from '../skills/jtb/scripts/lib/browser-login.mjs';
|
|
@@ -103,6 +104,15 @@ switch (command) {
|
|
|
103
104
|
break;
|
|
104
105
|
}
|
|
105
106
|
|
|
107
|
+
case 'stats': {
|
|
108
|
+
if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printStatsHelp(); break; }
|
|
109
|
+
runStats(cmdArgs).catch(err => {
|
|
110
|
+
process.stderr.write(`Error: ${err.message}\n`);
|
|
111
|
+
process.exitCode = 1;
|
|
112
|
+
});
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
|
|
106
116
|
case 'init':
|
|
107
117
|
if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printInitHelp(); break; }
|
|
108
118
|
runInit().catch(err => {
|
package/package.json
CHANGED
package/skills/jtb/SKILL.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- jtb-skill-version: 0.
|
|
1
|
+
<!-- jtb-skill-version: 0.8.0 -->
|
|
2
2
|
---
|
|
3
3
|
name: jtb
|
|
4
4
|
description: Fetch a Jira ticket's full context (description, comments, linked issues, code references) and assemble a structured TicketBrief for implementation planning. Use when user types /jtb, mentions a Jira ticket key, or wants to plan work from a Jira ticket.
|
|
@@ -35,6 +35,9 @@ Fetches a Jira ticket and produces a structured brief with code references, then
|
|
|
35
35
|
/jtb triage --push # push snapshot + git branches to Console (Team)
|
|
36
36
|
/jtb triage --share # generate 24h share URL (Team)
|
|
37
37
|
/jtb history PROD-1234 # show urgency timeline for a ticket (Pro)
|
|
38
|
+
/jtb stats # personal response-time metrics from local history
|
|
39
|
+
/jtb stats --days=14 # extend lookback window (Pro, max 30)
|
|
40
|
+
/jtb stats --format=json # JSON output for scripting
|
|
38
41
|
/jtb collisions # show branch collisions with teammates (Team)
|
|
39
42
|
/jtb collisions --json # machine-readable output
|
|
40
43
|
```
|
|
@@ -529,6 +529,49 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
529
529
|
|
|
530
530
|
const printFn = opts.print ?? ((s) => process.stdout.write(s));
|
|
531
531
|
printFn(summary + '\n');
|
|
532
|
+
|
|
533
|
+
// Inline stats footer — shown when ≥2 triage runs exist (non-fatal)
|
|
534
|
+
try {
|
|
535
|
+
const metricsInjector = opts.metricsInjector;
|
|
536
|
+
let metrics;
|
|
537
|
+
if (metricsInjector) {
|
|
538
|
+
metrics = metricsInjector();
|
|
539
|
+
} else {
|
|
540
|
+
const { computeResponseMetrics } = await import('./lib/triage-history.mjs');
|
|
541
|
+
metrics = computeResponseMetrics(profileName ?? 'default', { days: 7, configDir });
|
|
542
|
+
}
|
|
543
|
+
// Only show footer when there's a meaningful metric to display
|
|
544
|
+
const hasAvg = metrics && metrics.avgResponseHours !== null;
|
|
545
|
+
const hasClearRate = metrics && metrics.clearRate !== null;
|
|
546
|
+
if (metrics && metrics.triageRunCount >= 2 && (hasAvg || hasClearRate)) {
|
|
547
|
+
const usePlain = args.includes('--plain') || !process.stdout.isTTY;
|
|
548
|
+
const runs = metrics.triageRunCount;
|
|
549
|
+
|
|
550
|
+
let part;
|
|
551
|
+
if (hasAvg && hasClearRate) {
|
|
552
|
+
part = `avg ${metrics.avgResponseHours.toFixed(1)}h response · ${Math.round(metrics.clearRate * 100)}% cleared within 24h (${runs} runs)`;
|
|
553
|
+
} else if (hasAvg) {
|
|
554
|
+
part = `avg ${metrics.avgResponseHours.toFixed(1)}h response (${runs} runs)`;
|
|
555
|
+
} else {
|
|
556
|
+
part = `${Math.round(metrics.clearRate * 100)}% cleared within 24h (${runs} runs)`;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
if (usePlain) {
|
|
560
|
+
printFn(`── This week: ${part} ──\n`);
|
|
561
|
+
} else {
|
|
562
|
+
const { dim, bold: boldFn, cyan } = await import('./lib/ansi.mjs');
|
|
563
|
+
let styledPart;
|
|
564
|
+
if (hasAvg && hasClearRate) {
|
|
565
|
+
styledPart = `avg ${boldFn(cyan(metrics.avgResponseHours.toFixed(1) + 'h'))} response · ${boldFn(Math.round(metrics.clearRate * 100) + '%')} cleared within 24h (${runs} runs)`;
|
|
566
|
+
} else if (hasAvg) {
|
|
567
|
+
styledPart = `avg ${boldFn(cyan(metrics.avgResponseHours.toFixed(1) + 'h'))} response (${runs} runs)`;
|
|
568
|
+
} else {
|
|
569
|
+
styledPart = `${boldFn(Math.round(metrics.clearRate * 100) + '%')} cleared within 24h (${runs} runs)`;
|
|
570
|
+
}
|
|
571
|
+
printFn(`${dim('──')} This week: ${styledPart} ${dim('──')}\n`);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
} catch { /* non-fatal */ }
|
|
532
575
|
}
|
|
533
576
|
|
|
534
577
|
// Run if invoked directly
|
|
@@ -114,6 +114,10 @@ export function parseCommand(args) {
|
|
|
114
114
|
return { command: 'history', args: args.slice(1) };
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
if (first === 'stats') {
|
|
118
|
+
return { command: 'stats', args: args.slice(1) };
|
|
119
|
+
}
|
|
120
|
+
|
|
117
121
|
// Anything that looks like a ticket key or any non-flag arg → fetch
|
|
118
122
|
return { command: 'fetch', args };
|
|
119
123
|
}
|
|
@@ -43,6 +43,7 @@ export function printHelp({ stream = process.stdout } = {}) {
|
|
|
43
43
|
` ${s.brand('ticketlens')} standup ${s.dim('[--since=N]')} Standup summary from git log ${s.dim('(last 24h by default)')}`,
|
|
44
44
|
` ${s.brand('ticketlens')} compliance ${s.dim('<TICKET-KEY>')} Check requirements coverage ${s.dim('[Pro/Free 3/mo]')}`,
|
|
45
45
|
` ${s.brand('ticketlens')} history ${s.dim('<TICKET-KEY>')} Urgency timeline for a ticket ${s.dim('[Pro]')}`,
|
|
46
|
+
` ${s.brand('ticketlens')} stats ${s.dim('[options]')} Personal response-time metrics from local history`,
|
|
46
47
|
'',
|
|
47
48
|
` ${s.brand('ticketlens')} delete ${s.dim('<PROFILE-NAME>')} Remove a profile`,
|
|
48
49
|
` ${s.brand('ticketlens')} activate ${s.dim('<KEY>')} Activate a license key`,
|
|
@@ -688,6 +689,36 @@ export function printCollisionsHelp({ stream = process.stdout } = {}) {
|
|
|
688
689
|
stream.write(lines.join('\n') + '\n');
|
|
689
690
|
}
|
|
690
691
|
|
|
692
|
+
export function printStatsHelp({ stream = process.stdout } = {}) {
|
|
693
|
+
const s = createStyler({ isTTY: stream.isTTY });
|
|
694
|
+
const lines = [
|
|
695
|
+
'',
|
|
696
|
+
` ${s.bold(s.brand('ticketlens'))} ${s.bold('stats')} ${s.dim('[--days=N] [--format=plain|json] [--profile=NAME]')}`,
|
|
697
|
+
'',
|
|
698
|
+
` Show response-time and triage-cadence metrics from your local triage history.`,
|
|
699
|
+
` Reads daily snapshots captured by ${s.brand('ticketlens triage --push')} or the CLI.`,
|
|
700
|
+
'',
|
|
701
|
+
` ${s.bold('OPTIONS')}`,
|
|
702
|
+
'',
|
|
703
|
+
// visible widths: "--days=N"=8, "--format=plain"=14, "--format=json"=13,
|
|
704
|
+
// "--profile=NAME"=14, "-h, --help"=10 → target col = 19
|
|
705
|
+
` ${s.brand('--days')}=${s.dim('N')} Lookback window in days ${s.dim('(default: 7, Free max: 7, Pro max: 30)')}`,
|
|
706
|
+
` ${s.brand('--format')}=${s.dim('plain')} Human-readable table ${s.dim('(default)')}`,
|
|
707
|
+
` ${s.brand('--format')}=${s.dim('json')} JSON output for scripting/piping`,
|
|
708
|
+
` ${s.brand('--profile')}=${s.dim('NAME')} Use a specific tracker profile`,
|
|
709
|
+
` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
|
|
710
|
+
'',
|
|
711
|
+
` ${s.bold('EXAMPLES')}`,
|
|
712
|
+
'',
|
|
713
|
+
` ${s.dim('$')} ticketlens stats`,
|
|
714
|
+
` ${s.dim('$')} ticketlens stats --days=14 ${s.dim('# Pro only')}`,
|
|
715
|
+
` ${s.dim('$')} ticketlens stats --format=json | jq .`,
|
|
716
|
+
` ${s.dim('$')} ticketlens stats --profile=myteam`,
|
|
717
|
+
'',
|
|
718
|
+
];
|
|
719
|
+
stream.write(lines.join('\n') + '\n');
|
|
720
|
+
}
|
|
721
|
+
|
|
691
722
|
export function printStandupHelp({ stream = process.stdout } = {}) {
|
|
692
723
|
const s = createStyler({ isTTY: stream.isTTY });
|
|
693
724
|
const lines = [
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { computeResponseMetrics, DEFAULT_CONFIG_DIR } from './triage-history.mjs';
|
|
2
|
+
import { loadProfiles } from './profile-resolver.mjs';
|
|
3
|
+
import { isLicensed as defaultIsLicensed } from './license.mjs';
|
|
4
|
+
import { createStyler } from './ansi.mjs';
|
|
5
|
+
import { handleUnknownFlags } from './arg-validator.mjs';
|
|
6
|
+
import { printStatsHelp } from './help.mjs';
|
|
7
|
+
|
|
8
|
+
const FREE_DAYS_CAP = 7;
|
|
9
|
+
const MAX_DAYS = 30;
|
|
10
|
+
|
|
11
|
+
export async function runStats(args = [], opts = {}) {
|
|
12
|
+
const print = opts.print ?? ((s) => process.stdout.write(s));
|
|
13
|
+
const warn = opts.warn ?? ((s) => process.stderr.write(s));
|
|
14
|
+
const configDir = opts.configDir ?? DEFAULT_CONFIG_DIR;
|
|
15
|
+
const isLic = opts.isLicensed ?? defaultIsLicensed;
|
|
16
|
+
const calcMetrics = opts.metricsCalculator ?? computeResponseMetrics;
|
|
17
|
+
|
|
18
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
19
|
+
printStatsHelp();
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const validated = await handleUnknownFlags(
|
|
24
|
+
args,
|
|
25
|
+
['--help', '-h', '--profile=', '--days=', '--format='],
|
|
26
|
+
{ hints: [] },
|
|
27
|
+
);
|
|
28
|
+
if (validated === null) { process.exitCode = 1; return; }
|
|
29
|
+
|
|
30
|
+
const profileArg = args.find(a => a.startsWith('--profile='));
|
|
31
|
+
const daysArg = args.find(a => a.startsWith('--days='));
|
|
32
|
+
const formatArg = args.find(a => a.startsWith('--format='));
|
|
33
|
+
|
|
34
|
+
const format = formatArg ? formatArg.split('=')[1] : 'plain';
|
|
35
|
+
if (format !== 'plain' && format !== 'json') {
|
|
36
|
+
warn(`Error: --format must be plain or json, got: ${format}\n`);
|
|
37
|
+
process.exitCode = 1;
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const rawDays = daysArg ? daysArg.split('=')[1] : '7';
|
|
42
|
+
const parsedDays = parseInt(rawDays, 10);
|
|
43
|
+
if (isNaN(parsedDays) || parsedDays < 1 || parsedDays > MAX_DAYS) {
|
|
44
|
+
warn(`Error: --days must be between 1 and ${MAX_DAYS}, got: ${rawDays}\n`);
|
|
45
|
+
process.exitCode = 1;
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Free tier: silently cap at 7 days
|
|
50
|
+
const isPro = isLic('pro', configDir);
|
|
51
|
+
const days = (!isPro && parsedDays > FREE_DAYS_CAP) ? FREE_DAYS_CAP : parsedDays;
|
|
52
|
+
|
|
53
|
+
// Resolve profile name
|
|
54
|
+
let profile = profileArg ? profileArg.split('=')[1] : null;
|
|
55
|
+
if (!profile) {
|
|
56
|
+
const config = loadProfiles(configDir);
|
|
57
|
+
profile = config?.default ?? 'default';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let metrics;
|
|
61
|
+
try {
|
|
62
|
+
metrics = calcMetrics(profile, { days, configDir });
|
|
63
|
+
} catch (err) {
|
|
64
|
+
warn(`Error: Could not compute stats for profile "${profile}": ${err.message}\n`);
|
|
65
|
+
process.exitCode = 1;
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (format === 'json') {
|
|
70
|
+
print(JSON.stringify({ profile, ...metrics }, null, 2) + '\n');
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const isTTY = process.stdout.isTTY;
|
|
75
|
+
const s = createStyler({ isTTY });
|
|
76
|
+
|
|
77
|
+
if (metrics.triageRunCount === 0) {
|
|
78
|
+
print(`\n ${s.dim('No triage history found for profile')} ${s.bold(profile)}.\n`);
|
|
79
|
+
print(` ${s.dim('Run')} ${s.cyan('ticketlens triage')} ${s.dim('at least twice to generate stats.')}\n\n`);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const fmtH = (h) => h != null ? `${h.toFixed(1)}h` : '—';
|
|
84
|
+
const fmtPct = (r) => r != null ? `${Math.round(r * 100)}%` : '—';
|
|
85
|
+
const fmtTrend = (t) => {
|
|
86
|
+
if (t == null) return '';
|
|
87
|
+
const abs = Math.abs(t).toFixed(1);
|
|
88
|
+
return t > 0
|
|
89
|
+
? s.dim(` ↑ +${abs}h vs prior week`)
|
|
90
|
+
: s.dim(` ↓ -${abs}h vs prior week`);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const W = 52;
|
|
94
|
+
const bar = s.dim('─'.repeat(W));
|
|
95
|
+
const dayLabel = `last ${days} day${days === 1 ? '' : 's'}`;
|
|
96
|
+
|
|
97
|
+
print('\n');
|
|
98
|
+
print(`${bar}\n`);
|
|
99
|
+
print(` ${s.bold('Response Metrics')} ${s.dim(`(${dayLabel})`)}\n`);
|
|
100
|
+
print(`${bar}\n`);
|
|
101
|
+
print(` Avg response time ${s.bold(s.cyan(fmtH(metrics.avgResponseHours)))}${fmtTrend(metrics.trendHours)}\n`);
|
|
102
|
+
print(` Median response time ${s.bold(fmtH(metrics.medianResponseHours))}\n`);
|
|
103
|
+
print(` Clear rate ${s.bold(fmtPct(metrics.clearRate))} ${s.dim('(resolved within 24h)')}\n`);
|
|
104
|
+
print(` Triage runs ${s.bold(String(metrics.triageRunCount))} ${s.dim(`of ${days} days`)}\n`);
|
|
105
|
+
|
|
106
|
+
if (metrics.currentUrgency) {
|
|
107
|
+
const u = metrics.currentUrgency;
|
|
108
|
+
print(`${bar}\n`);
|
|
109
|
+
print(` ${s.bold('Right now')}\n`);
|
|
110
|
+
print(`${bar}\n`);
|
|
111
|
+
if (u.needsResponse > 0) print(` ${s.red('needs-response')} ${s.bold(String(u.needsResponse))}\n`);
|
|
112
|
+
if (u.aging > 0) print(` ${s.yellow('aging')} ${s.bold(String(u.aging))}\n`);
|
|
113
|
+
print(` ${s.green('clear')} ${s.bold(String(u.clear))}\n`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (!isPro) {
|
|
117
|
+
print(`${bar}\n`);
|
|
118
|
+
print(` ${s.dim('💡 Pro unlocks --days up to 30 days · $9/mo →')} ${s.cyan('ticketlens.dev')}\n`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
print('\n');
|
|
122
|
+
}
|
|
@@ -11,6 +11,9 @@ export function buildTicketPayload(scored, rawMap, baseUrl) {
|
|
|
11
11
|
assignee: raw?.assignee ?? null,
|
|
12
12
|
attention_score: null,
|
|
13
13
|
flags: scored.urgency === 'clear' ? [] : [scored.urgency],
|
|
14
|
+
// last_comment_at feeds server-side response-time metrics (F19c).
|
|
15
|
+
// null for custom-rule overrides (urgency forced without a real comment).
|
|
16
|
+
last_comment_at: scored.lastComment?.created ?? null,
|
|
14
17
|
compliance_coverage: null,
|
|
15
18
|
compliance_status: 'unknown',
|
|
16
19
|
url: baseUrl ? `${baseUrl}/browse/${scored.ticketKey}` : null,
|