ticketlens 0.35.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.35.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: {
@@ -485,11 +485,27 @@ export async function runTicketDuplicates(cmdArgs, {
485
485
  if (!adapter) return { ok: false };
486
486
 
487
487
  try {
488
- 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 });
489
492
  const searchText = [source.summary, source.description].filter(Boolean).join(' ');
490
493
  const candidates = await adapter.findCandidates(searchText, ticketKey);
491
494
  const scoreOpts = threshold !== undefined ? { threshold } : {};
492
- 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))];
493
509
 
494
510
  const s = createStyler({ isTTY: stream.isTTY });
495
511
  stream.write(` ${s.brand(s.bold(ticketKey))}: ${s.bold(source.summary ?? '')}\n\n`);
@@ -500,6 +516,10 @@ export async function runTicketDuplicates(cmdArgs, {
500
516
  }
501
517
  stream.write(` Possible duplicates:\n\n`);
502
518
  for (const r of results) {
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
+ }
503
523
  const pct = Math.round(r.score * 100);
504
524
  const pctColor = matchColor(pct, s);
505
525
  stream.write(` ${s.brand('●')} ${s.bold(r.key)} (${pctColor(`${pct}% match`)}) — ${r.summary}\n`);