ticketlens 0.34.0 → 0.36.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.34.0",
3
+ "version": "0.36.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: {
@@ -21,6 +21,7 @@ import { detectProjectOrTypeError, enrichCreateFailure } from './ticket-create-e
21
21
  import { TICKET_KEY_PATTERN } from './cli.mjs';
22
22
  import { scoreCandidates } from './duplicate-scorer.mjs';
23
23
  import { MAX_ATTACHMENTS } from './attachment-uploader.mjs';
24
+ import { createStyler } from './ansi.mjs';
24
25
 
25
26
  function parseFlag(cmdArgs, name) {
26
27
  return cmdArgs.find(a => a.startsWith(`--${name}=`))?.slice(name.length + 3);
@@ -123,6 +124,18 @@ function formatDuplicatesFailure(ticketKey, err) {
123
124
  return formatReadFailure(ticketKey, err, 'for duplicates');
124
125
  }
125
126
 
127
+ /**
128
+ * Match-confidence color tier for a duplicates result: ≥70% reads as a strong
129
+ * signal, 50-69% as worth a look, below that as a weak, low-confidence nudge.
130
+ * Exported standalone so the boundary values are pinned by a direct test
131
+ * rather than reverse-engineered from real Jaccard scores in a fixture.
132
+ */
133
+ export function matchColor(pct, s) {
134
+ if (pct >= 70) return s.green;
135
+ if (pct >= 50) return s.yellow;
136
+ return s.dim;
137
+ }
138
+
126
139
  function formatLinkListFailure(ticketKey, err) {
127
140
  return formatReadFailure(ticketKey, err, 'for link options');
128
141
  }
@@ -472,19 +485,44 @@ export async function runTicketDuplicates(cmdArgs, {
472
485
  if (!adapter) return { ok: false };
473
486
 
474
487
  try {
475
- const source = await adapter.fetchTicket(ticketKey);
488
+ // depth: 0 — only the shallow linkedIssues list is needed (for explicit
489
+ // Duplicate links, below); the default depth:1 would also recursively
490
+ // fetch full details of every linked ticket just to discard them here.
491
+ const source = await adapter.fetchTicket(ticketKey, { depth: 0 });
476
492
  const searchText = [source.summary, source.description].filter(Boolean).join(' ');
477
493
  const candidates = await adapter.findCandidates(searchText, ticketKey);
478
494
  const scoreOpts = threshold !== undefined ? { threshold } : {};
479
- const results = scoreCandidates({ key: ticketKey, summary: source.summary, description: source.description }, candidates, scoreOpts);
495
+ const textMatches = scoreCandidates({ key: ticketKey, summary: source.summary, description: source.description }, candidates, scoreOpts);
496
+
497
+ // A human already said these are duplicates — always include them,
498
+ // regardless of --threshold, ahead of anything the local scorer finds.
499
+ // Jira doesn't enforce uniqueness of link-type+target pairs, so the same
500
+ // key could appear via two separately-typed "duplicate-ish" links —
501
+ // de-duped against itself here, not just against textMatches below.
502
+ const seenLinkedKeys = new Set();
503
+ const linkedDuplicates = (source.linkedIssues ?? [])
504
+ .filter(l => /duplicate/i.test(l.linkType))
505
+ .filter(l => !seenLinkedKeys.has(l.key) && seenLinkedKeys.add(l.key))
506
+ .map(l => ({ key: l.key, summary: l.summary, linked: true, linkPhrase: l.linkPhrase }));
507
+ const linkedKeys = new Set(linkedDuplicates.map(l => l.key));
508
+ const results = [...linkedDuplicates, ...textMatches.filter(r => !linkedKeys.has(r.key))];
509
+
510
+ const s = createStyler({ isTTY: stream.isTTY });
511
+ stream.write(` ${s.brand(s.bold(ticketKey))}: ${s.bold(source.summary ?? '')}\n\n`);
480
512
 
481
513
  if (results.length === 0) {
482
- stream.write(` No likely duplicates found for ${ticketKey}.\n`);
514
+ stream.write(` No likely duplicates found.\n`);
483
515
  return { ok: true, results: [] };
484
516
  }
485
- stream.write(` Possible duplicates of ${ticketKey}:\n`);
517
+ stream.write(` Possible duplicates:\n\n`);
486
518
  for (const r of results) {
487
- stream.write(` ${r.key} (${Math.round(r.score * 100)}% match) — ${r.summary}\n`);
519
+ if (r.linked) {
520
+ stream.write(` ${s.brand('●')} ${s.bold(r.key)} (${s.green(`Jira-linked — ${r.linkPhrase}`)}) — ${r.summary}\n`);
521
+ continue;
522
+ }
523
+ const pct = Math.round(r.score * 100);
524
+ const pctColor = matchColor(pct, s);
525
+ stream.write(` ${s.brand('●')} ${s.bold(r.key)} (${pctColor(`${pct}% match`)}) — ${r.summary}\n`);
488
526
  }
489
527
  return { ok: true, results };
490
528
  } catch (err) {