ticketlens 0.1.15 → 0.1.16

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.16",
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,124 @@ async function applySummarize(brief, args, opts, configDir, conn, licensedFn, up
145
145
  }
146
146
  }
147
147
 
148
+ function makeSpinner(s) {
149
+ if (!process.stderr.isTTY) return { update: () => {}, done: () => {} };
150
+ const frames = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
151
+ let i = 0, msg = 'Working…', finished = false;
152
+ const id = setInterval(() => {
153
+ process.stderr.write(`\r ${s.brand(frames[i++ % frames.length])} ${s.dim(msg)}`);
154
+ }, 80);
155
+ return {
156
+ update(m) { if (!finished) msg = m; },
157
+ done() {
158
+ if (finished) return;
159
+ finished = true;
160
+ clearInterval(id);
161
+ process.stderr.write('\r\x1b[2K');
162
+ },
163
+ };
164
+ }
165
+
166
+ function styleReviewMd(md, s) {
167
+ if (!s.enabled) return md;
168
+ const lines = md.split('\n');
169
+ const out = [];
170
+ let section = '';
171
+
172
+ for (const line of lines) {
173
+ if (line === '## PR Review Context') {
174
+ out.push('');
175
+ out.push(` ${s.brand(s.bold('◆ PR Review Context'))}`);
176
+ out.push(` ${s.dim('─'.repeat(50))}`);
177
+ section = 'header';
178
+ continue;
179
+ }
180
+
181
+ if (line.startsWith('### ')) {
182
+ const title = line.slice(4);
183
+ const fm = title.match(/^Changed files \((\d+)\)$/);
184
+ if (fm) {
185
+ out.push('');
186
+ out.push(` ${s.bold('Changed files')} ${s.dim('(')}${s.yellow(fm[1])}${s.dim(')')}`);
187
+ section = 'changed-files';
188
+ continue;
189
+ }
190
+ const icons = { Branch: '⎇ ', 'Ticket context': '◉ ', 'Requirements coverage': '✦ ', 'Review focus': '▲ ' };
191
+ const icon = icons[title] ?? '';
192
+ out.push('');
193
+ out.push(` ${s.bold(icon + title)}`);
194
+ section = title.toLowerCase().replace(/\s+/g, '-');
195
+ continue;
196
+ }
197
+
198
+ if (line.startsWith('#### ')) {
199
+ const inner = line.slice(5);
200
+ const covM = inner.match(/^([A-Z][A-Z0-9]+-\d+) \((\d+)%\)$/);
201
+ if (covM) {
202
+ const pct = parseInt(covM[2], 10);
203
+ const pctFmt = pct >= 75 ? s.green(`${pct}%`) : pct >= 50 ? s.yellow(`${pct}%`) : s.red(`${pct}%`);
204
+ out.push(` ${s.cyan(s.bold(covM[1]))} ${s.dim('coverage:')} ${pctFmt}`);
205
+ continue;
206
+ }
207
+ const tkM = inner.match(/^([A-Z][A-Z0-9]+-\d+)[: —–-]+(.*)$/);
208
+ if (tkM) {
209
+ out.push('');
210
+ out.push(` ${s.cyan(s.bold(tkM[1]))} ${s.dim(tkM[2].trim())}`);
211
+ continue;
212
+ }
213
+ out.push(line);
214
+ continue;
215
+ }
216
+
217
+ if (line.match(/^`[^`]+` → `[^`]+`$/)) {
218
+ const [h, b] = line.split(' → ');
219
+ out.push(` ${s.cyan(h.replace(/`/g, ''))} ${s.dim('→')} ${s.dim(b.replace(/`/g, ''))}`);
220
+ continue;
221
+ }
222
+ if (line.match(/^→ `[^`]+`$/)) {
223
+ out.push(` ${s.dim('(detached HEAD)')} ${s.dim('→')} ${s.dim(line.slice(4).replace(/`/g, ''))}`);
224
+ continue;
225
+ }
226
+
227
+ if (line.startsWith('- ✔ ')) {
228
+ const text = line.slice(4);
229
+ const di = text.lastIndexOf(' — ');
230
+ if (di > 0) out.push(` ${s.green('✔')} ${text.slice(0, di)}${s.dim(' — ' + text.slice(di + 3))}`);
231
+ else out.push(` ${s.green('✔')} ${text}`);
232
+ continue;
233
+ }
234
+ if (line.startsWith('- ✖ ')) { out.push(` ${s.red('✖')} ${line.slice(4)}`); continue; }
235
+ if (line.startsWith('- ~ ')) { out.push(` ${s.yellow('~')} ${s.dim(line.slice(4))}`); continue; }
236
+
237
+ if (line.startsWith('- ')) {
238
+ out.push(section === 'changed-files'
239
+ ? ` ${s.dim('·')} ${s.dim(line.slice(2))}`
240
+ : ` ${s.dim('·')} ${line.slice(2)}`);
241
+ continue;
242
+ }
243
+
244
+ if (line.startsWith('_') && line.endsWith('_') && line.length > 2) {
245
+ const inner = line.slice(1, -1);
246
+ if (inner.includes('No linked tickets found')) {
247
+ out.push(` ${s.yellow('○')} ${s.dim(inner)}`);
248
+ } else if (inner.includes('Requirements coverage analysis requires')) {
249
+ out.push(` ${s.yellow('⚡')} ${s.dim(inner)}`);
250
+ } else if (inner.includes('Generated by TicketLens')) {
251
+ out.push(s.dim(inner));
252
+ } else {
253
+ out.push(` ${s.dim(inner)}`);
254
+ }
255
+ continue;
256
+ }
257
+
258
+ if (line === '---') { out.push(` ${s.dim('─'.repeat(50))}`); continue; }
259
+
260
+ out.push(line);
261
+ }
262
+
263
+ return out.join('\n');
264
+ }
265
+
148
266
  const RETRY_OPTIONS = [
149
267
  { label: 'Retry', sublabel: 'Try again — e.g. VPN just connected', value: 'retry' },
150
268
  { label: 'Switch profile', sublabel: 'Use a different Jira profile', value: 'switch' },
@@ -351,9 +469,15 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
351
469
  const execFn = opts.execFn ?? spawnSync;
352
470
  const cwd = process.cwd();
353
471
 
472
+ const sErr = createStyler({ isTTY: process.stderr.isTTY });
473
+ const spinner = makeSpinner(sErr);
474
+
354
475
  // Resolve base branch: --base=BRANCH or auto-detect main/master/develop
355
476
  const baseArg = args.find(a => a.startsWith('--base='));
356
477
  let baseBranch = baseArg ? baseArg.split('=')[1] : null;
478
+
479
+ spinner.update('Scanning branch…');
480
+
357
481
  if (!baseBranch) {
358
482
  for (const candidate of ['main', 'master', 'develop']) {
359
483
  const r = execFn('git', ['rev-parse', '--verify', candidate], { encoding: 'utf8', cwd, timeout: 5_000 });
@@ -362,28 +486,41 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
362
486
  baseBranch = baseBranch ?? 'main';
363
487
  }
364
488
 
365
- // Current branch name
366
489
  const headResult = execFn('git', ['branch', '--show-current'], { encoding: 'utf8', cwd, timeout: 5_000 });
367
490
  const headBranch = headResult.status === 0 ? (headResult.stdout.trim() || null) : null;
368
491
 
369
- // Commits in range
370
492
  const logResult = execFn('git', ['log', '--oneline', `${baseBranch}..HEAD`], { encoding: 'utf8', cwd, timeout: 10_000 });
371
493
  const commitMessages = logResult.status === 0 ? (logResult.stdout ?? '') : '';
372
494
 
373
- // Diff between base and HEAD
374
495
  const diffResult = execFn('git', ['diff', `${baseBranch}..HEAD`], { encoding: 'utf8', cwd, timeout: 30_000 });
375
496
  const diff = diffResult.status === 0 ? (diffResult.stdout || null) : null;
376
497
 
377
- // Extract ticket keys from branch name + commit messages
378
498
  const allText = [headBranch ?? '', commitMessages].join('\n');
379
499
  const ticketKeys = extractTicketKeys(allText);
380
500
 
381
- // Fetch tickets (best-effort — gracefully degrade if no credentials)
382
501
  const profileArgR = args.find(a => a.startsWith('--profile='));
383
502
  const profileNameR = profileArgR ? profileArgR.split('=')[1] : undefined;
503
+
504
+ // Validate explicit --profile exists before attempting any fetch
505
+ if (profileNameR) {
506
+ const profiles = loadProfiles(resolvedConfigDir);
507
+ if (!profiles?.profiles?.[profileNameR]) {
508
+ spinner.done();
509
+ process.stderr.write(`${sErr.red('✖')} Profile "${profileNameR}" not found.\n`);
510
+ const names = Object.keys(profiles?.profiles ?? {});
511
+ if (names.length > 0) process.stderr.write(` ${sErr.dim('Available:')} ${names.join(', ')}\n`);
512
+ else process.stderr.write(` Run ${sErr.cyan('ticketlens init')} to configure a profile.\n`);
513
+ process.exitCode = 1;
514
+ return;
515
+ }
516
+ }
517
+
518
+ // Fetch tickets (best-effort — collect notes/warnings, write after spinner stops)
384
519
  const tickets = [];
520
+ const stderrNotes = [];
385
521
 
386
522
  if (ticketKeys.length > 0) {
523
+ spinner.update(`Fetching ticket context (${ticketKeys.length} key${ticketKeys.length > 1 ? 's' : ''})…`);
387
524
  const connR = resolveConnection(ticketKeys[0], {
388
525
  env,
389
526
  configDir: resolvedConfigDir,
@@ -398,15 +535,24 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
398
535
  for (const key of ticketKeys) {
399
536
  try {
400
537
  tickets.push(await adapterR.fetchTicket(key, { depth: 0 }));
401
- } catch { /* non-fatal — skip ticket */ }
538
+ } catch (err) {
539
+ stderrNotes.push(` ${sErr.yellow('⚠')} ${key}: ${err.message ?? 'fetch failed'}`);
540
+ }
402
541
  }
403
542
  } else {
404
- process.stderr.write(
405
- `Note: Found tickets [${ticketKeys.join(', ')}] but no profile configured. Run 'ticketlens init' to set up.\n`
543
+ stderrNotes.push(
544
+ ` ${sErr.yellow('○')} Found tickets [${ticketKeys.join(', ')}] but no profile configured. Run 'ticketlens init' to set up.`
406
545
  );
407
546
  }
547
+ } else if (profileNameR) {
548
+ stderrNotes.push(
549
+ ` ${sErr.dim('Note:')} --profile=${profileNameR} specified but no ticket keys found in branch or commits.`
550
+ );
408
551
  }
409
552
 
553
+ spinner.done();
554
+ for (const note of stderrNotes) process.stderr.write(note + '\n');
555
+
410
556
  const isLic = opts.isLicensedFn ?? ((tier) => isLicensed(tier, resolvedConfigDir));
411
557
  const assembleFn = opts.assemblePrReviewFn ?? assemblePrReview;
412
558
 
@@ -419,7 +565,9 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
419
565
  analyzeDiffFn: analyzeDiff,
420
566
  extractRequirementsFn: extractRequirements,
421
567
  });
422
- printFn(md + '\n');
568
+
569
+ const sOut = createStyler({ isTTY: process.stdout.isTTY });
570
+ printFn((sOut.enabled ? styleReviewMd(md, sOut) : md) + '\n');
423
571
  return;
424
572
  }
425
573
 
@@ -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
  }