ticketlens 0.35.0 → 0.37.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.35.0",
3
+ "version": "0.37.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.28.0 -->
1
+ <!-- jtb-skill-version: 0.29.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.
@@ -284,7 +284,7 @@ ticketlens duplicates PROD-1234 # find likely dupl
284
284
 
285
285
  `assign` is self-assign only — `--to` must be `me`. There is no way to assign to anyone else yet; don't attempt a workaround (e.g. via `comment`) if the user asks for that — tell them it isn't supported.
286
286
 
287
- `duplicates` lists likely-duplicate tickets in the same project, ranked by local title/description overlap — no tracker scores similarity server-side, so treat a match as a nudge for the user to check manually, never as a confirmed duplicate to act on unprompted (e.g. don't auto-close or auto-comment based on a match). `--threshold=N` (0–1, default 0.35) tightens or loosens what counts as a match.
287
+ `duplicates` lists likely-duplicate tickets in the same project. On Jira, any ticket already linked as a "Duplicate" is always listed first — that's a confirmed relationship a human already recorded, not a heuristic. Everything else is ranked by local title/description overlap — no tracker scores similarity server-side, so treat those as a nudge for the user to check manually, never as a confirmed duplicate to act on unprompted (e.g. don't auto-close or auto-comment based on a match). `--threshold=N` (0–1, default 0.35) tightens or loosens what counts as a text-match — it has no effect on Jira-linked duplicates, which are always shown.
288
288
 
289
289
  The three write actions (comment/transition/assign) have a short local debounce (10s) against an accidental double-fire, and every write is appended to a local audit log (`~/.ticketlens/ticket-action-log.jsonl`). A write that times out is never retried automatically — surface the failure to the user rather than silently re-attempting, since a ticket write isn't naturally idempotent the way a Recall note save is. `duplicates` has neither, since nothing is written.
290
290
 
@@ -751,8 +751,10 @@ export function printDuplicatesHelp({ stream = process.stdout } = {}) {
751
751
  '',
752
752
  ` Find likely duplicate tickets in the same project (Jira/GitHub/Linear). ${s.dim('[Pro]')}`,
753
753
  ` Read-only — lists possible matches, never links or changes anything.`,
754
- ` No tracker scores similarity server-side, so ranking happens locally`,
755
- ` from title/description overlap. Not exact — treat it as a nudge to check.`,
754
+ ` On Jira, any ticket already linked as a "Duplicate" is always listed first —`,
755
+ ` that's a confirmed relationship, not a guess. Everything else comes from a`,
756
+ ` local title/description overlap score (no tracker scores similarity`,
757
+ ` server-side) — not exact, treat those as a nudge to check.`,
756
758
  '',
757
759
  ` ${s.bold('OPTIONS')}`,
758
760
  '',
@@ -93,6 +93,10 @@ export function normalizeTicket(raw) {
93
93
  return {
94
94
  direction,
95
95
  linkType: link.type.name,
96
+ // The actual directional wording ("is duplicated by" / "duplicates") as
97
+ // named in Jira itself, not just the link type's generic name — lets
98
+ // callers show the user's own words instead of inventing new phrasing.
99
+ linkPhrase: direction === 'outward' ? link.type.outward : link.type.inward,
96
100
  key: issue.key,
97
101
  summary: issue.fields.summary,
98
102
  status: issue.fields.status?.name ?? null,
@@ -93,7 +93,7 @@ const TOOLS = [
93
93
  },
94
94
  {
95
95
  name: 'ticket_duplicates',
96
- description: 'Find likely duplicate tickets in the same project (Jira/GitHub/Linear). Read-only — never links or changes anything; no tracker scores similarity server-side, so ranking is local and approximate. Requires a TicketLens Pro license.',
96
+ description: 'Find likely duplicate tickets in the same project (Jira/GitHub/Linear). Read-only — never links or changes anything. On Jira, any ticket already linked as a "Duplicate" is always included first (a confirmed relationship, not a guess); everything else comes from a local, approximate title/description overlap score, since no tracker scores similarity server-side. Requires a TicketLens Pro license.',
97
97
  inputSchema: {
98
98
  type: 'object',
99
99
  properties: {
@@ -45,10 +45,10 @@ function refuseGithubAttachments(adapter, attachPaths, stream) {
45
45
  return true;
46
46
  }
47
47
 
48
- function formatAttachSummary(attachResult) {
48
+ function formatAttachSummary(attachResult, s) {
49
49
  if (!attachResult) return '';
50
50
  const lines = [];
51
- for (const u of attachResult.uploaded) lines.push(` Attached ${u.filename}${u.url ? ` (${u.url})` : ''}\n`);
51
+ for (const u of attachResult.uploaded) lines.push(` ${s.green('✔')} Attached ${s.bold(u.filename)}${u.url ? ` (${u.url})` : ''}\n`);
52
52
  for (const e of attachResult.errors) lines.push(` Failed to attach ${e.path}: ${e.message}\n`);
53
53
  if (attachResult.droppedCount > 0) lines.push(` ${attachResult.droppedCount} attachment(s) dropped — exceeds the ${MAX_ATTACHMENTS}-file limit per call.\n`);
54
54
  return lines.join('');
@@ -190,11 +190,14 @@ function describeAppliedFields(applied) {
190
190
  return parts.join(', ');
191
191
  }
192
192
 
193
- function formatUpdateResult(ticketKey, { applied, errors }) {
193
+ function formatUpdateResult(ticketKey, { applied, errors }, s) {
194
194
  const appliedText = describeAppliedFields(applied);
195
195
  const errorText = Object.entries(errors).map(([field, info]) => formatFieldError(field, info)).join('; ');
196
- if (appliedText && !errorText) return ` ${ticketKey} updated: ${appliedText}.\n`;
197
- if (appliedText && errorText) return ` ${ticketKey} partially updated: ${appliedText}. Failed: ${errorText}.\n`;
196
+ // Success/partial get the styled brand+bold key; total failure stays plain,
197
+ // consistent with every other refusal/error message in this file (e.g. the
198
+ // GitHub-priority-refusal line just above in runTicketUpdate).
199
+ if (appliedText && !errorText) return ` ${s.green('✔')} ${s.brand(s.bold(ticketKey))} updated: ${s.bold(appliedText)}.\n`;
200
+ if (appliedText && errorText) return ` ${s.yellow('~')} ${s.brand(s.bold(ticketKey))} partially updated: ${s.bold(appliedText)}. Failed: ${errorText}.\n`;
198
201
  return ` Nothing updated on ${ticketKey}. Failed: ${errorText}.\n`;
199
202
  }
200
203
 
@@ -267,6 +270,7 @@ export async function runTicketComment(cmdArgs, {
267
270
 
268
271
  const adapter = resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
269
272
  if (!adapter) return { ok: false };
273
+ const s = createStyler({ isTTY: stream.isTTY });
270
274
 
271
275
  // Uploaded BEFORE the comment write so a tracker capable of inline
272
276
  // rendering (Jira Server/DC via wiki markup, Jira Cloud via a real ADF
@@ -287,13 +291,13 @@ export async function runTicketComment(cmdArgs, {
287
291
  // actually landed) — a partial attach failure is reconstructable from
288
292
  // the difference between the two, not just silently absent from audit.
289
293
  logActionFn({ ticketKey, action: 'comment', actor, tracker: adapter.type, detail: { id: result.id, attachPaths, attachedFilenames: (attachResult?.uploaded ?? []).map(a => a.filename) } }, { configDir });
290
- stream.write(` Comment posted to ${ticketKey}${result.url ? ` (${result.url})` : ''}\n` + formatAttachSummary(attachResult));
294
+ stream.write(` ${s.green('✔')} Comment posted to ${s.brand(s.bold(ticketKey))}${result.url ? ` (${result.url})` : ''}\n` + formatAttachSummary(attachResult, s));
291
295
  return { ok: true };
292
296
  } catch (err) {
293
297
  // Attachments (if any) genuinely landed on the tracker before this
294
298
  // write was attempted — formatAttachSummary is still shown here so a
295
299
  // caller retrying the whole command doesn't blindly re-upload them.
296
- stream.write(formatWriteFailure(ticketKey, err) + formatAttachSummary(attachResult));
300
+ stream.write(formatWriteFailure(ticketKey, err) + formatAttachSummary(attachResult, s));
297
301
  return { ok: false };
298
302
  }
299
303
  }
@@ -323,13 +327,14 @@ export async function runTicketTransitionList(cmdArgs, {
323
327
 
324
328
  try {
325
329
  const options = await adapter.getTransitions(ticketKey);
330
+ const s = createStyler({ isTTY: stream.isTTY });
326
331
  if (options.length === 0) {
327
- stream.write(` No valid transitions available for ${ticketKey}.\n`);
332
+ stream.write(` No valid transitions available for ${s.brand(s.bold(ticketKey))}.\n`);
328
333
  return { ok: true, options: [] };
329
334
  }
330
- stream.write(` Valid transitions for ${ticketKey}:\n`);
331
- for (const o of options) stream.write(` - ${o.name}\n`);
332
- stream.write(` Run again with --target="<name>" --confirm to execute.\n`);
335
+ stream.write(` Valid transitions for ${s.brand(s.bold(ticketKey))}:\n\n`);
336
+ for (const o of options) stream.write(` ${s.brand('●')} ${o.name}\n`);
337
+ stream.write(`\n Run again with --target="<name>" --confirm to execute.\n`);
333
338
  return { ok: true, options };
334
339
  } catch (err) {
335
340
  stream.write(formatWriteFailure(ticketKey, err));
@@ -389,7 +394,8 @@ export async function runTicketTransition(cmdArgs, {
389
394
  }
390
395
  recordActionFn(ticketKey, 'transition', { configDir });
391
396
  logActionFn({ ticketKey, action: 'transition', actor, tracker: adapter.type, detail: { to: result.to } }, { configDir });
392
- stream.write(` ${ticketKey} transitioned to "${result.to}".\n`);
397
+ const s = createStyler({ isTTY: stream.isTTY });
398
+ stream.write(` ${s.green('✔')} ${s.brand(s.bold(ticketKey))} transitioned to ${s.bold(`"${result.to}"`)}.\n`);
393
399
  return { ok: true };
394
400
  } catch (err) {
395
401
  stream.write(formatWriteFailure(ticketKey, err));
@@ -443,7 +449,8 @@ export async function runTicketAssign(cmdArgs, {
443
449
  const result = await adapter.assignToSelf(ticketKey);
444
450
  recordActionFn(ticketKey, 'assign', { configDir });
445
451
  logActionFn({ ticketKey, action: 'assign', actor, tracker: adapter.type, detail: { assignee: result.assignee } }, { configDir });
446
- stream.write(` ${ticketKey} assigned to ${result.assignee}.\n`);
452
+ const s = createStyler({ isTTY: stream.isTTY });
453
+ stream.write(` ${s.green('✔')} ${s.brand(s.bold(ticketKey))} assigned to ${s.bold(result.assignee)}.\n`);
447
454
  return { ok: true };
448
455
  } catch (err) {
449
456
  stream.write(formatWriteFailure(ticketKey, err));
@@ -485,11 +492,27 @@ export async function runTicketDuplicates(cmdArgs, {
485
492
  if (!adapter) return { ok: false };
486
493
 
487
494
  try {
488
- const source = await adapter.fetchTicket(ticketKey);
495
+ // depth: 0 — only the shallow linkedIssues list is needed (for explicit
496
+ // Duplicate links, below); the default depth:1 would also recursively
497
+ // fetch full details of every linked ticket just to discard them here.
498
+ const source = await adapter.fetchTicket(ticketKey, { depth: 0 });
489
499
  const searchText = [source.summary, source.description].filter(Boolean).join(' ');
490
500
  const candidates = await adapter.findCandidates(searchText, ticketKey);
491
501
  const scoreOpts = threshold !== undefined ? { threshold } : {};
492
- const results = scoreCandidates({ key: ticketKey, summary: source.summary, description: source.description }, candidates, scoreOpts);
502
+ const textMatches = scoreCandidates({ key: ticketKey, summary: source.summary, description: source.description }, candidates, scoreOpts);
503
+
504
+ // A human already said these are duplicates — always include them,
505
+ // regardless of --threshold, ahead of anything the local scorer finds.
506
+ // Jira doesn't enforce uniqueness of link-type+target pairs, so the same
507
+ // key could appear via two separately-typed "duplicate-ish" links —
508
+ // de-duped against itself here, not just against textMatches below.
509
+ const seenLinkedKeys = new Set();
510
+ const linkedDuplicates = (source.linkedIssues ?? [])
511
+ .filter(l => /duplicate/i.test(l.linkType))
512
+ .filter(l => !seenLinkedKeys.has(l.key) && seenLinkedKeys.add(l.key))
513
+ .map(l => ({ key: l.key, summary: l.summary, linked: true, linkPhrase: l.linkPhrase }));
514
+ const linkedKeys = new Set(linkedDuplicates.map(l => l.key));
515
+ const results = [...linkedDuplicates, ...textMatches.filter(r => !linkedKeys.has(r.key))];
493
516
 
494
517
  const s = createStyler({ isTTY: stream.isTTY });
495
518
  stream.write(` ${s.brand(s.bold(ticketKey))}: ${s.bold(source.summary ?? '')}\n\n`);
@@ -500,6 +523,10 @@ export async function runTicketDuplicates(cmdArgs, {
500
523
  }
501
524
  stream.write(` Possible duplicates:\n\n`);
502
525
  for (const r of results) {
526
+ if (r.linked) {
527
+ stream.write(` ${s.brand('●')} ${s.bold(r.key)} (${s.green(`Jira-linked — ${r.linkPhrase}`)}) — ${r.summary}\n`);
528
+ continue;
529
+ }
503
530
  const pct = Math.round(r.score * 100);
504
531
  const pctColor = matchColor(pct, s);
505
532
  stream.write(` ${s.brand('●')} ${s.bold(r.key)} (${pctColor(`${pct}% match`)}) — ${r.summary}\n`);
@@ -543,12 +570,14 @@ export async function runTicketLinkList(cmdArgs, {
543
570
 
544
571
  try {
545
572
  const types = await adapter.getLinkTypes();
573
+ const s = createStyler({ isTTY: stream.isTTY });
546
574
  if (types.length === 0) {
547
- stream.write(` No link types available for ${sourceKey} → ${targetKey} on ${adapter.type}.\n`);
575
+ stream.write(` No link types available for ${s.brand(s.bold(sourceKey))} → ${s.brand(s.bold(targetKey))} on ${adapter.type}.\n`);
548
576
  return { ok: true, types: [] };
549
577
  }
550
- stream.write(` Available link types for ${sourceKey} → ${targetKey} (${adapter.type}):\n`);
551
- for (const t of types) stream.write(` - ${t}\n`);
578
+ stream.write(` Available link types for ${s.brand(s.bold(sourceKey))} → ${s.brand(s.bold(targetKey))} (${adapter.type}):\n\n`);
579
+ for (const t of types) stream.write(` ${s.brand('●')} ${t}\n`);
580
+ stream.write('\n');
552
581
  if (adapter.type === 'github') {
553
582
  stream.write(` Note: GitHub has no generic link relationship — linking will CLOSE ${sourceKey} as a duplicate of ${targetKey}.\n`);
554
583
  }
@@ -627,10 +656,11 @@ export async function runTicketLink(cmdArgs, {
627
656
  }
628
657
  recordActionFn(cooldownKey, 'link', { configDir });
629
658
  logActionFn({ ticketKey: sourceKey, action: 'link', actor, tracker: adapter.type, detail: { targetKey, type } }, { configDir });
659
+ const s = createStyler({ isTTY: stream.isTTY });
630
660
  stream.write(
631
661
  adapter.type === 'github'
632
- ? ` ${sourceKey} closed as a duplicate of ${targetKey}.\n`
633
- : ` ${sourceKey} linked to ${targetKey} as "${type}".\n`,
662
+ ? ` ${s.green('✔')} ${s.brand(s.bold(sourceKey))} closed as a duplicate of ${s.brand(s.bold(targetKey))}.\n`
663
+ : ` ${s.green('✔')} ${s.brand(s.bold(sourceKey))} linked to ${s.brand(s.bold(targetKey))} as ${s.bold(`"${type}"`)}.\n`,
634
664
  );
635
665
  return { ok: true };
636
666
  } catch (err) {
@@ -713,7 +743,7 @@ export async function runTicketUpdate(cmdArgs, {
713
743
  recordActionFn(ticketKey, 'update', { configDir });
714
744
  logActionFn({ ticketKey, action: 'update', actor, tracker: adapter.type, detail: { ...result.applied, failed: Object.keys(result.errors) } }, { configDir });
715
745
  }
716
- stream.write(formatUpdateResult(ticketKey, result));
746
+ stream.write(formatUpdateResult(ticketKey, result, createStyler({ isTTY: stream.isTTY })));
717
747
  return hasErrors ? { ok: false, applied: result.applied, errors: result.errors } : { ok: true, applied: result.applied };
718
748
  } catch (err) {
719
749
  stream.write(formatWriteFailure(ticketKey, err));
@@ -774,6 +804,7 @@ export async function runTicketCreate(cmdArgs, {
774
804
 
775
805
  const adapter = resolveTicketAdapter(undefined, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
776
806
  if (!adapter) return { ok: false };
807
+ const s = createStyler({ isTTY: stream.isTTY });
777
808
  const attachRefused = refuseGithubAttachments(adapter, attachPaths, stream);
778
809
 
779
810
  if (adapter.type !== 'github' && !project) {
@@ -863,6 +894,6 @@ export async function runTicketCreate(cmdArgs, {
863
894
  }
864
895
  }
865
896
 
866
- stream.write(` Created ${result.key}${result.url ? ` (${result.url})` : ''}\n` + formatAttachSummary(attachResult));
897
+ stream.write(` ${s.green('✔')} Created ${s.brand(s.bold(result.key))}${result.url ? ` (${result.url})` : ''}\n` + formatAttachSummary(attachResult, s));
867
898
  return { ok: true, key: result.key };
868
899
  }