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
package/skills/jtb/SKILL.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- jtb-skill-version: 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
|
|
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
|
-
`
|
|
755
|
-
`
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
514
|
+
stream.write(` No likely duplicates found.\n`);
|
|
483
515
|
return { ok: true, results: [] };
|
|
484
516
|
}
|
|
485
|
-
stream.write(` Possible duplicates
|
|
517
|
+
stream.write(` Possible duplicates:\n\n`);
|
|
486
518
|
for (const r of results) {
|
|
487
|
-
|
|
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) {
|