ticketlens 0.5.1 → 0.7.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 +54 -0
- package/bin/ticketlens.mjs +25 -0
- package/package.json +1 -1
- package/skills/jtb/SKILL.md +7 -1
- package/skills/jtb/scripts/fetch-my-tickets.mjs +168 -26
- package/skills/jtb/scripts/lib/ansi.mjs +3 -0
- package/skills/jtb/scripts/lib/attention-scorer.mjs +37 -3
- package/skills/jtb/scripts/lib/cli.mjs +4 -0
- package/skills/jtb/scripts/lib/help.mjs +13 -0
- package/skills/jtb/scripts/lib/profile-resolver.mjs +1 -0
- package/skills/jtb/scripts/lib/schedule-wizard.mjs +117 -10
- package/skills/jtb/scripts/lib/triage-history.mjs +218 -4
package/README.md
CHANGED
|
@@ -133,10 +133,15 @@ 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]
|
|
139
142
|
ticketlens triage --share # Generate 24h share URL (no login for recipient) [Team]
|
|
143
|
+
ticketlens triage --all # Triage all profiles at once, merged output [Pro]
|
|
144
|
+
ticketlens triage --save=~/triage.txt # Save ANSI-stripped output to file [Pro]
|
|
140
145
|
ticketlens triage --digest # POST scored results to digest endpoint [Pro]
|
|
141
146
|
ticketlens triage --plain # Plain markdown — pipe to file or LLM
|
|
142
147
|
ticketlens triage --static # Static table, no interactive mode
|
|
@@ -331,12 +336,48 @@ Cache locations:
|
|
|
331
336
|
ticketlens schedule # Interactive wizard — set digest time, timezone, profile [Pro]
|
|
332
337
|
ticketlens schedule --stop # Cancel the scheduled digest
|
|
333
338
|
ticketlens schedule --status # Show current schedule
|
|
339
|
+
ticketlens schedule --local # Local-only cron (no Console auth) — saves triage to file [Pro]
|
|
334
340
|
```
|
|
335
341
|
|
|
336
342
|
Stores the schedule as a cron entry. Delivers your triage digest at the configured time without an open terminal. Requires a Pro license.
|
|
337
343
|
|
|
338
344
|
---
|
|
339
345
|
|
|
346
|
+
### History
|
|
347
|
+
|
|
348
|
+
```bash
|
|
349
|
+
ticketlens history <TICKET-KEY> # Show urgency timeline for a ticket [Pro]
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
Reads from your local `~/.ticketlens/triage-history/` snapshots (written automatically on each `ticketlens triage` run) and renders a day-by-day urgency timeline for the requested ticket. Entries where urgency changed direction are flagged as "bounced" — useful for spotting tickets that keep reverting between Code Review and In Progress.
|
|
353
|
+
|
|
354
|
+
Requires a Pro license. No network call — reads local snapshots only.
|
|
355
|
+
|
|
356
|
+
---
|
|
357
|
+
|
|
358
|
+
### Custom Attention Rules
|
|
359
|
+
|
|
360
|
+
Add an `attentionRules` array to any profile in `~/.ticketlens/profiles.json` to override how `ticketlens triage` scores specific tickets:
|
|
361
|
+
|
|
362
|
+
```json
|
|
363
|
+
{
|
|
364
|
+
"profiles": {
|
|
365
|
+
"work": {
|
|
366
|
+
"baseUrl": "https://jira.example.com",
|
|
367
|
+
"attentionRules": [
|
|
368
|
+
{ "match": { "priority": "Highest" }, "action": "force-urgent", "reason": "P1 always urgent" },
|
|
369
|
+
{ "match": { "label": "backlog" }, "action": "ignore", "reason": "skip backlog" },
|
|
370
|
+
{ "match": { "status": "Parked" }, "action": "ignore", "reason": "parked tickets" }
|
|
371
|
+
]
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
Rules are evaluated in order — first match wins. Supported `match` keys: `priority`, `label`, `status`, `keyPrefix`. Supported `action` values: `force-urgent` (bumps to needs-response) and `ignore` (excludes from output). Requires a Pro license.
|
|
378
|
+
|
|
379
|
+
---
|
|
380
|
+
|
|
340
381
|
### Login
|
|
341
382
|
|
|
342
383
|
```bash
|
|
@@ -481,11 +522,17 @@ ticketlens triage --static # Static table output (no interacti
|
|
|
481
522
|
ticketlens triage --plain # Plain markdown — pipe to LLM or file
|
|
482
523
|
ticketlens triage --assignee="Jane Dev" # View another dev's tickets [Team]
|
|
483
524
|
ticketlens triage --sprint="Sprint 12" # Filter by sprint name [Team]
|
|
525
|
+
ticketlens triage --project=MYPROJ # Scope to a Jira project key [Team]
|
|
526
|
+
ticketlens triage --label=Bug,P1 # Filter by label(s) [Team]
|
|
527
|
+
ticketlens triage --priority=High # Filter by priority level [Team]
|
|
528
|
+
ticketlens triage --project=MYPROJ --label=Bug --priority=High # Combined [Team]
|
|
484
529
|
ticketlens triage --assignee="Jane Dev" --sprint="Sprint 12" # Combined [Team]
|
|
485
530
|
ticketlens triage --export=csv # Export to CSV [Team]
|
|
486
531
|
ticketlens triage --export=json # Export to JSON [Team]
|
|
487
532
|
ticketlens triage --push # Push snapshot to Console queue [Team]
|
|
488
533
|
ticketlens triage --share # Generate 24h share URL (no login for recipient) [Team]
|
|
534
|
+
ticketlens triage --all # Triage all profiles at once, merged output [Pro]
|
|
535
|
+
ticketlens triage --save=~/triage.txt # Save ANSI-stripped output to file [Pro]
|
|
489
536
|
ticketlens triage --digest # POST results to digest endpoint [Pro]
|
|
490
537
|
ticketlens triage --profile=acme --stale=3 --static # Combine flags
|
|
491
538
|
|
|
@@ -537,6 +584,10 @@ ticketlens cache clear --older-than=30d --yes # Skip confirmation (CI
|
|
|
537
584
|
ticketlens schedule # Interactive wizard — set time, timezone, profile [Pro]
|
|
538
585
|
ticketlens schedule --stop # Cancel the scheduled digest [Pro]
|
|
539
586
|
ticketlens schedule --status # Show current schedule [Pro]
|
|
587
|
+
ticketlens schedule --local # Local-only cron/LaunchAgent — no Console auth needed [Pro]
|
|
588
|
+
|
|
589
|
+
# ── History ───────────────────────────────────────────────────────────────────
|
|
590
|
+
ticketlens history <TICKET-KEY> # Show urgency timeline for a ticket [Pro]
|
|
540
591
|
|
|
541
592
|
# ── Compliance ────────────────────────────────────────────────────────────────
|
|
542
593
|
ticketlens compliance <TICKET-KEY> # Check ticket requirements against local diff [Pro/Free 3/mo]
|
|
@@ -657,6 +708,9 @@ Pro also unlocks configurable brief cache TTL per profile — set `cacheTtl` to
|
|
|
657
708
|
```bash
|
|
658
709
|
ticketlens triage --assignee="Jane Dev" # View another dev's tickets
|
|
659
710
|
ticketlens triage --sprint="Sprint 12" # Filter by sprint name
|
|
711
|
+
ticketlens triage --project=MYPROJ # Scope to a Jira project key
|
|
712
|
+
ticketlens triage --label=Bug,P1 # Filter by label(s)
|
|
713
|
+
ticketlens triage --priority=High # Filter by priority level
|
|
660
714
|
ticketlens triage --export=csv # Export triage to CSV for standups and reports
|
|
661
715
|
ticketlens triage --export=json # Machine-readable export for dashboards
|
|
662
716
|
ticketlens triage --push # Push snapshot to the Console queue
|
package/bin/ticketlens.mjs
CHANGED
|
@@ -78,6 +78,31 @@ switch (command) {
|
|
|
78
78
|
break;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
case 'history': {
|
|
82
|
+
if (!isLicensed('pro')) { showUpgradePrompt('pro', 'ticketlens history'); break; }
|
|
83
|
+
const ticketKey = cmdArgs[0];
|
|
84
|
+
if (!ticketKey || ticketKey.startsWith('-')) {
|
|
85
|
+
process.stderr.write('Usage: ticketlens history TICKET-KEY\n');
|
|
86
|
+
process.exitCode = 1;
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
const { queryTicketHistory } = await import('../skills/jtb/scripts/lib/triage-history.mjs');
|
|
90
|
+
const entries = queryTicketHistory(ticketKey);
|
|
91
|
+
if (entries.length === 0) {
|
|
92
|
+
process.stdout.write(`No triage history found for ${ticketKey}.\n`);
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
const hs = createStyler({ isTTY: process.stdout.isTTY });
|
|
96
|
+
process.stdout.write(`\nHistory for ${hs.bold(ticketKey)} (${entries.length} entries)\n\n`);
|
|
97
|
+
for (const e of entries) {
|
|
98
|
+
const bounce = e.bounced ? hs.yellow(' ⟳ bounced') : '';
|
|
99
|
+
const urg = e.urgency === 'needs-response' ? hs.red(e.urgency) : e.urgency === 'aging' ? hs.yellow(e.urgency) : hs.green(e.urgency);
|
|
100
|
+
process.stdout.write(` ${hs.dim(e.date)} [${e.profile}] ${urg}${bounce} ${hs.dim(e.reason)}\n`);
|
|
101
|
+
}
|
|
102
|
+
process.stdout.write('\n');
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
|
|
81
106
|
case 'init':
|
|
82
107
|
if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printInitHelp(); break; }
|
|
83
108
|
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.7.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.
|
|
@@ -27,8 +27,14 @@ Fetches a Jira ticket and produces a structured brief with code references, then
|
|
|
27
27
|
/jtb triage --stale=3 # custom aging threshold (days)
|
|
28
28
|
/jtb triage --status=CR,QA # only check specific statuses
|
|
29
29
|
/jtb triage --profile=acme # explicit profile override
|
|
30
|
+
/jtb triage --all # triage all configured profiles at once, merged (Pro)
|
|
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)
|
|
30
35
|
/jtb triage --push # push snapshot + git branches to Console (Team)
|
|
31
36
|
/jtb triage --share # generate 24h share URL (Team)
|
|
37
|
+
/jtb history PROD-1234 # show urgency timeline for a ticket (Pro)
|
|
32
38
|
/jtb collisions # show branch collisions with teammates (Team)
|
|
33
39
|
/jtb collisions --json # machine-readable output
|
|
34
40
|
```
|
|
@@ -10,6 +10,8 @@ import { assembleTriageSummary } from './lib/brief-assembler.mjs';
|
|
|
10
10
|
import { styleTriageSummary } from './lib/styled-assembler.mjs';
|
|
11
11
|
import { resolveConnection, loadProfiles, saveProfile } from './lib/profile-resolver.mjs';
|
|
12
12
|
import { resolveAdapter } from './lib/resolve-adapter.mjs';
|
|
13
|
+
import { writeFileSync, mkdirSync, statSync } from 'node:fs';
|
|
14
|
+
import { resolve as resolvePath, dirname } from 'node:path';
|
|
13
15
|
import { createSpinner } from './lib/spinner.mjs';
|
|
14
16
|
import { createSession } from './lib/banner.mjs';
|
|
15
17
|
import { classifyError } from './lib/error-classifier.mjs';
|
|
@@ -20,6 +22,7 @@ import { handleUnknownFlags } from './lib/arg-validator.mjs';
|
|
|
20
22
|
import { isLicensed, showUpgradePrompt, revalidateIfStale } from './lib/license.mjs';
|
|
21
23
|
import { readCliToken } from './lib/cli-auth.mjs';
|
|
22
24
|
import { apiBase } from './lib/api-utils.mjs';
|
|
25
|
+
import { stripAnsi, bold, cyan, dim, red, green } from './lib/ansi.mjs';
|
|
23
26
|
|
|
24
27
|
const DEFAULT_STATUSES = ['In Progress', 'Code Review', 'QA'];
|
|
25
28
|
|
|
@@ -62,20 +65,15 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
62
65
|
printTriageHelp();
|
|
63
66
|
return;
|
|
64
67
|
}
|
|
65
|
-
// Normalize --project= alias once at entry so all recursive calls only see --profile=
|
|
66
|
-
const projectArg = args.find(a => a.startsWith('--project='));
|
|
67
|
-
if (projectArg) {
|
|
68
|
-
process.stderr.write(`Hint: --project recognized as alias for --profile=${projectArg.split('=')[1]}\n\n`);
|
|
69
|
-
args = args.map(a => a.startsWith('--project=') ? `--profile=${a.split('=')[1]}` : a);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
68
|
const profileArg = args.find(a => a.startsWith('--profile='));
|
|
73
69
|
const profileName = profileArg ? profileArg.split('=')[1] : undefined;
|
|
74
70
|
|
|
75
71
|
const validatedArgs = await handleUnknownFlags(
|
|
76
72
|
args,
|
|
77
|
-
['--help', '-h', '--static', '--plain', '--styled', '--profile=', '--stale=', '--status=',
|
|
78
|
-
|
|
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'] }
|
|
79
77
|
);
|
|
80
78
|
if (validatedArgs === null) { process.exitCode = 1; return; }
|
|
81
79
|
args = validatedArgs;
|
|
@@ -86,13 +84,18 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
86
84
|
const staleArg = args.find(a => a.startsWith('--stale='));
|
|
87
85
|
const staleDays = staleArg ? parseInt(staleArg.split('=')[1], 10) : 5;
|
|
88
86
|
|
|
89
|
-
const statusArg
|
|
87
|
+
const statusArg = args.find(a => a.startsWith('--status='));
|
|
90
88
|
const assigneeArg = args.find(a => a.startsWith('--assignee='));
|
|
91
|
-
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='));
|
|
92
93
|
const exportArg = args.find(a => a.startsWith('--export='))?.split('=')[1] ?? null;
|
|
93
94
|
const digestFlag = args.includes('--digest');
|
|
94
95
|
const pushFlag = args.includes('--push');
|
|
95
96
|
const shareFlag = args.includes('--share');
|
|
97
|
+
const allFlag = args.includes('--all');
|
|
98
|
+
const saveArg = args.find(a => a.startsWith('--save='))?.split('=').slice(1).join('=') ?? null;
|
|
96
99
|
|
|
97
100
|
if (exportArg && exportArg !== 'csv' && exportArg !== 'json') {
|
|
98
101
|
process.stderr.write(`Error: --export must be csv or json, got: ${exportArg}\n`);
|
|
@@ -103,6 +106,121 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
103
106
|
const licensedFn = opts.isLicensed ?? isLicensed;
|
|
104
107
|
const upgradeFn = opts.showUpgradePrompt ?? showUpgradePrompt;
|
|
105
108
|
|
|
109
|
+
// --save=FILE: Pro gate + validate path is not a directory
|
|
110
|
+
if (saveArg) {
|
|
111
|
+
if (!licensedFn('pro', configDir)) {
|
|
112
|
+
upgradeFn('pro', '--save');
|
|
113
|
+
process.exitCode = 1;
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const resolvedSave = resolvePath(saveArg);
|
|
117
|
+
try {
|
|
118
|
+
if (statSync(resolvedSave).isDirectory()) {
|
|
119
|
+
process.stderr.write(`Error: --save path must be a file, not a directory: ${resolvedSave}\n`);
|
|
120
|
+
process.exitCode = 1;
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
} catch { /* doesn't exist yet — ok */ }
|
|
124
|
+
}
|
|
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
|
+
|
|
136
|
+
// --all: triage all configured profiles in parallel with live status block
|
|
137
|
+
if (allFlag) {
|
|
138
|
+
if (!licensedFn('pro', configDir)) {
|
|
139
|
+
upgradeFn('pro', '--all');
|
|
140
|
+
process.exitCode = 1;
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const profilesConfig = loadProfiles(configDir);
|
|
144
|
+
const profileNames = profilesConfig?.profiles ? Object.keys(profilesConfig.profiles) : [];
|
|
145
|
+
if (profileNames.length === 0) {
|
|
146
|
+
process.stderr.write('Error: No profiles configured. Run `ticketlens init` first.\n');
|
|
147
|
+
process.exitCode = 1;
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const usePlain = args.includes('--plain');
|
|
152
|
+
const printFn = opts.print ?? ((s) => process.stdout.write(s));
|
|
153
|
+
const argsBase = args.filter(a => a !== '--all' && !a.startsWith('--profile='));
|
|
154
|
+
// --static disables interactive mode in sub-runs (isTTY is still true but we're capturing output)
|
|
155
|
+
const modeFlags = usePlain ? ['--plain'] : ['--styled', '--static'];
|
|
156
|
+
const isTTY = process.stderr.isTTY;
|
|
157
|
+
const SPIN = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
158
|
+
const COL = Math.max(...profileNames.map(n => n.length)) + 2;
|
|
159
|
+
|
|
160
|
+
// Per-profile state
|
|
161
|
+
const entries = profileNames.map(name => ({ name, state: 'pending', output: [], summary: '' }));
|
|
162
|
+
|
|
163
|
+
const statusLine = (e, frame = 0) => {
|
|
164
|
+
const nameCol = e.name.padEnd(COL);
|
|
165
|
+
if (e.state === 'pending') return ` ${SPIN[frame % SPIN.length]} ${nameCol}${dim('fetching...')}`;
|
|
166
|
+
if (e.state === 'done') return ` ${green('✔')} ${nameCol}${dim(e.summary)}`;
|
|
167
|
+
return ` ${red('✗')} ${nameCol}${dim('could not connect')}`;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
// Suppress all sub-run stderr so banners/spinners don't corrupt the status block.
|
|
171
|
+
// Status updates bypass suppression via `toStatus` which holds the original reference.
|
|
172
|
+
const toStatus = isTTY ? process.stderr.write.bind(process.stderr) : null;
|
|
173
|
+
if (isTTY) {
|
|
174
|
+
process.stderr.write = () => true; // silence sub-runs
|
|
175
|
+
toStatus('\x1b[?25l');
|
|
176
|
+
for (const e of entries) toStatus(statusLine(e) + '\n');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
let frame = 0;
|
|
180
|
+
const timer = isTTY ? setInterval(() => {
|
|
181
|
+
frame++;
|
|
182
|
+
toStatus(`\x1b[${entries.length}A`);
|
|
183
|
+
for (const e of entries) toStatus(`\r\x1b[K${statusLine(e, frame)}\n`);
|
|
184
|
+
}, 80) : null;
|
|
185
|
+
|
|
186
|
+
await Promise.allSettled(entries.map((e, idx) =>
|
|
187
|
+
run([...argsBase, `--profile=${e.name}`, ...modeFlags],
|
|
188
|
+
{ ...opts, env, fetcher, configDir, print: s => e.output.push(s) })
|
|
189
|
+
.then(() => {
|
|
190
|
+
const combined = e.output.join('');
|
|
191
|
+
const m = combined.match(/(\d+) found/);
|
|
192
|
+
entries[idx].state = e.output.length === 0 ? 'error' : 'done';
|
|
193
|
+
entries[idx].summary = m ? `${m[1]} ticket${m[1] === '1' ? '' : 's'}` : 'done';
|
|
194
|
+
})
|
|
195
|
+
.catch(() => { entries[idx].state = 'error'; })
|
|
196
|
+
));
|
|
197
|
+
|
|
198
|
+
if (isTTY) {
|
|
199
|
+
clearInterval(timer);
|
|
200
|
+
toStatus(`\x1b[${entries.length}A`);
|
|
201
|
+
for (const e of entries) toStatus(`\r\x1b[K${statusLine(e)}\n`);
|
|
202
|
+
toStatus('\x1b[?25h\n');
|
|
203
|
+
process.stderr.write = toStatus; // restore
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Render results in profile order
|
|
207
|
+
for (const e of entries) {
|
|
208
|
+
printFn(usePlain
|
|
209
|
+
? `\n── ${e.name} ──\n`
|
|
210
|
+
: `\n${dim('──')} ${bold(cyan(e.name))} ${dim('──')}\n`);
|
|
211
|
+
|
|
212
|
+
if (e.output.length === 0) {
|
|
213
|
+
const hint = `ticketlens triage --profile=${e.name}`;
|
|
214
|
+
printFn(usePlain
|
|
215
|
+
? ` [could not connect — run: ${hint}]\n`
|
|
216
|
+
: ` ${red('✗')} ${dim('Could not connect — run')} ${cyan(hint)} ${dim('for details')}\n`);
|
|
217
|
+
} else {
|
|
218
|
+
printFn(e.output.join(''));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
106
224
|
// Team-tier gate: --assignee and --sprint require a Team license
|
|
107
225
|
if ((assigneeArg || sprintArg) && !licensedFn('team', configDir)) {
|
|
108
226
|
upgradeFn('team', assigneeArg ? '--assignee' : '--sprint');
|
|
@@ -160,10 +278,16 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
160
278
|
: conn.triageStatuses || DEFAULT_STATUSES;
|
|
161
279
|
|
|
162
280
|
// Build JQL before any I/O — pure computation, no dependency on currentUser
|
|
163
|
-
const statusList
|
|
281
|
+
const statusList = statuses.map(s => `"${escapeJql(s)}"`).join(',');
|
|
164
282
|
const assigneeClause = assigneeName ? `assignee = "${escapeJql(assigneeName)}"` : `assignee = currentUser()`;
|
|
165
283
|
const sprintClause = sprintName ? ` AND sprint = "${escapeJql(sprintName)}"` : '';
|
|
166
|
-
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`;
|
|
167
291
|
|
|
168
292
|
const session = createSession(conn);
|
|
169
293
|
session.spin(`Connecting to ${session.label}…`);
|
|
@@ -283,11 +407,17 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
283
407
|
process.stderr.write(`Viewing ${assigneeName}'s tickets\n\n`);
|
|
284
408
|
}
|
|
285
409
|
|
|
286
|
-
const scored = tickets.map(t => scoreAttention(t, effectiveUser, { staleDays }));
|
|
287
|
-
const actionable = scored.filter(s => s.urgency !== 'clear');
|
|
410
|
+
const scored = tickets.map(t => scoreAttention(t, effectiveUser, { staleDays, customRules: conn.attentionRules }));
|
|
411
|
+
const actionable = scored.filter(s => s.urgency !== 'clear' && s.urgency !== 'ignore');
|
|
288
412
|
const sorted = sortByUrgency(actionable);
|
|
289
413
|
const rawTicketMap = new Map(tickets.map(t => [t.key, t]));
|
|
290
414
|
|
|
415
|
+
// Always save a daily snapshot for history tracking (non-fatal)
|
|
416
|
+
try {
|
|
417
|
+
const { saveTriageSnapshot } = await import('./lib/triage-history.mjs');
|
|
418
|
+
saveTriageSnapshot(scored, { profile: profileName ?? 'default', configDir });
|
|
419
|
+
} catch { /* non-fatal */ }
|
|
420
|
+
|
|
291
421
|
// --digest: POST scored results to the digest backend endpoint
|
|
292
422
|
if (digestFlag) {
|
|
293
423
|
if (!licensedFn('pro', configDir)) {
|
|
@@ -298,15 +428,14 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
298
428
|
const deliverer = opts.digestDeliverer ?? defaultDigestDeliverer;
|
|
299
429
|
const digestCliToken = opts.cliToken ?? readCliToken(configDir) ?? null;
|
|
300
430
|
|
|
301
|
-
// Triage history delta (non-fatal —
|
|
431
|
+
// Triage history delta (non-fatal — snapshot already saved above)
|
|
302
432
|
let delta = null;
|
|
303
433
|
try {
|
|
304
|
-
const {
|
|
434
|
+
const { loadYesterdaySnapshot, diffSnapshots, buildDeltaSection } =
|
|
305
435
|
await import('./lib/triage-history.mjs');
|
|
306
|
-
saveTriageSnapshot(sorted, { profile: profileName ?? 'default', configDir });
|
|
307
436
|
const yesterday = loadYesterdaySnapshot({ profile: profileName ?? 'default', configDir });
|
|
308
437
|
if (yesterday) {
|
|
309
|
-
const deltas = diffSnapshots(sorted, yesterday);
|
|
438
|
+
const deltas = diffSnapshots(sorted, yesterday.tickets);
|
|
310
439
|
delta = buildDeltaSection(deltas) || null;
|
|
311
440
|
}
|
|
312
441
|
} catch { /* non-fatal — digest still sends */ }
|
|
@@ -314,11 +443,14 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
314
443
|
await deliverer({
|
|
315
444
|
profile: profileName ?? 'default',
|
|
316
445
|
staleDays,
|
|
317
|
-
summary: {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
446
|
+
summary: (() => {
|
|
447
|
+
let needsResponse = 0, aging = 0;
|
|
448
|
+
for (const t of sorted) {
|
|
449
|
+
if (t.urgency === 'needs-response') needsResponse++;
|
|
450
|
+
else if (t.urgency === 'aging') aging++;
|
|
451
|
+
}
|
|
452
|
+
return { total: sorted.length, needsResponse, aging };
|
|
453
|
+
})(),
|
|
322
454
|
tickets: sorted,
|
|
323
455
|
delta,
|
|
324
456
|
}, { cliToken: digestCliToken });
|
|
@@ -372,7 +504,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
372
504
|
}
|
|
373
505
|
|
|
374
506
|
// Interactive mode: TTY + not --plain + not --static
|
|
375
|
-
const wantInteractive = process.stdout.isTTY && !args.includes('--plain') && !args.includes('--static');
|
|
507
|
+
const wantInteractive = process.stdout.isTTY && !args.includes('--plain') && !args.includes('--static') && !saveArg;
|
|
376
508
|
if (wantInteractive && process.stdin.setRawMode) {
|
|
377
509
|
const result = await runInteractiveList(sorted, { baseUrl: conn.baseUrl, staleDays, styled: true });
|
|
378
510
|
if (result === 'switch') {
|
|
@@ -386,7 +518,17 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
386
518
|
const summary = useStyled
|
|
387
519
|
? styleTriageSummary(sorted, { styled: true, staleDays, baseUrl: conn.baseUrl })
|
|
388
520
|
: assembleTriageSummary(sorted, { staleDays, baseUrl: conn.baseUrl });
|
|
389
|
-
|
|
521
|
+
|
|
522
|
+
// --save=FILE: write ANSI-stripped output to file
|
|
523
|
+
if (saveArg) {
|
|
524
|
+
const resolvedSave = resolvePath(saveArg);
|
|
525
|
+
mkdirSync(dirname(resolvedSave), { recursive: true });
|
|
526
|
+
const plain = stripAnsi(summary);
|
|
527
|
+
writeFileSync(resolvedSave, plain + '\n', 'utf8');
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const printFn = opts.print ?? ((s) => process.stdout.write(s));
|
|
531
|
+
printFn(summary + '\n');
|
|
390
532
|
}
|
|
391
533
|
|
|
392
534
|
// Run if invoked directly
|
|
@@ -85,3 +85,6 @@ export const brightYellow = defaultStyler.brightYellow;
|
|
|
85
85
|
export const brightCyan = defaultStyler.brightCyan;
|
|
86
86
|
export const brand = defaultStyler.brand;
|
|
87
87
|
export const isStyled = () => defaultStyler.enabled;
|
|
88
|
+
|
|
89
|
+
export const ANSI_RE = /\[[0-9;]*m/g;
|
|
90
|
+
export function stripAnsi(s) { return s.replace(ANSI_RE, ''); }
|
|
@@ -75,8 +75,42 @@ export function findLastEffectiveComment(comments, currentUser) {
|
|
|
75
75
|
return { comment: null, fromCurrentUser: false };
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Apply a single custom rule against a ticket.
|
|
80
|
+
* Returns 'force-urgent' | 'ignore' | null.
|
|
81
|
+
* A rule must have a valid `match` object and a known `action` to fire.
|
|
82
|
+
*/
|
|
83
|
+
function applyCustomRule(ticket, rule) {
|
|
84
|
+
if (!rule || typeof rule.match !== 'object' || !rule.match) return null;
|
|
85
|
+
if (rule.action !== 'force-urgent' && rule.action !== 'ignore') return null;
|
|
86
|
+
|
|
87
|
+
const { priority, label, status, keyPrefix } = rule.match;
|
|
88
|
+
if (priority !== undefined && ticket.priority !== priority) return null;
|
|
89
|
+
if (status !== undefined && ticket.status !== status) return null;
|
|
90
|
+
if (label !== undefined) {
|
|
91
|
+
const labels = Array.isArray(ticket.labels) ? ticket.labels : [];
|
|
92
|
+
if (!labels.includes(label)) return null;
|
|
93
|
+
}
|
|
94
|
+
if (keyPrefix !== undefined && !String(ticket.key).startsWith(keyPrefix)) return null;
|
|
95
|
+
|
|
96
|
+
return rule.action;
|
|
97
|
+
}
|
|
98
|
+
|
|
78
99
|
export function scoreAttention(ticket, currentUser, opts = {}) {
|
|
79
|
-
const { staleDays = 5, now = new Date() } = opts;
|
|
100
|
+
const { staleDays = 5, now = new Date(), customRules } = opts;
|
|
101
|
+
|
|
102
|
+
// Custom rules evaluated first — first match wins
|
|
103
|
+
if (Array.isArray(customRules)) {
|
|
104
|
+
for (const rule of customRules) {
|
|
105
|
+
const action = applyCustomRule(ticket, rule);
|
|
106
|
+
if (action === 'ignore') {
|
|
107
|
+
return { ticketKey: ticket.key, summary: ticket.summary, status: ticket.status, urgency: 'ignore', reason: rule.reason ?? 'Ignored by custom rule', lastComment: null };
|
|
108
|
+
}
|
|
109
|
+
if (action === 'force-urgent') {
|
|
110
|
+
return { ticketKey: ticket.key, summary: ticket.summary, status: ticket.status, urgency: 'needs-response', reason: rule.reason ?? 'Flagged by custom rule', lastComment: null };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
80
114
|
|
|
81
115
|
const { comment: lastComment, fromCurrentUser } = findLastEffectiveComment(
|
|
82
116
|
ticket.comments || [], currentUser
|
|
@@ -145,8 +179,8 @@ export function sortByUrgency(scores) {
|
|
|
145
179
|
const orderDiff = URGENCY_ORDER[a.urgency] - URGENCY_ORDER[b.urgency];
|
|
146
180
|
if (orderDiff !== 0) return orderDiff;
|
|
147
181
|
// Within same urgency, sort by most recent activity (lastComment date or ticket updated)
|
|
148
|
-
const dateA = a.lastComment?.created ? new Date(a.lastComment.created) :
|
|
149
|
-
const dateB = b.lastComment?.created ? new Date(b.lastComment.created) :
|
|
182
|
+
const dateA = a.lastComment?.created ? new Date(a.lastComment.created).getTime() : 0;
|
|
183
|
+
const dateB = b.lastComment?.created ? new Date(b.lastComment.created).getTime() : 0;
|
|
150
184
|
return dateB - dateA; // most recent first
|
|
151
185
|
});
|
|
152
186
|
}
|
|
@@ -110,6 +110,10 @@ export function parseCommand(args) {
|
|
|
110
110
|
return { command: 'collisions', args: args.slice(1) };
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
if (first === 'history') {
|
|
114
|
+
return { command: 'history', args: args.slice(1) };
|
|
115
|
+
}
|
|
116
|
+
|
|
113
117
|
// Anything that looks like a ticket key or any non-flag arg → fetch
|
|
114
118
|
return { command: 'fetch', args };
|
|
115
119
|
}
|
|
@@ -42,6 +42,7 @@ export function printHelp({ stream = process.stdout } = {}) {
|
|
|
42
42
|
` ${s.brand('ticketlens')} review ${s.dim('[--branch=BRANCH]')} Code-review context brief from current branch`,
|
|
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
|
+
` ${s.brand('ticketlens')} history ${s.dim('<TICKET-KEY>')} Urgency timeline for a ticket ${s.dim('[Pro]')}`,
|
|
45
46
|
'',
|
|
46
47
|
` ${s.brand('ticketlens')} delete ${s.dim('<PROFILE-NAME>')} Remove a profile`,
|
|
47
48
|
` ${s.brand('ticketlens')} activate ${s.dim('<KEY>')} Activate a license key`,
|
|
@@ -76,9 +77,14 @@ export function printHelp({ stream = process.stdout } = {}) {
|
|
|
76
77
|
` ${s.brand('--status')}=${s.dim('X,Y')} Override statuses to scan`,
|
|
77
78
|
` ${s.brand('--assignee')}=${s.dim('NAME')} Triage another dev's tickets ${s.dim('[Team]')}`,
|
|
78
79
|
` ${s.brand('--sprint')}=${s.dim('NAME')} Filter by sprint name ${s.dim('[Team]')}`,
|
|
80
|
+
` ${s.brand('--project')}=${s.dim('PROJ')} Filter by Jira project key ${s.dim('[Team]')}`,
|
|
81
|
+
` ${s.brand('--label')}=${s.dim('X,Y')} Filter by label(s) ${s.dim('[Team]')}`,
|
|
82
|
+
` ${s.brand('--priority')}=${s.dim('LEVEL')} Filter by priority ${s.dim('(e.g. High, Blocker) [Team]')}`,
|
|
79
83
|
` ${s.brand('--export')}=${s.dim('FORMAT')} Export results to file ${s.dim('(csv|json) [Team]')}`,
|
|
80
84
|
` ${s.brand('--push')} Push snapshot to Console queue ${s.dim('[Team]')}`,
|
|
81
85
|
` ${s.brand('--share')} Generate a 24h share URL ${s.dim('(no login required) [Team]')}`,
|
|
86
|
+
` ${s.brand('--all')} Triage all configured profiles at once ${s.dim('[Pro]')}`,
|
|
87
|
+
` ${s.brand('--save')}=${s.dim('FILE')} Save ANSI-stripped output to file ${s.dim('[Pro]')}`,
|
|
82
88
|
` ${s.brand('--digest')} POST scored results to digest endpoint ${s.dim('[Pro]')}`,
|
|
83
89
|
` ${s.brand('--static')} Static table output ${s.dim('(skip interactive mode)')}`,
|
|
84
90
|
` ${s.brand('--plain')} Plain markdown output ${s.dim('(for piping / LLM)')}`,
|
|
@@ -546,9 +552,14 @@ export function printTriageHelp({ stream = process.stdout } = {}) {
|
|
|
546
552
|
` ${s.brand('--status')}=${s.dim('X,Y')} Override statuses to scan`,
|
|
547
553
|
` ${s.brand('--assignee')}=${s.dim('NAME')} Triage another dev's tickets ${s.dim('[Team]')}`,
|
|
548
554
|
` ${s.brand('--sprint')}=${s.dim('NAME')} Filter by sprint name ${s.dim('[Team]')}`,
|
|
555
|
+
` ${s.brand('--project')}=${s.dim('PROJ')} Filter by Jira project key ${s.dim('[Team]')}`,
|
|
556
|
+
` ${s.brand('--label')}=${s.dim('X,Y')} Filter by label(s) ${s.dim('[Team]')}`,
|
|
557
|
+
` ${s.brand('--priority')}=${s.dim('LEVEL')} Filter by priority ${s.dim('(e.g. High, Blocker) [Team]')}`,
|
|
549
558
|
` ${s.brand('--export')}=${s.dim('FORMAT')} Export results to file ${s.dim('(csv|json) [Team]')}`,
|
|
550
559
|
` ${s.brand('--push')} Push snapshot to Console queue ${s.dim('[Team]')}`,
|
|
551
560
|
` ${s.brand('--share')} Generate a 24h share URL ${s.dim('(no login required) [Team]')}`,
|
|
561
|
+
` ${s.brand('--all')} Triage all configured profiles at once ${s.dim('[Pro]')}`,
|
|
562
|
+
` ${s.brand('--save')}=${s.dim('FILE')} Save ANSI-stripped output to file ${s.dim('[Pro]')}`,
|
|
552
563
|
` ${s.brand('--digest')} POST scored results to digest endpoint ${s.dim('[Pro]')}`,
|
|
553
564
|
` ${s.brand('--static')} Static table output ${s.dim('(skip interactive mode)')}`,
|
|
554
565
|
` ${s.brand('--plain')} Plain markdown output`,
|
|
@@ -560,6 +571,8 @@ export function printTriageHelp({ stream = process.stdout } = {}) {
|
|
|
560
571
|
` ${s.dim('$')} ticketlens triage --profile=acme`,
|
|
561
572
|
` ${s.dim('$')} ticketlens triage --stale=3 --status="Code Review,QA Testing"`,
|
|
562
573
|
` ${s.dim('$')} ticketlens triage --assignee="Jane Dev" --sprint="Sprint 12"`,
|
|
574
|
+
` ${s.dim('$')} ticketlens triage --project=MYPROJ --priority=High`,
|
|
575
|
+
` ${s.dim('$')} ticketlens triage --label=Bug,P1`,
|
|
563
576
|
` ${s.dim('$')} ticketlens triage --static`,
|
|
564
577
|
'',
|
|
565
578
|
` ${s.bold('INTERACTIVE MODE')}`,
|
|
@@ -231,6 +231,7 @@ export function resolveConnection(ticketKey, opts = {}) {
|
|
|
231
231
|
pat: profileCreds.pat || null,
|
|
232
232
|
triageStatuses: profile.triageStatuses || null,
|
|
233
233
|
ticketPrefixes: profile.ticketPrefixes || null,
|
|
234
|
+
attentionRules: profile.attentionRules ?? null,
|
|
234
235
|
source: 'profile',
|
|
235
236
|
profileName: profile.name,
|
|
236
237
|
};
|
|
@@ -7,13 +7,37 @@ import { apiBase, warnIfInsecure } from './api-utils.mjs';
|
|
|
7
7
|
|
|
8
8
|
const SCHEDULE_PATH = '/v1/schedule';
|
|
9
9
|
|
|
10
|
+
// Safe path: printable ASCII, no shell metacharacters or XML special chars.
|
|
11
|
+
// Allows: letters, digits, /, ., _, -, ~, space (none of which are shell-special in this context).
|
|
12
|
+
const SAFE_PATH_RE = /^[A-Za-z0-9/._~-]+$/;
|
|
13
|
+
|
|
14
|
+
function xmlEscape(s) {
|
|
15
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function validateOutputFile(outputFile) {
|
|
19
|
+
if (!outputFile || !SAFE_PATH_RE.test(outputFile)) {
|
|
20
|
+
throw new Error(`Invalid output file path: must contain only letters, digits, /, ., _, -, ~ (no spaces or shell metacharacters)`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function validateTime(hour, minute) {
|
|
25
|
+
if (!Number.isInteger(hour) || !Number.isInteger(minute) ||
|
|
26
|
+
hour < 0 || hour > 23 || minute < 0 || minute > 59) {
|
|
27
|
+
throw new Error(`Invalid time: hour must be 0–23, minute must be 0–59`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function parseHHMM(time) {
|
|
32
|
+
const [hourStr, minuteStr] = time.split(':');
|
|
33
|
+
return { hour: parseInt(hourStr, 10), minute: parseInt(minuteStr, 10) };
|
|
34
|
+
}
|
|
35
|
+
|
|
10
36
|
/**
|
|
11
37
|
* Build a macOS LaunchAgent plist string.
|
|
12
38
|
*/
|
|
13
39
|
export function buildPlist({ hour, minute, ticketlensBin }) {
|
|
14
|
-
|
|
15
|
-
throw new Error('hour and minute must be integers');
|
|
16
|
-
}
|
|
40
|
+
validateTime(hour, minute);
|
|
17
41
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
18
42
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
19
43
|
<plist version="1.0">
|
|
@@ -68,9 +92,7 @@ export async function runScheduleWizard({
|
|
|
68
92
|
return { ok: false };
|
|
69
93
|
}
|
|
70
94
|
const { time, email, timezone } = answers;
|
|
71
|
-
const
|
|
72
|
-
const hour = parseInt(hourStr, 10);
|
|
73
|
-
const minute = parseInt(minuteStr, 10);
|
|
95
|
+
const { hour, minute } = parseHHMM(time);
|
|
74
96
|
|
|
75
97
|
try {
|
|
76
98
|
const res = await fetcher(`${apiBase()}${SCHEDULE_PATH}`, {
|
|
@@ -146,7 +168,8 @@ export async function runScheduleStop({
|
|
|
146
168
|
try { unlinkSync(plistPath); } catch { /* already removed */ }
|
|
147
169
|
} else {
|
|
148
170
|
const existing = spawnSync('crontab', ['-l'], { encoding: 'utf8' }).stdout ?? '';
|
|
149
|
-
|
|
171
|
+
// Remove both cloud-digest and local-save ticketlens cron lines
|
|
172
|
+
const updated = existing.replace(/.*ticketlens triage --(digest|'--save=).*\n?/g, '');
|
|
150
173
|
const tmp = `/tmp/ticketlens-crontab-${Date.now()}`;
|
|
151
174
|
writeFileSync(tmp, updated, 'utf8');
|
|
152
175
|
spawnSync('crontab', [tmp], { encoding: 'utf8' });
|
|
@@ -189,10 +212,93 @@ export async function runScheduleStatus({
|
|
|
189
212
|
print('\n');
|
|
190
213
|
}
|
|
191
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Build a local-only cron/plist entry that runs triage --save=FILE.
|
|
217
|
+
* No cloud auth required — purely local scheduling.
|
|
218
|
+
*/
|
|
219
|
+
export function buildLocalPlist({ hour, minute, ticketlensBin, outputFile }) {
|
|
220
|
+
const safeFile = xmlEscape(outputFile);
|
|
221
|
+
const safeBin = xmlEscape(ticketlensBin);
|
|
222
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
223
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
224
|
+
<plist version="1.0">
|
|
225
|
+
<dict>
|
|
226
|
+
<key>Label</key>
|
|
227
|
+
<string>io.ticketlens.triage-local</string>
|
|
228
|
+
<key>ProgramArguments</key>
|
|
229
|
+
<array>
|
|
230
|
+
<string>${safeBin}</string>
|
|
231
|
+
<string>triage</string>
|
|
232
|
+
<string>--save=${safeFile}</string>
|
|
233
|
+
</array>
|
|
234
|
+
<key>StartCalendarInterval</key>
|
|
235
|
+
<dict>
|
|
236
|
+
<key>Hour</key>
|
|
237
|
+
<integer>${hour}</integer>
|
|
238
|
+
<key>Minute</key>
|
|
239
|
+
<integer>${minute}</integer>
|
|
240
|
+
</dict>
|
|
241
|
+
<key>StandardOutPath</key>
|
|
242
|
+
<string>${safeFile}</string>
|
|
243
|
+
<key>StandardErrorPath</key>
|
|
244
|
+
<string>${safeFile}.err</string>
|
|
245
|
+
</dict>
|
|
246
|
+
</plist>`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function buildLocalCronLine({ hour, minute, ticketlensBin, outputFile }) {
|
|
250
|
+
// Single-quote the --save argument to prevent shell word-splitting.
|
|
251
|
+
// SAFE_PATH_RE already guarantees no single-quote chars in outputFile.
|
|
252
|
+
return `${minute} ${hour} * * * ${ticketlensBin} triage '--save=${outputFile}' >> '${outputFile}.err' 2>&1`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Set up a local-only scheduled triage (no Console auth, no cloud push).
|
|
257
|
+
* Writes a cron/LaunchAgent entry that runs `ticketlens triage --save=FILE`.
|
|
258
|
+
*/
|
|
259
|
+
export async function runScheduleLocal({
|
|
260
|
+
answers,
|
|
261
|
+
platform = osPlatform(),
|
|
262
|
+
writeLocalJob = defaultWriteLocalJob,
|
|
263
|
+
print = s => process.stdout.write(s),
|
|
264
|
+
// fetcher is accepted but intentionally ignored — no network calls
|
|
265
|
+
}) {
|
|
266
|
+
const { time, outputFile } = answers ?? {};
|
|
267
|
+
if (!time || !outputFile) {
|
|
268
|
+
print(` ${red('✗')} Local schedule requires a time and output file.\n`);
|
|
269
|
+
return { ok: false };
|
|
270
|
+
}
|
|
271
|
+
const { hour, minute } = parseHHMM(time);
|
|
272
|
+
try {
|
|
273
|
+
validateTime(hour, minute);
|
|
274
|
+
validateOutputFile(outputFile);
|
|
275
|
+
} catch (err) {
|
|
276
|
+
print(` ${red('✗')} ${err.message}\n`);
|
|
277
|
+
return { ok: false };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const ticketlensBin = resolveTicketlensBin();
|
|
281
|
+
const content = platform === 'darwin'
|
|
282
|
+
? buildLocalPlist({ hour, minute, ticketlensBin, outputFile })
|
|
283
|
+
: buildLocalCronLine({ hour, minute, ticketlensBin, outputFile });
|
|
284
|
+
|
|
285
|
+
writeLocalJob(content, platform);
|
|
286
|
+
|
|
287
|
+
print(`\n ${green('✔')} ${bold('Local triage scheduled')}\n`);
|
|
288
|
+
print(` ${dim('Time: ')} ${cyan(time)} daily\n`);
|
|
289
|
+
print(` ${dim('Output: ')} ${cyan(outputFile)}\n\n`);
|
|
290
|
+
|
|
291
|
+
return { ok: true, time, outputFile };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
let _ticketlensBin;
|
|
192
295
|
function resolveTicketlensBin() {
|
|
296
|
+
if (_ticketlensBin) return _ticketlensBin;
|
|
193
297
|
const which = spawnSync('which', ['ticketlens'], { encoding: 'utf8' });
|
|
194
|
-
|
|
195
|
-
|
|
298
|
+
_ticketlensBin = (which.status === 0 && which.stdout.trim())
|
|
299
|
+
? which.stdout.trim()
|
|
300
|
+
: `${homedir()}/.npm/bin/ticketlens`;
|
|
301
|
+
return _ticketlensBin;
|
|
196
302
|
}
|
|
197
303
|
|
|
198
304
|
function defaultWriteLocalJob(content, platform) {
|
|
@@ -204,7 +310,8 @@ function defaultWriteLocalJob(content, platform) {
|
|
|
204
310
|
spawnSync('launchctl', ['load', plistPath], { encoding: 'utf8' });
|
|
205
311
|
} else {
|
|
206
312
|
const existing = spawnSync('crontab', ['-l'], { encoding: 'utf8' }).stdout ?? '';
|
|
207
|
-
|
|
313
|
+
// Remove both cloud-digest and local-save ticketlens cron lines before re-adding
|
|
314
|
+
const updated = existing.replace(/.*ticketlens triage --(digest|'--save=).*\n?/g, '').trimEnd() + '\n' + content + '\n';
|
|
208
315
|
const tmp = `/tmp/ticketlens-crontab-${Date.now()}`;
|
|
209
316
|
writeFileSync(tmp, updated, 'utf8');
|
|
210
317
|
spawnSync('crontab', [tmp], { encoding: 'utf8' });
|
|
@@ -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
|
}
|
|
@@ -162,3 +176,203 @@ export function buildDeltaSection(deltas) {
|
|
|
162
176
|
|
|
163
177
|
return lines.join('\n') + '\n';
|
|
164
178
|
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Query the full history for a single ticket key across all dated snapshots.
|
|
182
|
+
*
|
|
183
|
+
* Reads ~/.ticketlens/triage-history/YYYY-MM-DD/*.json, finds entries matching
|
|
184
|
+
* the given ticketKey, and returns a chronologically sorted timeline.
|
|
185
|
+
* Entries with urgency oscillating in the same direction on consecutive days
|
|
186
|
+
* are flagged with `bounced: true`.
|
|
187
|
+
*
|
|
188
|
+
* @param {string} ticketKey
|
|
189
|
+
* @param {object} [opts]
|
|
190
|
+
* @param {string} [opts.configDir]
|
|
191
|
+
* @param {object} [opts.fsModule]
|
|
192
|
+
* @returns {{ date: string, profile: string, urgency: string, status: string, reason: string, bounced: boolean }[]}
|
|
193
|
+
*/
|
|
194
|
+
export function queryTicketHistory(ticketKey, {
|
|
195
|
+
configDir = DEFAULT_CONFIG_DIR,
|
|
196
|
+
fsModule = defaultFs,
|
|
197
|
+
} = {}) {
|
|
198
|
+
const histDir = join(configDir, 'triage-history');
|
|
199
|
+
if (!fsModule.existsSync(histDir)) return [];
|
|
200
|
+
|
|
201
|
+
const dates = fsModule.readdirSync(histDir)
|
|
202
|
+
.filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d))
|
|
203
|
+
.sort(); // lexicographic = chronological for YYYY-MM-DD
|
|
204
|
+
|
|
205
|
+
const entries = [];
|
|
206
|
+
for (const date of dates) {
|
|
207
|
+
const dayDir = join(histDir, date);
|
|
208
|
+
let profileFiles;
|
|
209
|
+
try {
|
|
210
|
+
profileFiles = fsModule.readdirSync(dayDir).filter(f => f.endsWith('.json'));
|
|
211
|
+
} catch {
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
for (const file of profileFiles) {
|
|
215
|
+
const profile = file.slice(0, -5); // strip .json
|
|
216
|
+
let tickets;
|
|
217
|
+
try {
|
|
218
|
+
const raw = JSON.parse(fsModule.readFileSync(join(dayDir, file), 'utf8'));
|
|
219
|
+
tickets = Array.isArray(raw) ? raw : (raw.tickets ?? []);
|
|
220
|
+
} catch {
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
const found = tickets.find(t => t.ticketKey === ticketKey);
|
|
224
|
+
if (found) {
|
|
225
|
+
entries.push({
|
|
226
|
+
date,
|
|
227
|
+
profile,
|
|
228
|
+
urgency: found.urgency ?? 'unknown',
|
|
229
|
+
status: found.status ?? '',
|
|
230
|
+
reason: found.reason ?? '',
|
|
231
|
+
bounced: false,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Detect bounces: flag entries where urgency changed direction on consecutive days
|
|
238
|
+
for (let i = 1; i < entries.length; i++) {
|
|
239
|
+
const prev = entries[i - 1];
|
|
240
|
+
const cur = entries[i];
|
|
241
|
+
if (cur.urgency !== prev.urgency && cur.profile === prev.profile) {
|
|
242
|
+
cur.bounced = true;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return entries;
|
|
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
|
+
}
|