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
|
@@ -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 =
|
|
513
|
+
const baseArg = reviewFlags.find(a => a.startsWith('--base='));
|
|
356
514
|
let baseBranch = baseArg ? baseArg.split('=')[1] : null;
|
|
357
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
590
|
+
} catch (err) {
|
|
591
|
+
stderrNotes.push(` ${sErr.yellow('⚠')} ${key}: ${err.message ?? 'fetch failed'}`);
|
|
592
|
+
}
|
|
402
593
|
}
|
|
403
594
|
} else {
|
|
404
|
-
|
|
405
|
-
`
|
|
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
|
-
|
|
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
|
}
|