ticketlens 0.6.0 → 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 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]
@@ -519,6 +522,10 @@ ticketlens triage --static # Static table output (no interacti
519
522
  ticketlens triage --plain # Plain markdown — pipe to LLM or file
520
523
  ticketlens triage --assignee="Jane Dev" # View another dev's tickets [Team]
521
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]
522
529
  ticketlens triage --assignee="Jane Dev" --sprint="Sprint 12" # Combined [Team]
523
530
  ticketlens triage --export=csv # Export to CSV [Team]
524
531
  ticketlens triage --export=json # Export to JSON [Team]
@@ -701,6 +708,9 @@ Pro also unlocks configurable brief cache TTL per profile — set `cacheTtl` to
701
708
  ```bash
702
709
  ticketlens triage --assignee="Jane Dev" # View another dev's tickets
703
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
704
714
  ticketlens triage --export=csv # Export triage to CSV for standups and reports
705
715
  ticketlens triage --export=json # Machine-readable export for dashboards
706
716
  ticketlens triage --push # Push snapshot to the Console queue
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Jira CLI for developers — fetch ticket context, triage your queue, and stop tab-switching. Zero dependencies, all local.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,4 +1,4 @@
1
- <!-- jtb-skill-version: 0.6.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.
@@ -29,6 +29,9 @@ 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)
@@ -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=', '--assignee=', '--sprint=', '--export=', '--digest', '--push', '--share', '--all', '--save='],
81
- { hints: ['--depth=', '--no-attachments', '--no-cache'] } // fetch-only flags — shown as hints, not applied
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 = args.find(a => a.startsWith('--status='));
87
+ const statusArg = args.find(a => a.startsWith('--status='));
93
88
  const assigneeArg = args.find(a => a.startsWith('--assignee='));
94
- const sprintArg = args.find(a => a.startsWith('--sprint='));
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 = statuses.map(s => `"${escapeJql(s)}"`).join(',');
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 jql = `${assigneeClause} AND status IN (${statusList})${sprintClause} ORDER BY updated DESC`;
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 */ }
@@ -77,6 +77,9 @@ export function printHelp({ stream = process.stdout } = {}) {
77
77
  ` ${s.brand('--status')}=${s.dim('X,Y')} Override statuses to scan`,
78
78
  ` ${s.brand('--assignee')}=${s.dim('NAME')} Triage another dev's tickets ${s.dim('[Team]')}`,
79
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]')}`,
80
83
  ` ${s.brand('--export')}=${s.dim('FORMAT')} Export results to file ${s.dim('(csv|json) [Team]')}`,
81
84
  ` ${s.brand('--push')} Push snapshot to Console queue ${s.dim('[Team]')}`,
82
85
  ` ${s.brand('--share')} Generate a 24h share URL ${s.dim('(no login required) [Team]')}`,
@@ -549,6 +552,9 @@ export function printTriageHelp({ stream = process.stdout } = {}) {
549
552
  ` ${s.brand('--status')}=${s.dim('X,Y')} Override statuses to scan`,
550
553
  ` ${s.brand('--assignee')}=${s.dim('NAME')} Triage another dev's tickets ${s.dim('[Team]')}`,
551
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]')}`,
552
558
  ` ${s.brand('--export')}=${s.dim('FORMAT')} Export results to file ${s.dim('(csv|json) [Team]')}`,
553
559
  ` ${s.brand('--push')} Push snapshot to Console queue ${s.dim('[Team]')}`,
554
560
  ` ${s.brand('--share')} Generate a 24h share URL ${s.dim('(no login required) [Team]')}`,
@@ -565,6 +571,8 @@ export function printTriageHelp({ stream = process.stdout } = {}) {
565
571
  ` ${s.dim('$')} ticketlens triage --profile=acme`,
566
572
  ` ${s.dim('$')} ticketlens triage --stale=3 --status="Code Review,QA Testing"`,
567
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`,
568
576
  ` ${s.dim('$')} ticketlens triage --static`,
569
577
  '',
570
578
  ` ${s.bold('INTERACTIVE MODE')}`,
@@ -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
- fsModule.writeFileSync(filePath, JSON.stringify(tickets, null, 2), 'utf8');
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
- return JSON.parse(raw);
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
- tickets = JSON.parse(fsModule.readFileSync(join(dayDir, file), 'utf8'));
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 = Array.isArray(tickets) && tickets.find(t => t.ticketKey === ticketKey);
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
+ }