ticketlens 0.6.0 → 0.8.0
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 +39 -0
- package/bin/ticketlens.mjs +11 -1
- package/package.json +1 -1
- package/skills/jtb/SKILL.md +7 -1
- package/skills/jtb/scripts/fetch-my-tickets.mjs +71 -14
- package/skills/jtb/scripts/lib/cli.mjs +4 -0
- package/skills/jtb/scripts/lib/help.mjs +39 -0
- package/skills/jtb/scripts/lib/run-stats.mjs +122 -0
- package/skills/jtb/scripts/lib/triage-history.mjs +152 -6
package/README.md
CHANGED
|
@@ -133,6 +133,9 @@ ticketlens triage --stale=3 # Aging threshold: 3 days (defaul
|
|
|
133
133
|
ticketlens triage --status="Code Review,QA" # Override statuses to scan
|
|
134
134
|
ticketlens triage --assignee="Jane Dev" # Another dev's tickets [Team]
|
|
135
135
|
ticketlens triage --sprint="Sprint 12" # Filter by sprint [Team]
|
|
136
|
+
ticketlens triage --project=MYPROJ # Scope to a Jira project key [Team]
|
|
137
|
+
ticketlens triage --label=Bug,P1 # Filter by label(s) [Team]
|
|
138
|
+
ticketlens triage --priority=High # Filter by priority level [Team]
|
|
136
139
|
ticketlens triage --export=csv # Export results to CSV [Team]
|
|
137
140
|
ticketlens triage --export=json # Export results to JSON [Team]
|
|
138
141
|
ticketlens triage --push # Push snapshot to Console queue [Team]
|
|
@@ -352,6 +355,29 @@ Requires a Pro license. No network call — reads local snapshots only.
|
|
|
352
355
|
|
|
353
356
|
---
|
|
354
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
|
+
|
|
355
381
|
### Custom Attention Rules
|
|
356
382
|
|
|
357
383
|
Add an `attentionRules` array to any profile in `~/.ticketlens/profiles.json` to override how `ticketlens triage` scores specific tickets:
|
|
@@ -519,6 +545,10 @@ ticketlens triage --static # Static table output (no interacti
|
|
|
519
545
|
ticketlens triage --plain # Plain markdown — pipe to LLM or file
|
|
520
546
|
ticketlens triage --assignee="Jane Dev" # View another dev's tickets [Team]
|
|
521
547
|
ticketlens triage --sprint="Sprint 12" # Filter by sprint name [Team]
|
|
548
|
+
ticketlens triage --project=MYPROJ # Scope to a Jira project key [Team]
|
|
549
|
+
ticketlens triage --label=Bug,P1 # Filter by label(s) [Team]
|
|
550
|
+
ticketlens triage --priority=High # Filter by priority level [Team]
|
|
551
|
+
ticketlens triage --project=MYPROJ --label=Bug --priority=High # Combined [Team]
|
|
522
552
|
ticketlens triage --assignee="Jane Dev" --sprint="Sprint 12" # Combined [Team]
|
|
523
553
|
ticketlens triage --export=csv # Export to CSV [Team]
|
|
524
554
|
ticketlens triage --export=json # Export to JSON [Team]
|
|
@@ -582,6 +612,12 @@ ticketlens schedule --local # Local-only cron/LaunchAgent —
|
|
|
582
612
|
# ── History ───────────────────────────────────────────────────────────────────
|
|
583
613
|
ticketlens history <TICKET-KEY> # Show urgency timeline for a ticket [Pro]
|
|
584
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
|
+
|
|
585
621
|
# ── Compliance ────────────────────────────────────────────────────────────────
|
|
586
622
|
ticketlens compliance <TICKET-KEY> # Check ticket requirements against local diff [Pro/Free 3/mo]
|
|
587
623
|
ticketlens ledger # View local compliance audit ledger [Pro]
|
|
@@ -701,6 +737,9 @@ Pro also unlocks configurable brief cache TTL per profile — set `cacheTtl` to
|
|
|
701
737
|
```bash
|
|
702
738
|
ticketlens triage --assignee="Jane Dev" # View another dev's tickets
|
|
703
739
|
ticketlens triage --sprint="Sprint 12" # Filter by sprint name
|
|
740
|
+
ticketlens triage --project=MYPROJ # Scope to a Jira project key
|
|
741
|
+
ticketlens triage --label=Bug,P1 # Filter by label(s)
|
|
742
|
+
ticketlens triage --priority=High # Filter by priority level
|
|
704
743
|
ticketlens triage --export=csv # Export triage to CSV for standups and reports
|
|
705
744
|
ticketlens triage --export=json # Machine-readable export for dashboards
|
|
706
745
|
ticketlens triage --push # Push snapshot to the Console queue
|
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.
|
|
@@ -29,9 +29,15 @@ Fetches a Jira ticket and produces a structured brief with code references, then
|
|
|
29
29
|
/jtb triage --profile=acme # explicit profile override
|
|
30
30
|
/jtb triage --all # triage all configured profiles at once, merged (Pro)
|
|
31
31
|
/jtb triage --save=~/triage.txt # save ANSI-stripped output to file (Pro)
|
|
32
|
+
/jtb triage --project=MYPROJ # scope to a Jira project key (Team)
|
|
33
|
+
/jtb triage --label=Bug,P1 # filter by label(s) (Team)
|
|
34
|
+
/jtb triage --priority=High # filter by priority level (Team)
|
|
32
35
|
/jtb triage --push # push snapshot + git branches to Console (Team)
|
|
33
36
|
/jtb triage --share # generate 24h share URL (Team)
|
|
34
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
|
|
35
41
|
/jtb collisions # show branch collisions with teammates (Team)
|
|
36
42
|
/jtb collisions --json # machine-readable output
|
|
37
43
|
```
|
|
@@ -65,20 +65,15 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
65
65
|
printTriageHelp();
|
|
66
66
|
return;
|
|
67
67
|
}
|
|
68
|
-
// Normalize --project= alias once at entry so all recursive calls only see --profile=
|
|
69
|
-
const projectArg = args.find(a => a.startsWith('--project='));
|
|
70
|
-
if (projectArg) {
|
|
71
|
-
process.stderr.write(`Hint: --project recognized as alias for --profile=${projectArg.split('=')[1]}\n\n`);
|
|
72
|
-
args = args.map(a => a.startsWith('--project=') ? `--profile=${a.split('=')[1]}` : a);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
68
|
const profileArg = args.find(a => a.startsWith('--profile='));
|
|
76
69
|
const profileName = profileArg ? profileArg.split('=')[1] : undefined;
|
|
77
70
|
|
|
78
71
|
const validatedArgs = await handleUnknownFlags(
|
|
79
72
|
args,
|
|
80
|
-
['--help', '-h', '--static', '--plain', '--styled', '--profile=', '--stale=', '--status=',
|
|
81
|
-
|
|
73
|
+
['--help', '-h', '--static', '--plain', '--styled', '--profile=', '--stale=', '--status=',
|
|
74
|
+
'--assignee=', '--sprint=', '--export=', '--digest', '--push', '--share', '--all', '--save=',
|
|
75
|
+
'--project=', '--label=', '--priority='],
|
|
76
|
+
{ hints: ['--depth=', '--no-attachments', '--no-cache'] }
|
|
82
77
|
);
|
|
83
78
|
if (validatedArgs === null) { process.exitCode = 1; return; }
|
|
84
79
|
args = validatedArgs;
|
|
@@ -89,9 +84,12 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
89
84
|
const staleArg = args.find(a => a.startsWith('--stale='));
|
|
90
85
|
const staleDays = staleArg ? parseInt(staleArg.split('=')[1], 10) : 5;
|
|
91
86
|
|
|
92
|
-
const statusArg
|
|
87
|
+
const statusArg = args.find(a => a.startsWith('--status='));
|
|
93
88
|
const assigneeArg = args.find(a => a.startsWith('--assignee='));
|
|
94
|
-
const sprintArg
|
|
89
|
+
const sprintArg = args.find(a => a.startsWith('--sprint='));
|
|
90
|
+
const projectArg = args.find(a => a.startsWith('--project='));
|
|
91
|
+
const labelArg = args.find(a => a.startsWith('--label='));
|
|
92
|
+
const priorityArg = args.find(a => a.startsWith('--priority='));
|
|
95
93
|
const exportArg = args.find(a => a.startsWith('--export='))?.split('=')[1] ?? null;
|
|
96
94
|
const digestFlag = args.includes('--digest');
|
|
97
95
|
const pushFlag = args.includes('--push');
|
|
@@ -125,6 +123,16 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
125
123
|
} catch { /* doesn't exist yet — ok */ }
|
|
126
124
|
}
|
|
127
125
|
|
|
126
|
+
// --project / --label / --priority: Team gate
|
|
127
|
+
if (projectArg || labelArg || priorityArg) {
|
|
128
|
+
if (!licensedFn('team', configDir)) {
|
|
129
|
+
const flag = projectArg ? '--project' : labelArg ? '--label' : '--priority';
|
|
130
|
+
upgradeFn('team', flag);
|
|
131
|
+
process.exitCode = 1;
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
128
136
|
// --all: triage all configured profiles in parallel with live status block
|
|
129
137
|
if (allFlag) {
|
|
130
138
|
if (!licensedFn('pro', configDir)) {
|
|
@@ -270,10 +278,16 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
270
278
|
: conn.triageStatuses || DEFAULT_STATUSES;
|
|
271
279
|
|
|
272
280
|
// Build JQL before any I/O — pure computation, no dependency on currentUser
|
|
273
|
-
const statusList
|
|
281
|
+
const statusList = statuses.map(s => `"${escapeJql(s)}"`).join(',');
|
|
274
282
|
const assigneeClause = assigneeName ? `assignee = "${escapeJql(assigneeName)}"` : `assignee = currentUser()`;
|
|
275
283
|
const sprintClause = sprintName ? ` AND sprint = "${escapeJql(sprintName)}"` : '';
|
|
276
|
-
const
|
|
284
|
+
const projectClause = projectArg ? ` AND project = "${escapeJql(projectArg.split('=')[1])}"` : '';
|
|
285
|
+
const labelValues = labelArg ? labelArg.split('=')[1].split(',').map(l => l.trim()).filter(Boolean) : [];
|
|
286
|
+
const labelClause = labelValues.length > 1
|
|
287
|
+
? ` AND labels IN (${labelValues.map(l => `"${escapeJql(l)}"`).join(',')})`
|
|
288
|
+
: labelValues.length === 1 ? ` AND labels = "${escapeJql(labelValues[0])}"` : '';
|
|
289
|
+
const priorityClause = priorityArg ? ` AND priority = "${escapeJql(priorityArg.split('=')[1])}"` : '';
|
|
290
|
+
const jql = `${assigneeClause} AND status IN (${statusList})${sprintClause}${projectClause}${labelClause}${priorityClause} ORDER BY updated DESC`;
|
|
277
291
|
|
|
278
292
|
const session = createSession(conn);
|
|
279
293
|
session.spin(`Connecting to ${session.label}…`);
|
|
@@ -421,7 +435,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
421
435
|
await import('./lib/triage-history.mjs');
|
|
422
436
|
const yesterday = loadYesterdaySnapshot({ profile: profileName ?? 'default', configDir });
|
|
423
437
|
if (yesterday) {
|
|
424
|
-
const deltas = diffSnapshots(sorted, yesterday);
|
|
438
|
+
const deltas = diffSnapshots(sorted, yesterday.tickets);
|
|
425
439
|
delta = buildDeltaSection(deltas) || null;
|
|
426
440
|
}
|
|
427
441
|
} catch { /* non-fatal — digest still sends */ }
|
|
@@ -515,6 +529,49 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
515
529
|
|
|
516
530
|
const printFn = opts.print ?? ((s) => process.stdout.write(s));
|
|
517
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 */ }
|
|
518
575
|
}
|
|
519
576
|
|
|
520
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`,
|
|
@@ -77,6 +78,9 @@ export function printHelp({ stream = process.stdout } = {}) {
|
|
|
77
78
|
` ${s.brand('--status')}=${s.dim('X,Y')} Override statuses to scan`,
|
|
78
79
|
` ${s.brand('--assignee')}=${s.dim('NAME')} Triage another dev's tickets ${s.dim('[Team]')}`,
|
|
79
80
|
` ${s.brand('--sprint')}=${s.dim('NAME')} Filter by sprint name ${s.dim('[Team]')}`,
|
|
81
|
+
` ${s.brand('--project')}=${s.dim('PROJ')} Filter by Jira project key ${s.dim('[Team]')}`,
|
|
82
|
+
` ${s.brand('--label')}=${s.dim('X,Y')} Filter by label(s) ${s.dim('[Team]')}`,
|
|
83
|
+
` ${s.brand('--priority')}=${s.dim('LEVEL')} Filter by priority ${s.dim('(e.g. High, Blocker) [Team]')}`,
|
|
80
84
|
` ${s.brand('--export')}=${s.dim('FORMAT')} Export results to file ${s.dim('(csv|json) [Team]')}`,
|
|
81
85
|
` ${s.brand('--push')} Push snapshot to Console queue ${s.dim('[Team]')}`,
|
|
82
86
|
` ${s.brand('--share')} Generate a 24h share URL ${s.dim('(no login required) [Team]')}`,
|
|
@@ -549,6 +553,9 @@ export function printTriageHelp({ stream = process.stdout } = {}) {
|
|
|
549
553
|
` ${s.brand('--status')}=${s.dim('X,Y')} Override statuses to scan`,
|
|
550
554
|
` ${s.brand('--assignee')}=${s.dim('NAME')} Triage another dev's tickets ${s.dim('[Team]')}`,
|
|
551
555
|
` ${s.brand('--sprint')}=${s.dim('NAME')} Filter by sprint name ${s.dim('[Team]')}`,
|
|
556
|
+
` ${s.brand('--project')}=${s.dim('PROJ')} Filter by Jira project key ${s.dim('[Team]')}`,
|
|
557
|
+
` ${s.brand('--label')}=${s.dim('X,Y')} Filter by label(s) ${s.dim('[Team]')}`,
|
|
558
|
+
` ${s.brand('--priority')}=${s.dim('LEVEL')} Filter by priority ${s.dim('(e.g. High, Blocker) [Team]')}`,
|
|
552
559
|
` ${s.brand('--export')}=${s.dim('FORMAT')} Export results to file ${s.dim('(csv|json) [Team]')}`,
|
|
553
560
|
` ${s.brand('--push')} Push snapshot to Console queue ${s.dim('[Team]')}`,
|
|
554
561
|
` ${s.brand('--share')} Generate a 24h share URL ${s.dim('(no login required) [Team]')}`,
|
|
@@ -565,6 +572,8 @@ export function printTriageHelp({ stream = process.stdout } = {}) {
|
|
|
565
572
|
` ${s.dim('$')} ticketlens triage --profile=acme`,
|
|
566
573
|
` ${s.dim('$')} ticketlens triage --stale=3 --status="Code Review,QA Testing"`,
|
|
567
574
|
` ${s.dim('$')} ticketlens triage --assignee="Jane Dev" --sprint="Sprint 12"`,
|
|
575
|
+
` ${s.dim('$')} ticketlens triage --project=MYPROJ --priority=High`,
|
|
576
|
+
` ${s.dim('$')} ticketlens triage --label=Bug,P1`,
|
|
568
577
|
` ${s.dim('$')} ticketlens triage --static`,
|
|
569
578
|
'',
|
|
570
579
|
` ${s.bold('INTERACTIVE MODE')}`,
|
|
@@ -680,6 +689,36 @@ export function printCollisionsHelp({ stream = process.stdout } = {}) {
|
|
|
680
689
|
stream.write(lines.join('\n') + '\n');
|
|
681
690
|
}
|
|
682
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
|
+
|
|
683
722
|
export function printStandupHelp({ stream = process.stdout } = {}) {
|
|
684
723
|
const s = createStyler({ isTTY: stream.isTTY });
|
|
685
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
|
+
}
|
|
@@ -8,7 +8,7 @@ import * as defaultFs from 'node:fs';
|
|
|
8
8
|
import { homedir } from 'node:os';
|
|
9
9
|
import { join } from 'node:path';
|
|
10
10
|
|
|
11
|
-
const DEFAULT_CONFIG_DIR = join(homedir(), '.ticketlens');
|
|
11
|
+
export const DEFAULT_CONFIG_DIR = join(homedir(), '.ticketlens');
|
|
12
12
|
|
|
13
13
|
const URGENCY_ORDER = { 'needs-response': 0, 'aging': 1, 'clear': 2 };
|
|
14
14
|
|
|
@@ -16,6 +16,17 @@ const URGENCY_ORDER = { 'needs-response': 0, 'aging': 1, 'clear': 2 };
|
|
|
16
16
|
// Internal helpers
|
|
17
17
|
// ---------------------------------------------------------------------------
|
|
18
18
|
|
|
19
|
+
function median(arr) {
|
|
20
|
+
if (!arr.length) return null;
|
|
21
|
+
const sorted = [...arr].sort((a, b) => a - b);
|
|
22
|
+
const mid = Math.floor(sorted.length / 2);
|
|
23
|
+
return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Internal helpers
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
19
30
|
function validateProfile(profile) {
|
|
20
31
|
if (!profile || /[/\\]/.test(profile) || profile === '..' || profile.includes('..')) {
|
|
21
32
|
throw new Error('Invalid profile name');
|
|
@@ -59,7 +70,8 @@ export function saveTriageSnapshot(tickets, {
|
|
|
59
70
|
const dir = join(configDir, 'triage-history', dateStr);
|
|
60
71
|
fsModule.mkdirSync(dir, { recursive: true });
|
|
61
72
|
const filePath = snapshotPath(configDir, dateStr, profile);
|
|
62
|
-
|
|
73
|
+
const envelope = { captured_at: now.toISOString(), tickets };
|
|
74
|
+
fsModule.writeFileSync(filePath, JSON.stringify(envelope, null, 2), 'utf8');
|
|
63
75
|
}
|
|
64
76
|
|
|
65
77
|
/**
|
|
@@ -70,7 +82,7 @@ export function saveTriageSnapshot(tickets, {
|
|
|
70
82
|
* @param {string} [opts.configDir]
|
|
71
83
|
* @param {object} [opts.fsModule]
|
|
72
84
|
* @param {Date} [opts.now]
|
|
73
|
-
* @returns {object[]|null}
|
|
85
|
+
* @returns {{ captured_at: string|null, tickets: object[] }|null}
|
|
74
86
|
*/
|
|
75
87
|
export function loadYesterdaySnapshot({
|
|
76
88
|
profile,
|
|
@@ -85,7 +97,9 @@ export function loadYesterdaySnapshot({
|
|
|
85
97
|
const filePath = snapshotPath(configDir, dateStr, profile);
|
|
86
98
|
try {
|
|
87
99
|
const raw = fsModule.readFileSync(filePath, 'utf8');
|
|
88
|
-
|
|
100
|
+
const parsed = JSON.parse(raw);
|
|
101
|
+
if (Array.isArray(parsed)) return { captured_at: null, tickets: parsed };
|
|
102
|
+
return parsed;
|
|
89
103
|
} catch {
|
|
90
104
|
return null;
|
|
91
105
|
}
|
|
@@ -201,11 +215,12 @@ export function queryTicketHistory(ticketKey, {
|
|
|
201
215
|
const profile = file.slice(0, -5); // strip .json
|
|
202
216
|
let tickets;
|
|
203
217
|
try {
|
|
204
|
-
|
|
218
|
+
const raw = JSON.parse(fsModule.readFileSync(join(dayDir, file), 'utf8'));
|
|
219
|
+
tickets = Array.isArray(raw) ? raw : (raw.tickets ?? []);
|
|
205
220
|
} catch {
|
|
206
221
|
continue;
|
|
207
222
|
}
|
|
208
|
-
const found =
|
|
223
|
+
const found = tickets.find(t => t.ticketKey === ticketKey);
|
|
209
224
|
if (found) {
|
|
210
225
|
entries.push({
|
|
211
226
|
date,
|
|
@@ -230,3 +245,134 @@ export function queryTicketHistory(ticketKey, {
|
|
|
230
245
|
|
|
231
246
|
return entries;
|
|
232
247
|
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Load a snapshot file for an arbitrary date string. Returns { captured_at, tickets } or null.
|
|
251
|
+
*
|
|
252
|
+
* @param {string} dateStr - YYYY-MM-DD
|
|
253
|
+
* @param {string} profile
|
|
254
|
+
* @param {string} configDir
|
|
255
|
+
* @param {object} fsModule
|
|
256
|
+
* @returns {{ captured_at: string|null, tickets: object[] }|null}
|
|
257
|
+
*/
|
|
258
|
+
function loadSnapshotForDate(dateStr, profile, configDir, fsModule) {
|
|
259
|
+
const filePath = snapshotPath(configDir, dateStr, profile);
|
|
260
|
+
try {
|
|
261
|
+
const parsed = JSON.parse(fsModule.readFileSync(filePath, 'utf8'));
|
|
262
|
+
if (Array.isArray(parsed)) return { captured_at: null, tickets: parsed };
|
|
263
|
+
return parsed;
|
|
264
|
+
} catch {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Compute response-time and triage-cadence metrics from local triage history.
|
|
271
|
+
*
|
|
272
|
+
* A "response transition" is detected when a ticket appears as `needs-response`
|
|
273
|
+
* in snapshot D and `clear` in snapshot D+1. Response time = captured_at of the
|
|
274
|
+
* clear snapshot minus lastComment.created of the needs-response snapshot.
|
|
275
|
+
*
|
|
276
|
+
* @param {string} profile
|
|
277
|
+
* @param {object} [opts]
|
|
278
|
+
* @param {number} [opts.days=7] Lookback window in calendar days
|
|
279
|
+
* @param {string} [opts.configDir]
|
|
280
|
+
* @param {object} [opts.fsModule]
|
|
281
|
+
* @param {Date} [opts.now]
|
|
282
|
+
* @returns {{
|
|
283
|
+
* avgResponseHours: number|null,
|
|
284
|
+
* medianResponseHours: number|null,
|
|
285
|
+
* clearRate: number|null,
|
|
286
|
+
* triageRunCount: number,
|
|
287
|
+
* currentUrgency: { needsResponse: number, aging: number, clear: number }|null,
|
|
288
|
+
* windowDays: number,
|
|
289
|
+
* trendHours: number|null,
|
|
290
|
+
* }}
|
|
291
|
+
*/
|
|
292
|
+
export function computeResponseMetrics(profile, {
|
|
293
|
+
days = 7,
|
|
294
|
+
configDir = DEFAULT_CONFIG_DIR,
|
|
295
|
+
fsModule = defaultFs,
|
|
296
|
+
now = new Date(),
|
|
297
|
+
} = {}) {
|
|
298
|
+
validateProfile(profile);
|
|
299
|
+
|
|
300
|
+
function windowSnapshots(offsetStart, count) {
|
|
301
|
+
const snaps = [];
|
|
302
|
+
for (let i = offsetStart + count - 1; i >= offsetStart; i--) {
|
|
303
|
+
const d = new Date(now);
|
|
304
|
+
d.setDate(d.getDate() - i);
|
|
305
|
+
const dateStr = toDateString(d);
|
|
306
|
+
const snap = loadSnapshotForDate(dateStr, profile, configDir, fsModule);
|
|
307
|
+
if (snap) snaps.push({ dateStr, ...snap });
|
|
308
|
+
}
|
|
309
|
+
return snaps; // chronological order (oldest first)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function computeFromSnaps(snaps) {
|
|
313
|
+
const durations = [];
|
|
314
|
+
let transitions = 0;
|
|
315
|
+
let fastTransitions = 0;
|
|
316
|
+
|
|
317
|
+
for (let i = 0; i + 1 < snaps.length; i++) {
|
|
318
|
+
const dayA = snaps[i];
|
|
319
|
+
const dayB = snaps[i + 1];
|
|
320
|
+
const mapA = new Map(dayA.tickets.map(t => [t.ticketKey, t]));
|
|
321
|
+
|
|
322
|
+
for (const tb of dayB.tickets) {
|
|
323
|
+
if (tb.urgency !== 'clear') continue;
|
|
324
|
+
const ta = mapA.get(tb.ticketKey);
|
|
325
|
+
if (!ta || ta.urgency !== 'needs-response') continue;
|
|
326
|
+
|
|
327
|
+
transitions++;
|
|
328
|
+
const commentCreated = ta.lastComment?.created;
|
|
329
|
+
const clearedAt = dayB.captured_at;
|
|
330
|
+
if (!commentCreated || !clearedAt) continue;
|
|
331
|
+
|
|
332
|
+
const ms = new Date(clearedAt).getTime() - new Date(commentCreated).getTime();
|
|
333
|
+
if (isNaN(ms) || ms < 0) continue;
|
|
334
|
+
const hours = ms / 3_600_000;
|
|
335
|
+
durations.push(hours);
|
|
336
|
+
if (hours <= 24) fastTransitions++;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
avgResponseHours: durations.length ? durations.reduce((a, b) => a + b, 0) / durations.length : null,
|
|
342
|
+
medianResponseHours: median(durations),
|
|
343
|
+
clearRate: durations.length > 0 ? fastTransitions / durations.length : null,
|
|
344
|
+
triageRunCount: snaps.length,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const currentSnaps = windowSnapshots(0, days);
|
|
349
|
+
const current = computeFromSnaps(currentSnaps);
|
|
350
|
+
|
|
351
|
+
// Current urgency from the most recent snapshot
|
|
352
|
+
const latest = currentSnaps[currentSnaps.length - 1] ?? null;
|
|
353
|
+
const currentUrgency = latest
|
|
354
|
+
? {
|
|
355
|
+
needsResponse: latest.tickets.filter(t => t.urgency === 'needs-response').length,
|
|
356
|
+
aging: latest.tickets.filter(t => t.urgency === 'aging').length,
|
|
357
|
+
clear: latest.tickets.filter(t => t.urgency === 'clear').length,
|
|
358
|
+
}
|
|
359
|
+
: null;
|
|
360
|
+
|
|
361
|
+
// Trend: compare current window avg vs prior window avg
|
|
362
|
+
const priorSnaps = windowSnapshots(days, days);
|
|
363
|
+
const prior = computeFromSnaps(priorSnaps);
|
|
364
|
+
const trendHours =
|
|
365
|
+
current.avgResponseHours !== null && prior.avgResponseHours !== null
|
|
366
|
+
? current.avgResponseHours - prior.avgResponseHours
|
|
367
|
+
: null;
|
|
368
|
+
|
|
369
|
+
return {
|
|
370
|
+
avgResponseHours: current.avgResponseHours,
|
|
371
|
+
medianResponseHours: current.medianResponseHours,
|
|
372
|
+
clearRate: current.clearRate,
|
|
373
|
+
triageRunCount: current.triageRunCount,
|
|
374
|
+
currentUrgency,
|
|
375
|
+
windowDays: days,
|
|
376
|
+
trendHours,
|
|
377
|
+
};
|
|
378
|
+
}
|