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
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: {
|
|
@@ -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
|
-
|
|
197
|
-
|
|
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(`
|
|
332
|
-
stream.write(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(`
|
|
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
|
}
|