ticketlens 0.1.15 → 0.1.17

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
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": {
@@ -145,6 +145,135 @@ async function applySummarize(brief, args, opts, configDir, conn, licensedFn, up
145
145
  }
146
146
  }
147
147
 
148
+ function makeSpinner(s) {
149
+ // setInterval won't fire while spawnSync blocks the event loop, so we draw
150
+ // synchronously on update() and only use setInterval during async fetch phases.
151
+ if (!process.stderr.isTTY) return { update: () => {}, startAnim: () => {}, done: () => {} };
152
+ const frames = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
153
+ let fi = 0, msg = '', lastW = 0, timerId = null, finished = false;
154
+
155
+ const draw = () => {
156
+ const raw = ` ${s.brand(frames[fi % frames.length])} ${s.dim(msg)}`;
157
+ const vis = raw.replace(/\x1b\[[0-9;]*m/g, '');
158
+ process.stderr.write(`\r${raw}${' '.repeat(Math.max(0, lastW - vis.length))}`);
159
+ lastW = vis.length;
160
+ };
161
+
162
+ return {
163
+ update(m) { if (!finished) { fi++; msg = m; draw(); } },
164
+ startAnim() {
165
+ if (finished || timerId) return;
166
+ timerId = setInterval(() => { if (!finished) { fi++; draw(); } }, 80);
167
+ },
168
+ done() {
169
+ if (finished) return;
170
+ finished = true;
171
+ if (timerId) { clearInterval(timerId); timerId = null; }
172
+ process.stderr.write('\r\x1b[2K');
173
+ },
174
+ };
175
+ }
176
+
177
+ function styleReviewMd(md, s) {
178
+ if (!s.enabled) return md;
179
+ const lines = md.split('\n');
180
+ const out = [];
181
+ let section = '';
182
+
183
+ for (const line of lines) {
184
+ if (line === '## PR Review Context') {
185
+ out.push('');
186
+ out.push(` ${s.brand(s.bold('◆ PR Review Context'))}`);
187
+ out.push(` ${s.dim('─'.repeat(50))}`);
188
+ section = 'header';
189
+ continue;
190
+ }
191
+
192
+ if (line.startsWith('### ')) {
193
+ const title = line.slice(4);
194
+ const fm = title.match(/^Changed files \((\d+)\)$/);
195
+ if (fm) {
196
+ out.push('');
197
+ out.push(` ${s.bold('Changed files')} ${s.dim('(')}${s.yellow(fm[1])}${s.dim(')')}`);
198
+ section = 'changed-files';
199
+ continue;
200
+ }
201
+ const icons = { Branch: '⎇ ', 'Ticket context': '◉ ', 'Requirements coverage': '✦ ', 'Review focus': '▲ ' };
202
+ const icon = icons[title] ?? '';
203
+ out.push('');
204
+ out.push(` ${s.bold(icon + title)}`);
205
+ section = title.toLowerCase().replace(/\s+/g, '-');
206
+ continue;
207
+ }
208
+
209
+ if (line.startsWith('#### ')) {
210
+ const inner = line.slice(5);
211
+ const covM = inner.match(/^([A-Z][A-Z0-9]+-\d+) \((\d+)%\)$/);
212
+ if (covM) {
213
+ const pct = parseInt(covM[2], 10);
214
+ const pctFmt = pct >= 75 ? s.green(`${pct}%`) : pct >= 50 ? s.yellow(`${pct}%`) : s.red(`${pct}%`);
215
+ out.push(` ${s.cyan(s.bold(covM[1]))} ${s.dim('coverage:')} ${pctFmt}`);
216
+ continue;
217
+ }
218
+ const tkM = inner.match(/^([A-Z][A-Z0-9]+-\d+)[: —–-]+(.*)$/);
219
+ if (tkM) {
220
+ out.push('');
221
+ out.push(` ${s.cyan(s.bold(tkM[1]))} ${s.dim(tkM[2].trim())}`);
222
+ continue;
223
+ }
224
+ out.push(line);
225
+ continue;
226
+ }
227
+
228
+ if (line.match(/^`[^`]+` → `[^`]+`$/)) {
229
+ const [h, b] = line.split(' → ');
230
+ out.push(` ${s.cyan(h.replace(/`/g, ''))} ${s.dim('→')} ${s.dim(b.replace(/`/g, ''))}`);
231
+ continue;
232
+ }
233
+ if (line.match(/^→ `[^`]+`$/)) {
234
+ out.push(` ${s.dim('(detached HEAD)')} ${s.dim('→')} ${s.dim(line.slice(4).replace(/`/g, ''))}`);
235
+ continue;
236
+ }
237
+
238
+ if (line.startsWith('- ✔ ')) {
239
+ const text = line.slice(4);
240
+ const di = text.lastIndexOf(' — ');
241
+ if (di > 0) out.push(` ${s.green('✔')} ${text.slice(0, di)}${s.dim(' — ' + text.slice(di + 3))}`);
242
+ else out.push(` ${s.green('✔')} ${text}`);
243
+ continue;
244
+ }
245
+ if (line.startsWith('- ✖ ')) { out.push(` ${s.red('✖')} ${line.slice(4)}`); continue; }
246
+ if (line.startsWith('- ~ ')) { out.push(` ${s.yellow('~')} ${s.dim(line.slice(4))}`); continue; }
247
+
248
+ if (line.startsWith('- ')) {
249
+ out.push(section === 'changed-files'
250
+ ? ` ${s.dim('·')} ${s.dim(line.slice(2))}`
251
+ : ` ${s.dim('·')} ${line.slice(2)}`);
252
+ continue;
253
+ }
254
+
255
+ if (line.startsWith('_') && line.endsWith('_') && line.length > 2) {
256
+ const inner = line.slice(1, -1);
257
+ if (inner.includes('No linked tickets found')) {
258
+ out.push(` ${s.yellow('○')} ${s.dim(inner)}`);
259
+ } else if (inner.includes('Requirements coverage analysis requires')) {
260
+ out.push(` ${s.yellow('⚡')} ${s.dim(inner)}`);
261
+ } else if (inner.includes('Generated by TicketLens')) {
262
+ out.push(s.dim(inner));
263
+ } else {
264
+ out.push(` ${s.dim(inner)}`);
265
+ }
266
+ continue;
267
+ }
268
+
269
+ if (line === '---') { out.push(` ${s.dim('─'.repeat(50))}`); continue; }
270
+
271
+ out.push(line);
272
+ }
273
+
274
+ return out.join('\n');
275
+ }
276
+
148
277
  const RETRY_OPTIONS = [
149
278
  { label: 'Retry', sublabel: 'Try again — e.g. VPN just connected', value: 'retry' },
150
279
  { label: 'Switch profile', sublabel: 'Use a different Jira profile', value: 'switch' },
@@ -351,10 +480,51 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
351
480
  const execFn = opts.execFn ?? spawnSync;
352
481
  const cwd = process.cwd();
353
482
 
483
+ const sErr = createStyler({ isTTY: process.stderr.isTTY });
484
+
485
+ // Validate flags before any git work
486
+ const reviewFlags = args.slice(1);
487
+ const VALID_REVIEW_FLAG = /^(--base=.+|--profile=.+)$/;
488
+ for (const flag of reviewFlags) {
489
+ if (!flag.startsWith('-')) continue;
490
+ if (VALID_REVIEW_FLAG.test(flag)) continue;
491
+ // Detect --profile-NAME typo (dash instead of =)
492
+ const profileDashM = flag.match(/^--profile-(.+)$/);
493
+ if (profileDashM) {
494
+ process.stderr.write(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Did you mean ${sErr.cyan(`--profile=${profileDashM[1]}`)}?\n`);
495
+ process.exitCode = 1;
496
+ return;
497
+ }
498
+ const baseDashM = flag.match(/^--base-(.+)$/);
499
+ if (baseDashM) {
500
+ process.stderr.write(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Did you mean ${sErr.cyan(`--base=${baseDashM[1]}`)}?\n`);
501
+ process.exitCode = 1;
502
+ return;
503
+ }
504
+ process.stderr.write(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Usage: ${sErr.cyan('ticketlens review [--base=BRANCH] [--profile=NAME]')}\n`);
505
+ process.exitCode = 1;
506
+ return;
507
+ }
508
+
509
+ const spinner = makeSpinner(sErr);
510
+ const stderrNotes = [];
511
+
354
512
  // Resolve base branch: --base=BRANCH or auto-detect main/master/develop
355
- const baseArg = args.find(a => a.startsWith('--base='));
513
+ const baseArg = reviewFlags.find(a => a.startsWith('--base='));
356
514
  let baseBranch = baseArg ? baseArg.split('=')[1] : null;
357
- if (!baseBranch) {
515
+
516
+ spinner.update('Scanning branch…');
517
+
518
+ if (baseBranch) {
519
+ // Explicit --base: verify the branch exists
520
+ const verifyR = execFn('git', ['rev-parse', '--verify', baseBranch], { encoding: 'utf8', cwd, timeout: 5_000 });
521
+ if (verifyR.status !== 0) {
522
+ spinner.done();
523
+ process.stderr.write(`${sErr.red('✖')} Branch "${baseBranch}" not found in this repository.\n`);
524
+ process.exitCode = 1;
525
+ return;
526
+ }
527
+ } else {
358
528
  for (const candidate of ['main', 'master', 'develop']) {
359
529
  const r = execFn('git', ['rev-parse', '--verify', candidate], { encoding: 'utf8', cwd, timeout: 5_000 });
360
530
  if (r.status === 0) { baseBranch = candidate; break; }
@@ -362,28 +532,47 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
362
532
  baseBranch = baseBranch ?? 'main';
363
533
  }
364
534
 
365
- // Current branch name
366
535
  const headResult = execFn('git', ['branch', '--show-current'], { encoding: 'utf8', cwd, timeout: 5_000 });
367
536
  const headBranch = headResult.status === 0 ? (headResult.stdout.trim() || null) : null;
368
537
 
369
- // Commits in range
538
+ if (headBranch && headBranch === baseBranch) {
539
+ stderrNotes.push(
540
+ ` ${sErr.yellow('⚠')} You are on ${sErr.cyan(headBranch)} — switch to a feature branch for meaningful output.`
541
+ );
542
+ }
543
+
370
544
  const logResult = execFn('git', ['log', '--oneline', `${baseBranch}..HEAD`], { encoding: 'utf8', cwd, timeout: 10_000 });
371
545
  const commitMessages = logResult.status === 0 ? (logResult.stdout ?? '') : '';
372
546
 
373
- // Diff between base and HEAD
374
547
  const diffResult = execFn('git', ['diff', `${baseBranch}..HEAD`], { encoding: 'utf8', cwd, timeout: 30_000 });
375
548
  const diff = diffResult.status === 0 ? (diffResult.stdout || null) : null;
376
549
 
377
- // Extract ticket keys from branch name + commit messages
378
550
  const allText = [headBranch ?? '', commitMessages].join('\n');
379
551
  const ticketKeys = extractTicketKeys(allText);
380
552
 
381
- // Fetch tickets (best-effort — gracefully degrade if no credentials)
382
- const profileArgR = args.find(a => a.startsWith('--profile='));
553
+ const profileArgR = reviewFlags.find(a => a.startsWith('--profile='));
383
554
  const profileNameR = profileArgR ? profileArgR.split('=')[1] : undefined;
555
+
556
+ // Validate explicit --profile exists before attempting any fetch
557
+ if (profileNameR) {
558
+ const profiles = loadProfiles(resolvedConfigDir);
559
+ if (!profiles?.profiles?.[profileNameR]) {
560
+ spinner.done();
561
+ process.stderr.write(`${sErr.red('✖')} Profile "${profileNameR}" not found.\n`);
562
+ const names = Object.keys(profiles?.profiles ?? {});
563
+ if (names.length > 0) process.stderr.write(` ${sErr.dim('Available:')} ${names.join(', ')}\n`);
564
+ else process.stderr.write(` Run ${sErr.cyan('ticketlens init')} to configure a profile.\n`);
565
+ process.exitCode = 1;
566
+ return;
567
+ }
568
+ }
569
+
570
+ // Fetch tickets (best-effort) — start animation here since fetch is async
384
571
  const tickets = [];
385
572
 
386
573
  if (ticketKeys.length > 0) {
574
+ spinner.update(`Fetching ticket context (${ticketKeys.length} key${ticketKeys.length > 1 ? 's' : ''})…`);
575
+ spinner.startAnim();
387
576
  const connR = resolveConnection(ticketKeys[0], {
388
577
  env,
389
578
  configDir: resolvedConfigDir,
@@ -398,15 +587,24 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
398
587
  for (const key of ticketKeys) {
399
588
  try {
400
589
  tickets.push(await adapterR.fetchTicket(key, { depth: 0 }));
401
- } catch { /* non-fatal — skip ticket */ }
590
+ } catch (err) {
591
+ stderrNotes.push(` ${sErr.yellow('⚠')} ${key}: ${err.message ?? 'fetch failed'}`);
592
+ }
402
593
  }
403
594
  } else {
404
- process.stderr.write(
405
- `Note: Found tickets [${ticketKeys.join(', ')}] but no profile configured. Run 'ticketlens init' to set up.\n`
595
+ stderrNotes.push(
596
+ ` ${sErr.yellow('○')} Found tickets [${ticketKeys.join(', ')}] but no profile configured. Run 'ticketlens init' to set up.`
406
597
  );
407
598
  }
599
+ } else if (profileNameR) {
600
+ stderrNotes.push(
601
+ ` ${sErr.dim('Note:')} --profile=${profileNameR} specified but no ticket keys found in branch or commits.`
602
+ );
408
603
  }
409
604
 
605
+ spinner.done();
606
+ for (const note of stderrNotes) process.stderr.write(note + '\n');
607
+
410
608
  const isLic = opts.isLicensedFn ?? ((tier) => isLicensed(tier, resolvedConfigDir));
411
609
  const assembleFn = opts.assemblePrReviewFn ?? assemblePrReview;
412
610
 
@@ -419,7 +617,9 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
419
617
  analyzeDiffFn: analyzeDiff,
420
618
  extractRequirementsFn: extractRequirements,
421
619
  });
422
- printFn(md + '\n');
620
+
621
+ const sOut = createStyler({ isTTY: process.stdout.isTTY });
622
+ printFn((sOut.enabled ? styleReviewMd(md, sOut) : md) + '\n');
423
623
  return;
424
624
  }
425
625
 
@@ -82,6 +82,10 @@ export function parseCommand(args) {
82
82
  return { command: 'compliance', args: args.slice(1) };
83
83
  }
84
84
 
85
+ if (first === 'review') {
86
+ return { command: 'review', args: args.slice(1) };
87
+ }
88
+
85
89
  if (first === 'login') {
86
90
  return { command: 'login', args: args.slice(1) };
87
91
  }