ticketlens 0.25.0 → 0.27.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/README.md +18 -5
- package/bin/ticketlens.mjs +29 -1
- package/package.json +2 -2
- package/skills/jtb/SKILL.md +9 -6
- package/skills/jtb/scripts/fetch-my-tickets.mjs +1 -4
- package/skills/jtb/scripts/lib/adapters/github-adapter.mjs +64 -0
- package/skills/jtb/scripts/lib/adapters/jira-adapter.mjs +47 -1
- package/skills/jtb/scripts/lib/adapters/linear-adapter.mjs +79 -0
- package/skills/jtb/scripts/lib/api-utils.mjs +1 -1
- package/skills/jtb/scripts/lib/cli.mjs +8 -0
- package/skills/jtb/scripts/lib/duplicate-scorer.mjs +69 -0
- package/skills/jtb/scripts/lib/help.mjs +68 -5
- package/skills/jtb/scripts/lib/jira-client.mjs +63 -0
- package/skills/jtb/scripts/lib/mcp-server.mjs +73 -4
- package/skills/jtb/scripts/lib/resolve-adapter.mjs +1 -1
- package/skills/jtb/scripts/lib/ticket-command.mjs +217 -0
package/README.md
CHANGED
|
@@ -411,7 +411,7 @@ Every note is scanned before saving — anything shaped like a real secret (API
|
|
|
411
411
|
|
|
412
412
|
**Removing a note:** `ticketlens note delete --id="..." [--ticket=KEY]` removes a note from your local vault. Local only — if it was already pushed to a team, teammates who pulled it keep their copy; deleting it there too is a manager action from the Console (Admin > Recall).
|
|
413
413
|
|
|
414
|
-
**Any MCP-capable AI harness:** `ticketlens mcp` starts a stdio [MCP](https://modelcontextprotocol.io) server exposing `recall_add`, `recall_search`, `ticket_comment`, `ticket_transition`, and `
|
|
414
|
+
**Any MCP-capable AI harness:** `ticketlens mcp` starts a stdio [MCP](https://modelcontextprotocol.io) server exposing `recall_add`, `recall_search`, `ticket_comment`, `ticket_transition`, `ticket_assign`, `ticket_duplicates`, and `ticket_link` as native tools — any MCP-compatible AI assistant, not just Claude Code, can call them directly instead of constructing a shell command. It's a thin adapter over the exact same code as the CLI commands above — same Pro gate, same secret scan/local vault/tracker writes, same team sync — nothing is reimplemented. Point your harness's MCP config at it: `{ "command": "ticketlens", "args": ["mcp"] }` — or run `ticketlens mcp install` in a project to write that entry into its `.mcp.json` for you (creates the file if it doesn't exist, merges in if it does — never touches any other entry already there; `--dry-run` to preview first).
|
|
415
415
|
|
|
416
416
|
`note add`'s save confirmation and `recall`'s search results are styled by default in a terminal; add `--plain` to either for bare, pipe-safe output. `recall` always shows each note's file ID (e.g. `[1784135399545-fe01c4.md]`) so you can open it directly (`cat ~/.ticketlens/recall/<PREFIX>/<id>`), or pass `--full` to print the full body content inline instead.
|
|
417
417
|
|
|
@@ -419,22 +419,29 @@ Every note is scanned before saving — anything shaped like a real secret (API
|
|
|
419
419
|
|
|
420
420
|
---
|
|
421
421
|
|
|
422
|
-
### Comment, Transition &
|
|
422
|
+
### Comment, Transition, Assign, Duplicates & Link
|
|
423
423
|
|
|
424
424
|
```bash
|
|
425
425
|
ticketlens comment PROJ-123 --body="Looks good, merging." # Post a comment to the tracker
|
|
426
426
|
ticketlens transition PROJ-123 # List valid transitions (read-only)
|
|
427
427
|
ticketlens transition PROJ-123 --target="Done" --confirm # Execute the transition
|
|
428
428
|
ticketlens assign PROJ-123 --to=me # Assign the ticket to yourself
|
|
429
|
+
ticketlens duplicates PROJ-123 # Find likely duplicates (read-only)
|
|
430
|
+
ticketlens link PROJ-123 PROJ-456 # List valid link types (read-only)
|
|
431
|
+
ticketlens link PROJ-123 PROJ-456 --type="Duplicate" --confirm # Execute the link
|
|
429
432
|
```
|
|
430
433
|
|
|
431
|
-
Write directly to the ticket in its real tracker — Jira, GitHub, or Linear — from your terminal or an AI session via `ticket_comment`/`ticket_transition`/`ticket_assign` MCP tools. Requires a Pro license.
|
|
434
|
+
Write directly to the ticket in its real tracker — Jira, GitHub, or Linear — from your terminal or an AI session via `ticket_comment`/`ticket_transition`/`ticket_assign`/`ticket_duplicates`/`ticket_link` MCP tools. Requires a Pro license.
|
|
432
435
|
|
|
433
436
|
`ticketlens transition` with just a ticket key lists the tracker's current valid options without changing anything (Jira: real workflow transitions for that issue; GitHub: open/closed; Linear: team-scoped workflow states). Add both `--target` and `--confirm` to execute — `--confirm` is a deliberate two-step gate: a behavioral nudge and forensic trail, not a hard security guarantee. Every write, once resolved, is re-validated against the tracker's current state immediately before executing — never a blind write against a stale option.
|
|
434
437
|
|
|
435
438
|
`ticketlens assign` is self-assign only for now — `--to` must be `me`. Assigning to someone else needs a per-tracker user-lookup step this doesn't do yet, so it's deliberately out of scope until that's built.
|
|
436
439
|
|
|
437
|
-
|
|
440
|
+
`ticketlens duplicates` is read-only — it never links or changes anything, just lists likely matches in the same project. No tracker (Jira/GitHub/Linear) scores similarity server-side, so ranking happens locally from title/description word overlap; treat a match as a nudge to check manually, not a verdict. `--threshold=N` (0–1, default 0.35) controls how loose a match counts.
|
|
441
|
+
|
|
442
|
+
`ticketlens link SOURCE-KEY TARGET-KEY` links two tickets — direction matters: SOURCE "types" TARGET (e.g. `link A B --type=Duplicate` means A duplicates B, not the other way around). With just the two keys it lists the tracker's current valid link types without changing anything — always fetched live for Jira, since link type names are per-instance configurable there. GitHub is different from Jira/Linear: it has no generic link relationship, so linking on a GitHub-tracked ticket *closes SOURCE as a duplicate of TARGET* — a state change, not just a relationship add — and prints an explicit warning immediately before that happens, on top of the same `--confirm` gate.
|
|
443
|
+
|
|
444
|
+
All four write actions (comment/transition/assign/link) have a short local debounce (10s) against an accidental double-fire (a flaky retry, hitting enter twice), and every successful write is appended to a local, append-only audit log (`~/.ticketlens/ticket-action-log.jsonl`). A write that times out is never retried automatically — unlike Recall notes, ticket writes aren't naturally idempotent, so a timed-out attempt is surfaced to you instead of silently repeated. `duplicates` has neither, since nothing is written.
|
|
438
445
|
|
|
439
446
|
---
|
|
440
447
|
|
|
@@ -715,11 +722,15 @@ ticketlens mcp # Start the MCP stdio server (reca
|
|
|
715
722
|
ticketlens mcp install # Register it into the current project's .mcp.json
|
|
716
723
|
ticketlens mcp install --dry-run # Preview the registration without writing
|
|
717
724
|
|
|
718
|
-
# ── Comment, Transition &
|
|
725
|
+
# ── Comment, Transition, Assign, Duplicates & Link ──────────────────────────────
|
|
719
726
|
ticketlens comment CNV1-2 --body="Looks good, merging." # Post a comment to the tracker [Pro]
|
|
720
727
|
ticketlens transition CNV1-2 # List valid transitions (read-only) [Pro]
|
|
721
728
|
ticketlens transition CNV1-2 --target="Done" --confirm # Execute the transition [Pro]
|
|
722
729
|
ticketlens assign CNV1-2 --to=me # Assign the ticket to yourself [Pro]
|
|
730
|
+
ticketlens duplicates CNV1-2 # Find likely duplicates (read-only) [Pro]
|
|
731
|
+
ticketlens duplicates CNV1-2 --threshold=0.5 # Tighten the match threshold [Pro]
|
|
732
|
+
ticketlens link CNV1-2 CNV1-3 # List valid link types (read-only) [Pro]
|
|
733
|
+
ticketlens link CNV1-2 CNV1-3 --type="Duplicate" --confirm # Execute the link [Pro]
|
|
723
734
|
|
|
724
735
|
# ── Stats ──────────────────────────────────────────────────────────────────────
|
|
725
736
|
ticketlens stats # Response-time metrics from local history
|
|
@@ -805,6 +816,8 @@ ticketlens mcp # Start the MCP stdio server (recall/ti
|
|
|
805
816
|
ticketlens comment CNV1-2 --body="..." # Post a comment to the tracker
|
|
806
817
|
ticketlens transition CNV1-2 --target="Done" --confirm # Transition ticket status
|
|
807
818
|
ticketlens assign CNV1-2 --to=me # Assign the ticket to yourself
|
|
819
|
+
ticketlens duplicates CNV1-2 # Find likely duplicates (read-only)
|
|
820
|
+
ticketlens link CNV1-2 CNV1-3 --type="Duplicate" --confirm # Link two tickets
|
|
808
821
|
ticketlens activate YOUR-LICENSE-KEY # Activate Pro license
|
|
809
822
|
```
|
|
810
823
|
|
package/bin/ticketlens.mjs
CHANGED
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
printCollisionsHelp, printStatsHelp,
|
|
29
29
|
printCloudKeysHelp,
|
|
30
30
|
printNoteHelp, printRecallHelp, printMcpHelp,
|
|
31
|
-
printCommentHelp, printTransitionHelp, printAssignHelp,
|
|
31
|
+
printCommentHelp, printTransitionHelp, printAssignHelp, printDuplicatesHelp, printLinkHelp,
|
|
32
32
|
} from '../skills/jtb/scripts/lib/help.mjs';
|
|
33
33
|
import { runStats } from '../skills/jtb/scripts/lib/run-stats.mjs';
|
|
34
34
|
import { createStyler } from '../skills/jtb/scripts/lib/ansi.mjs';
|
|
@@ -778,6 +778,34 @@ switch (command) {
|
|
|
778
778
|
break;
|
|
779
779
|
}
|
|
780
780
|
|
|
781
|
+
case 'duplicates': {
|
|
782
|
+
if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printDuplicatesHelp(); break; }
|
|
783
|
+
const { runTicketDuplicates } = await import('../skills/jtb/scripts/lib/ticket-command.mjs');
|
|
784
|
+
runTicketDuplicates(cmdArgs).then(({ ok }) => {
|
|
785
|
+
if (!ok) process.exitCode = 1;
|
|
786
|
+
}).catch(err => {
|
|
787
|
+
process.stderr.write(`Error: ${err.message}\n`);
|
|
788
|
+
process.exitCode = 1;
|
|
789
|
+
});
|
|
790
|
+
break;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
case 'link': {
|
|
794
|
+
if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printLinkHelp(); break; }
|
|
795
|
+
const { runTicketLinkList, runTicketLink } = await import('../skills/jtb/scripts/lib/ticket-command.mjs');
|
|
796
|
+
// No --type → discovery only, never mutates. --type present → execute
|
|
797
|
+
// (runTicketLink itself still refuses without --confirm).
|
|
798
|
+
const hasType = cmdArgs.some(a => a.startsWith('--type='));
|
|
799
|
+
const runFn = hasType ? runTicketLink : runTicketLinkList;
|
|
800
|
+
runFn(cmdArgs).then(({ ok }) => {
|
|
801
|
+
if (!ok) process.exitCode = 1;
|
|
802
|
+
}).catch(err => {
|
|
803
|
+
process.stderr.write(`Error: ${err.message}\n`);
|
|
804
|
+
process.exitCode = 1;
|
|
805
|
+
});
|
|
806
|
+
break;
|
|
807
|
+
}
|
|
808
|
+
|
|
781
809
|
case 'help':
|
|
782
810
|
default: {
|
|
783
811
|
const isInteractive = args.length === 0 && process.stdin.isTTY && process.stdout.isTTY && !process.env.CI;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ticketlens",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.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": {
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"skills/jtb/scripts/fetch-my-tickets.mjs"
|
|
18
18
|
],
|
|
19
19
|
"scripts": {
|
|
20
|
-
"test": "node --test skills/jtb/scripts/test
|
|
20
|
+
"test": "node --test 'skills/jtb/scripts/test/**/*.test.mjs'",
|
|
21
21
|
"postinstall": "node scripts/postinstall.mjs",
|
|
22
22
|
"prepublishOnly": "node scripts/preflight.mjs",
|
|
23
23
|
"publish:beta": "node scripts/publish.mjs --tag=beta",
|
package/skills/jtb/SKILL.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- jtb-skill-version: 0.
|
|
1
|
+
<!-- jtb-skill-version: 0.26.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.
|
|
@@ -264,26 +264,29 @@ Recall notes are stored locally at `~/.ticketlens/recall/`. On a Pro account wit
|
|
|
264
264
|
|
|
265
265
|
---
|
|
266
266
|
|
|
267
|
-
## Comment, Transition &
|
|
267
|
+
## Comment, Transition, Assign & Duplicates — write back to the tracker (Pro)
|
|
268
268
|
|
|
269
|
-
Unlike Recall (a local note about a ticket),
|
|
269
|
+
Unlike Recall (a local note about a ticket), comment/transition/assign write directly to the ticket's real tracker — Jira, GitHub, or Linear. Only dispatch a write when the user has actually asked for the ticket to be commented on, moved, or assigned — never as a routine end-of-session action the way Recall capture is. `duplicates` is read-only and safe to run more freely — it never mutates anything.
|
|
270
270
|
|
|
271
271
|
```bash
|
|
272
272
|
ticketlens comment PROD-1234 --body="Fixed in a2f9c1, deployed to staging."
|
|
273
273
|
ticketlens transition PROD-1234 # list valid transitions — read-only
|
|
274
274
|
ticketlens transition PROD-1234 --target="Done" --confirm # execute
|
|
275
275
|
ticketlens assign PROD-1234 --to=me # assign to yourself
|
|
276
|
+
ticketlens duplicates PROD-1234 # find likely duplicates — read-only
|
|
276
277
|
```
|
|
277
278
|
|
|
278
279
|
`transition` called with just a ticket key never mutates anything — it lists the tracker's current valid options (Jira: real workflow transitions for that issue; GitHub: open/closed; Linear: team-scoped workflow states). Only add `--target` **and** `--confirm` once the target has actually been confirmed with the user — `--confirm` is a deliberate two-step gate, not a formality to route around. Never guess a `--target` value; always list first, then use one of the names shown.
|
|
279
280
|
|
|
280
281
|
`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.
|
|
281
282
|
|
|
282
|
-
|
|
283
|
+
`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.
|
|
283
284
|
|
|
284
|
-
|
|
285
|
+
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.
|
|
285
286
|
|
|
286
|
-
|
|
287
|
+
**Pick exactly one path per action — never both.** If this harness has TicketLens's MCP server configured (`ticketlens mcp` — `ticket_comment`/`ticket_transition`/`ticket_assign`/`ticket_duplicates` as native tools, see `ticketlens mcp --help`), prefer calling those tools directly over the bash commands above — same license gate, same cooldown, same audit log. Fall back to the bash form only when the MCP tools aren't available.
|
|
288
|
+
|
|
289
|
+
Requires a Pro license — on Free, all four no-op with an upgrade hint on stderr.
|
|
287
290
|
|
|
288
291
|
---
|
|
289
292
|
|
|
@@ -10,6 +10,7 @@ import { assembleTriageSummary } from './lib/brief-assembler.mjs';
|
|
|
10
10
|
import { styleTriageSummary } from './lib/styled-assembler.mjs';
|
|
11
11
|
import { resolveConnection, loadProfiles, saveProfile } from './lib/profile-resolver.mjs';
|
|
12
12
|
import { resolveAdapter } from './lib/resolve-adapter.mjs';
|
|
13
|
+
import { escapeJql } from './lib/jira-client.mjs';
|
|
13
14
|
import { incrementTriageRun, readAndResetActivity } from './lib/activity-counter.mjs';
|
|
14
15
|
import { DEFAULT_CONFIG_DIR } from './lib/config.mjs';
|
|
15
16
|
import { writeFileSync, mkdirSync, statSync } from 'node:fs';
|
|
@@ -42,10 +43,6 @@ async function defaultDigestDeliverer(payload, { cliToken } = {}) {
|
|
|
42
43
|
return true;
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
function escapeJql(s) {
|
|
46
|
-
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
47
|
-
}
|
|
48
|
-
|
|
49
46
|
export async function run(args, envOrOpts = process.env, fetcher = globalThis.fetch, configDir = undefined) {
|
|
50
47
|
// Support both legacy positional form run(args, env, fetcher, configDir)
|
|
51
48
|
// and new opts-object form run(args, { env, fetcher, configDir, exporter, isLicensed, showUpgradePrompt, print })
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { tokenize } from '../duplicate-scorer.mjs';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Parses owner and repo from a GitHub profile baseUrl.
|
|
3
5
|
* Expected format: https://github.com/OWNER/REPO
|
|
@@ -27,6 +29,7 @@ export function parseGitHubRepo(baseUrl) {
|
|
|
27
29
|
export function normalizeGitHubIssue(raw, comments = [], keyPrefix = 'GH') {
|
|
28
30
|
return {
|
|
29
31
|
key: `${keyPrefix}-${raw.number}`,
|
|
32
|
+
id: raw.id,
|
|
30
33
|
summary: raw.title,
|
|
31
34
|
type: 'Issue',
|
|
32
35
|
status: raw.state,
|
|
@@ -208,5 +211,66 @@ export function createGitHubAdapter(conn, { fetcher = globalThis.fetch } = {}) {
|
|
|
208
211
|
if (!res.ok) await throwGitHubWriteError(res, 'assigning', key);
|
|
209
212
|
return { assignee: me.displayName ?? me.login };
|
|
210
213
|
},
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Candidate search for duplicate-ticket detection via GitHub's search
|
|
217
|
+
* endpoint (distinct from the issues-list endpoint `searchTickets` uses,
|
|
218
|
+
* which hardcodes "assigned to me" and ignores its query argument).
|
|
219
|
+
* Excludes the source issue and returns unranked candidates —
|
|
220
|
+
* duplicate-scorer.mjs does the actual similarity ranking.
|
|
221
|
+
*
|
|
222
|
+
* Never interpolates raw ticket text into `q` — GitHub parses the
|
|
223
|
+
* decoded query with its own qualifier grammar (`repo:`, `org:`, `is:`,
|
|
224
|
+
* etc, space-delimited), and multiple `repo:`/`org:` qualifiers are
|
|
225
|
+
* OR'd together. A crafted title/description containing e.g.
|
|
226
|
+
* `repo:otherorg/private-repo` would widen the search to a repo outside
|
|
227
|
+
* this profile's scope — a confused-deputy query-injection, not just a
|
|
228
|
+
* cosmetic bug. tokenize() strips all non-letter/digit characters
|
|
229
|
+
* (including `:`), so no qualifier syntax survives into `q`, and its
|
|
230
|
+
* cap keeps the query well under GitHub's search length limit.
|
|
231
|
+
*/
|
|
232
|
+
async findCandidates(text, sourceKey, opts = {}) {
|
|
233
|
+
const sourceNumber = parseInt(sourceKey.split('-').pop(), 10);
|
|
234
|
+
const terms = tokenize(text).slice(0, 8).join(' ');
|
|
235
|
+
const q = `repo:${owner}/${repo} is:issue ${terms}`;
|
|
236
|
+
const url = `${GITHUB_API}/search/issues?q=${encodeURIComponent(q)}`;
|
|
237
|
+
const res = await fetcher(url, { headers, signal: AbortSignal.timeout(opts.timeoutMs ?? 10_000) });
|
|
238
|
+
if (!res.ok) await throwGitHubWriteError(res, 'searching', sourceKey);
|
|
239
|
+
const raw = await res.json();
|
|
240
|
+
return raw.items
|
|
241
|
+
.filter(item => item.number !== sourceNumber)
|
|
242
|
+
.map(item => normalizeGitHubIssue(item, [], keyPrefix));
|
|
243
|
+
},
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* GitHub has no generic link-type concept — "duplicate" (via closing
|
|
247
|
+
* the source issue) is the only relationship it supports natively.
|
|
248
|
+
*/
|
|
249
|
+
async getLinkTypes() {
|
|
250
|
+
return ['duplicate'];
|
|
251
|
+
},
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* GitHub's only real "link" action closes sourceKey as a duplicate of
|
|
255
|
+
* targetKey — asymmetric and state-changing, unlike Jira/Linear's pure
|
|
256
|
+
* relationship-add. Resolves targetKey's internal id via fetchTicket
|
|
257
|
+
* (GitHub's duplicate_issue_id wants the internal id, not the
|
|
258
|
+
* repo-local number).
|
|
259
|
+
*/
|
|
260
|
+
async linkTo(sourceKey, targetKey, typeName, opts = {}) {
|
|
261
|
+
if (typeName.toLowerCase() !== 'duplicate') {
|
|
262
|
+
throw new Error(`GitHub only supports linking as a duplicate — got type "${typeName}".`);
|
|
263
|
+
}
|
|
264
|
+
const target = await this.fetchTicket(targetKey, opts);
|
|
265
|
+
const sourceNumber = parseInt(sourceKey.split('-').pop(), 10);
|
|
266
|
+
const res = await fetcher(`${GITHUB_API}/repos/${owner}/${repo}/issues/${sourceNumber}`, {
|
|
267
|
+
method: 'PATCH',
|
|
268
|
+
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
269
|
+
body: JSON.stringify({ state: 'closed', state_reason: 'duplicate', duplicate_issue_id: target.id }),
|
|
270
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? 10_000),
|
|
271
|
+
});
|
|
272
|
+
if (!res.ok) await throwGitHubWriteError(res, 'linking', sourceKey);
|
|
273
|
+
return { executed: true, closedAsDuplicateOf: targetKey };
|
|
274
|
+
},
|
|
211
275
|
};
|
|
212
276
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { fetchTicket, fetchCurrentUser, searchTickets, fetchStatuses, postComment, getTransitions, postTransition, assignIssue } from '../jira-client.mjs';
|
|
1
|
+
import { fetchTicket, fetchCurrentUser, searchTickets, fetchStatuses, postComment, getTransitions, postTransition, assignIssue, escapeJql, getIssueLinkTypes, postIssueLink } from '../jira-client.mjs';
|
|
2
2
|
import { buildJiraEnv } from '../config.mjs';
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -7,6 +7,8 @@ import { buildJiraEnv } from '../config.mjs';
|
|
|
7
7
|
* trusts a caller-supplied id without confirming it's still a real,
|
|
8
8
|
* currently-valid option for this exact issue right now.
|
|
9
9
|
*/
|
|
10
|
+
const SEARCH_TEXT_CHAR_LIMIT = 300;
|
|
11
|
+
|
|
10
12
|
function resolveTransitionTarget(options, target) {
|
|
11
13
|
const t = String(target).toLowerCase();
|
|
12
14
|
return options.find(o => o.id === String(target) || o.name.toLowerCase() === t || (o.to ?? '').toLowerCase() === t);
|
|
@@ -64,5 +66,49 @@ export function createJiraAdapter(conn, { fetcher = globalThis.fetch } = {}) {
|
|
|
64
66
|
await assignIssue(key, { [field]: value }, { ...base, ...opts });
|
|
65
67
|
return { assignee: me.displayName ?? value };
|
|
66
68
|
},
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Candidate search for duplicate-ticket detection. Scoped to the same
|
|
72
|
+
* project as `sourceKey` (derived from its own prefix) and excludes it
|
|
73
|
+
* from results. Jira has no server-side similarity scoring — this only
|
|
74
|
+
* narrows the candidate pool; ranking happens in duplicate-scorer.mjs.
|
|
75
|
+
*/
|
|
76
|
+
async findCandidates(text, sourceKey, opts = {}) {
|
|
77
|
+
const hyphenIndex = sourceKey.lastIndexOf('-');
|
|
78
|
+
if (hyphenIndex < 1) {
|
|
79
|
+
throw new Error(`Cannot derive a project key from "${sourceKey}" — expected PROJECT-123.`);
|
|
80
|
+
}
|
|
81
|
+
const project = sourceKey.slice(0, hyphenIndex);
|
|
82
|
+
const searchText = text.slice(0, SEARCH_TEXT_CHAR_LIMIT);
|
|
83
|
+
const jql = `project = "${escapeJql(project)}" AND key != "${escapeJql(sourceKey)}" AND text ~ "${escapeJql(searchText)}" ORDER BY updated DESC`;
|
|
84
|
+
return searchTickets(jql, { ...base, ...opts });
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Always fetched fresh — link type names are per-instance customizable
|
|
89
|
+
* in Jira, same "never trust a stale list" principle as getTransitions.
|
|
90
|
+
* Returns just names (matches GitHub/Linear's plain-string shape) so
|
|
91
|
+
* runTicketLinkList can render any tracker's list uniformly.
|
|
92
|
+
*/
|
|
93
|
+
async getLinkTypes(opts = {}) {
|
|
94
|
+
const types = await getIssueLinkTypes({ ...base, ...opts });
|
|
95
|
+
return types.map(t => t.name);
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Always re-fetches link types fresh and resolves `typeName` against
|
|
100
|
+
* them before executing — a caller can never blind-POST a stale or
|
|
101
|
+
* guessed type name, same principle as transition().
|
|
102
|
+
* sourceKey is the outwardIssue, targetKey is the inwardIssue — direction matters.
|
|
103
|
+
*/
|
|
104
|
+
async linkTo(sourceKey, targetKey, typeName, opts = {}) {
|
|
105
|
+
const types = await getIssueLinkTypes({ ...base, ...opts });
|
|
106
|
+
const match = types.find(t => t.name.toLowerCase() === typeName.toLowerCase());
|
|
107
|
+
if (!match) {
|
|
108
|
+
return { executed: false, reason: 'not-found', options: types.map(t => t.name) };
|
|
109
|
+
}
|
|
110
|
+
await postIssueLink(sourceKey, targetKey, match.name, { ...base, ...opts });
|
|
111
|
+
return { executed: true };
|
|
112
|
+
},
|
|
67
113
|
};
|
|
68
114
|
}
|
|
@@ -1,7 +1,12 @@
|
|
|
1
|
+
import { tokenize } from '../duplicate-scorer.mjs';
|
|
2
|
+
|
|
1
3
|
const LINEAR_API = 'https://api.linear.app/graphql';
|
|
2
4
|
|
|
3
5
|
const PRIORITY_LABELS = { 1: 'Urgent', 2: 'High', 3: 'Medium', 4: 'Low' };
|
|
4
6
|
|
|
7
|
+
/** Linear's IssueRelationType enum — fixed schema-level values, confirmed via GraphQL introspection. */
|
|
8
|
+
const LINK_TYPES = ['blocks', 'duplicate', 'related'];
|
|
9
|
+
|
|
5
10
|
const ISSUE_FIELDS = `
|
|
6
11
|
identifier
|
|
7
12
|
title
|
|
@@ -257,5 +262,79 @@ export function createLinearAdapter(conn, { fetcher = globalThis.fetch } = {}) {
|
|
|
257
262
|
}
|
|
258
263
|
return { assignee: me.displayName ?? me.id };
|
|
259
264
|
},
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Candidate search for duplicate-ticket detection. Linear's `contains`/
|
|
268
|
+
* `containsIgnoreCase` filters are literal substring matches, not
|
|
269
|
+
* full-text search (confirmed — Linear has no built-in similarity
|
|
270
|
+
* matching) — passing the whole source text as one substring filter
|
|
271
|
+
* would almost never match anything. Instead ORs across the
|
|
272
|
+
* significant tokens, ANDed with a team scope derived from the source
|
|
273
|
+
* key's prefix. Ranking happens in duplicate-scorer.mjs.
|
|
274
|
+
*/
|
|
275
|
+
async findCandidates(text, sourceKey, opts = {}) {
|
|
276
|
+
const terms = tokenize(text).slice(0, 5);
|
|
277
|
+
if (terms.length === 0) return [];
|
|
278
|
+
const hyphenIndex = sourceKey.lastIndexOf('-');
|
|
279
|
+
if (hyphenIndex < 1) {
|
|
280
|
+
throw new Error(`Cannot derive a team key from "${sourceKey}" — expected TEAM-123.`);
|
|
281
|
+
}
|
|
282
|
+
const signal = AbortSignal.timeout(opts.timeoutMs ?? 10_000);
|
|
283
|
+
const prefix = sourceKey.slice(0, hyphenIndex);
|
|
284
|
+
const filter = {
|
|
285
|
+
team: { key: { eq: prefix } },
|
|
286
|
+
or: terms.map(term => ({ title: { containsIgnoreCase: term } })),
|
|
287
|
+
};
|
|
288
|
+
const data = await gql(
|
|
289
|
+
`query ($filter: IssueFilter) {
|
|
290
|
+
issues(filter: $filter, first: 50) {
|
|
291
|
+
nodes { ${ISSUE_FIELDS} }
|
|
292
|
+
}
|
|
293
|
+
}`,
|
|
294
|
+
{ filter },
|
|
295
|
+
{ token, fetcher, signal },
|
|
296
|
+
);
|
|
297
|
+
return (data.issues?.nodes ?? [])
|
|
298
|
+
.filter(node => node.identifier !== sourceKey)
|
|
299
|
+
.map(normalizeLinearIssue);
|
|
300
|
+
},
|
|
301
|
+
|
|
302
|
+
/** Fixed schema-level enum (confirmed via GraphQL introspection) — never per-instance configurable, unlike Jira's link types. */
|
|
303
|
+
async getLinkTypes() {
|
|
304
|
+
return LINK_TYPES;
|
|
305
|
+
},
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Validates typeName against the fixed enum before ever touching the
|
|
309
|
+
* network — an invalid type must never reach gql(), which throws a
|
|
310
|
+
* plain Error with no .status, and would otherwise be misclassified
|
|
311
|
+
* as a network/timeout failure by classifyWriteFailure. Resolves both
|
|
312
|
+
* issues' internal UUIDs via fetchIssueStateInfo — issueRelationCreate
|
|
313
|
+
* needs the UUID, never the human identifier. Explicitly checks
|
|
314
|
+
* `success`: Linear can return HTTP 200 with no top-level GraphQL
|
|
315
|
+
* errors and still report success:false.
|
|
316
|
+
*/
|
|
317
|
+
async linkTo(sourceKey, targetKey, typeName, opts = {}) {
|
|
318
|
+
const normalizedType = typeName.toLowerCase();
|
|
319
|
+
if (!LINK_TYPES.includes(normalizedType)) {
|
|
320
|
+
return { executed: false, reason: 'not-found', options: LINK_TYPES };
|
|
321
|
+
}
|
|
322
|
+
const signal = AbortSignal.timeout(opts.timeoutMs ?? 10_000);
|
|
323
|
+
const source = await fetchIssueStateInfo(sourceKey, { token, fetcher, signal });
|
|
324
|
+
const target = await fetchIssueStateInfo(targetKey, { token, fetcher, signal });
|
|
325
|
+
const data = await gql(
|
|
326
|
+
`mutation ($issueId: String!, $relatedIssueId: String!, $type: IssueRelationType!) {
|
|
327
|
+
issueRelationCreate(input: { issueId: $issueId, relatedIssueId: $relatedIssueId, type: $type }) {
|
|
328
|
+
success
|
|
329
|
+
}
|
|
330
|
+
}`,
|
|
331
|
+
{ issueId: source.id, relatedIssueId: target.id, type: normalizedType },
|
|
332
|
+
{ token, fetcher, signal },
|
|
333
|
+
);
|
|
334
|
+
if (!data.issueRelationCreate?.success) {
|
|
335
|
+
throw new Error(`Linear issueRelationCreate reported success:false linking ${sourceKey} to ${targetKey}`);
|
|
336
|
+
}
|
|
337
|
+
return { executed: true };
|
|
338
|
+
},
|
|
260
339
|
};
|
|
261
340
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Centralised here to avoid triplicating the regex and warning logic.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
export const DEFAULT_API_BASE = '
|
|
6
|
+
export const DEFAULT_API_BASE = 'http://api.ticketlens.test';
|
|
7
7
|
export const DEFAULT_SITE_BASE = 'https://ticketlens.app';
|
|
8
8
|
|
|
9
9
|
// Matches localhost, 127.0.0.1, and any hostname ending in .test or .local,
|
|
@@ -142,6 +142,14 @@ export function parseCommand(args) {
|
|
|
142
142
|
return { command: 'assign', args: args.slice(1) };
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
+
if (first === 'duplicates') {
|
|
146
|
+
return { command: 'duplicates', args: args.slice(1) };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (first === 'link') {
|
|
150
|
+
return { command: 'link', args: args.slice(1) };
|
|
151
|
+
}
|
|
152
|
+
|
|
145
153
|
// Anything that looks like a ticket key or any non-flag arg → fetch
|
|
146
154
|
return { command: 'fetch', args };
|
|
147
155
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zero-dependency duplicate-ticket scoring. No tracker (Jira/GitHub/Linear)
|
|
3
|
+
* offers server-side similarity scoring — only exact/substring text filters —
|
|
4
|
+
* so candidate ranking happens here via Jaccard set overlap on tokenized text.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const STOPWORDS = new Set([
|
|
8
|
+
'the', 'a', 'an', 'to', 'of', 'in', 'on', 'for', 'and', 'or', 'is', 'are',
|
|
9
|
+
'this', 'that', 'with', 'as', 'at', 'by', 'from', 'it', 'be', 'was', 'were',
|
|
10
|
+
'has', 'have', 'had', 'not', 'but', 'if', 'will', 'can', 'do', 'does',
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
const TITLE_WEIGHT = 2;
|
|
14
|
+
const DESCRIPTION_WEIGHT = 1;
|
|
15
|
+
const DESCRIPTION_CHAR_LIMIT = 500;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Lowercases, strips punctuation, drops stopwords and sub-2-char tokens,
|
|
19
|
+
* and de-duplicates. Returns a plain array (order not significant — callers
|
|
20
|
+
* treat it as a set).
|
|
21
|
+
*/
|
|
22
|
+
export function tokenize(text) {
|
|
23
|
+
if (!text) return [];
|
|
24
|
+
const words = text.toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, ' ').split(/\s+/).filter(Boolean);
|
|
25
|
+
const seen = new Set();
|
|
26
|
+
for (const w of words) {
|
|
27
|
+
if (w.length >= 2 && !STOPWORDS.has(w)) seen.add(w);
|
|
28
|
+
}
|
|
29
|
+
return [...seen];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Intersection-over-union. Two empty sets are defined as 0 similarity, not 1 —
|
|
34
|
+
* an empty-vs-empty match is a lack of signal, not a confirmed duplicate.
|
|
35
|
+
*/
|
|
36
|
+
export function jaccardSimilarity(tokensA, tokensB) {
|
|
37
|
+
const setA = new Set(tokensA);
|
|
38
|
+
const setB = new Set(tokensB);
|
|
39
|
+
if (setA.size === 0 && setB.size === 0) return 0;
|
|
40
|
+
let intersection = 0;
|
|
41
|
+
for (const t of setA) {
|
|
42
|
+
if (setB.has(t)) intersection += 1;
|
|
43
|
+
}
|
|
44
|
+
const union = setA.size + setB.size - intersection;
|
|
45
|
+
return union === 0 ? 0 : intersection / union;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function weightedScore(source, candidate) {
|
|
49
|
+
const titleScore = jaccardSimilarity(tokenize(source.summary), tokenize(candidate.summary));
|
|
50
|
+
const descScore = jaccardSimilarity(
|
|
51
|
+
tokenize((source.description ?? '').slice(0, DESCRIPTION_CHAR_LIMIT)),
|
|
52
|
+
tokenize((candidate.description ?? '').slice(0, DESCRIPTION_CHAR_LIMIT)),
|
|
53
|
+
);
|
|
54
|
+
return (titleScore * TITLE_WEIGHT + descScore * DESCRIPTION_WEIGHT) / (TITLE_WEIGHT + DESCRIPTION_WEIGHT);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Ranks candidates against the source ticket, excludes the source itself
|
|
59
|
+
* (defense-in-depth — callers already exclude it at the query level) and
|
|
60
|
+
* anything below `threshold`, sorted highest score first, capped at `limit`.
|
|
61
|
+
*/
|
|
62
|
+
export function scoreCandidates(source, candidates, { threshold = 0.35, limit = 5 } = {}) {
|
|
63
|
+
return candidates
|
|
64
|
+
.filter(c => c.key !== source.key)
|
|
65
|
+
.map(c => ({ key: c.key, summary: c.summary, score: weightedScore(source, c) }))
|
|
66
|
+
.filter(c => c.score >= threshold)
|
|
67
|
+
.sort((a, b) => b.score - a.score)
|
|
68
|
+
.slice(0, limit);
|
|
69
|
+
}
|
|
@@ -57,6 +57,8 @@ export function printHelp({ stream = process.stdout } = {}) {
|
|
|
57
57
|
` ${s.brand('ticketlens')} comment ${s.dim('<TICKET-KEY> --body=...')} Post a comment to the tracker ${s.dim('[Pro]')}`,
|
|
58
58
|
` ${s.brand('ticketlens')} transition ${s.dim('<TICKET-KEY> [--target=... --confirm]')} Move ticket status ${s.dim('[Pro]')}`,
|
|
59
59
|
` ${s.brand('ticketlens')} assign ${s.dim('<TICKET-KEY> --to=me')} Assign a ticket to yourself ${s.dim('[Pro]')}`,
|
|
60
|
+
` ${s.brand('ticketlens')} duplicates ${s.dim('<TICKET-KEY> [--threshold=N]')} Find likely duplicate tickets ${s.dim('[Pro]')}`,
|
|
61
|
+
` ${s.brand('ticketlens')} link ${s.dim('<SOURCE> <TARGET> [--type=... --confirm]')} Link two tickets ${s.dim('[Pro]')}`,
|
|
60
62
|
'',
|
|
61
63
|
` ${s.brand('ticketlens')} delete ${s.dim('<PROFILE-NAME>')} Remove a profile`,
|
|
62
64
|
` ${s.brand('ticketlens')} activate ${s.dim('<KEY>')} Activate a license key`,
|
|
@@ -597,11 +599,13 @@ export function printMcpHelp({ stream = process.stdout } = {}) {
|
|
|
597
599
|
'',
|
|
598
600
|
` Start an MCP (Model Context Protocol) stdio server exposing Recall and`,
|
|
599
601
|
` ticket writes as native tools — ${s.cyan('recall_add')}, ${s.cyan('recall_search')}, ${s.cyan('ticket_comment')},`,
|
|
600
|
-
` ${s.cyan('ticket_transition')}, ${s.cyan('ticket_assign')} — for any MCP-compatible
|
|
601
|
-
` Claude Code. Thin adapter over the same code as
|
|
602
|
-
` ${s.cyan('transition')}/${s.cyan('assign')} above: same Pro gate
|
|
603
|
-
` team sync. ${s.cyan('ticket_transition')} is destructive
|
|
604
|
-
` ${s.cyan('ticket_assign')} is currently self-assign
|
|
602
|
+
` ${s.cyan('ticket_transition')}, ${s.cyan('ticket_assign')}, ${s.cyan('ticket_duplicates')}, ${s.cyan('ticket_link')} — for any MCP-compatible`,
|
|
603
|
+
` AI harness, not just Claude Code. Thin adapter over the same code as`,
|
|
604
|
+
` ${s.cyan('note add')}/${s.cyan('recall')}/${s.cyan('comment')}/${s.cyan('transition')}/${s.cyan('assign')}/${s.cyan('duplicates')}/${s.cyan('link')} above: same Pro gate,`,
|
|
605
|
+
` same local vault/tracker writes, same team sync. ${s.cyan('ticket_transition')} is destructive`,
|
|
606
|
+
` when called with \`target\`+\`confirm: true\`; ${s.cyan('ticket_assign')} is currently self-assign`,
|
|
607
|
+
` only; ${s.cyan('ticket_duplicates')} is read-only; ${s.cyan('ticket_link')} on GitHub closes the source`,
|
|
608
|
+
` issue as a duplicate — different semantics than Jira/Linear's relationship-only add.`,
|
|
605
609
|
` Long-running — exits when the client closes stdin.`,
|
|
606
610
|
'',
|
|
607
611
|
` ${s.bold('OPTIONS')}`,
|
|
@@ -702,6 +706,65 @@ export function printAssignHelp({ stream = process.stdout } = {}) {
|
|
|
702
706
|
stream.write(lines.join('\n') + '\n');
|
|
703
707
|
}
|
|
704
708
|
|
|
709
|
+
export function printDuplicatesHelp({ stream = process.stdout } = {}) {
|
|
710
|
+
const s = createStyler({ isTTY: stream.isTTY });
|
|
711
|
+
const lines = [
|
|
712
|
+
'',
|
|
713
|
+
` ${s.bold(s.brand('ticketlens'))} ${s.bold('duplicates')} ${s.dim('TICKET-KEY [--threshold=0.35]')} ${s.dim('[Pro]')}`,
|
|
714
|
+
'',
|
|
715
|
+
` Find likely duplicate tickets in the same project (Jira/GitHub/Linear). ${s.dim('[Pro]')}`,
|
|
716
|
+
` Read-only — lists possible matches, never links or changes anything.`,
|
|
717
|
+
` No tracker scores similarity server-side, so ranking happens locally`,
|
|
718
|
+
` from title/description overlap. Not exact — treat it as a nudge to check.`,
|
|
719
|
+
'',
|
|
720
|
+
` ${s.bold('OPTIONS')}`,
|
|
721
|
+
'',
|
|
722
|
+
` ${s.brand('--threshold')}=${s.dim('N')} Minimum match score 0–1 to report ${s.dim('(default 0.35)')}`,
|
|
723
|
+
` ${s.brand('--profile')}=${s.dim('NAME')} Connection profile to use ${s.dim('(optional)')}`,
|
|
724
|
+
` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
|
|
725
|
+
'',
|
|
726
|
+
` ${s.bold('EXAMPLES')}`,
|
|
727
|
+
'',
|
|
728
|
+
` ${s.dim('$')} ticketlens duplicates PROD-123`,
|
|
729
|
+
` ${s.dim('$')} ticketlens duplicates PROD-123 --threshold=0.5`,
|
|
730
|
+
'',
|
|
731
|
+
];
|
|
732
|
+
stream.write(lines.join('\n') + '\n');
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
export function printLinkHelp({ stream = process.stdout } = {}) {
|
|
736
|
+
const s = createStyler({ isTTY: stream.isTTY });
|
|
737
|
+
const lines = [
|
|
738
|
+
'',
|
|
739
|
+
` ${s.bold(s.brand('ticketlens'))} ${s.bold('link')} ${s.dim('SOURCE-KEY TARGET-KEY [--type="..." --confirm]')} ${s.dim('[Pro]')}`,
|
|
740
|
+
'',
|
|
741
|
+
` Link two tickets in their tracker (Jira/GitHub/Linear). ${s.dim('[Pro]')}`,
|
|
742
|
+
` Called with just SOURCE and TARGET, lists the tracker's current valid`,
|
|
743
|
+
` link types without changing anything. Add ${s.brand('--type')} and ${s.brand('--confirm')} together to execute.`,
|
|
744
|
+
'',
|
|
745
|
+
` ${s.bold('Direction matters')}: SOURCE "types" TARGET — e.g. \`link A B --type=Duplicate\` means`,
|
|
746
|
+
` A duplicates B, not the other way around.`,
|
|
747
|
+
'',
|
|
748
|
+
` ${s.bold('GitHub is different')}: it has no generic link relationship. Linking on a`,
|
|
749
|
+
` GitHub-tracked ticket CLOSES SOURCE as a duplicate of TARGET — a state`,
|
|
750
|
+
` change, not just a relationship add like Jira/Linear.`,
|
|
751
|
+
'',
|
|
752
|
+
` ${s.bold('OPTIONS')}`,
|
|
753
|
+
'',
|
|
754
|
+
` ${s.brand('--type')}=${s.dim('NAME')} Link type ${s.dim('(from the list, case-insensitive; GitHub only supports "duplicate")')}`,
|
|
755
|
+
` ${s.brand('--confirm')} Required alongside --type to actually execute`,
|
|
756
|
+
` ${s.brand('--profile')}=${s.dim('NAME')} Connection profile to use ${s.dim('(optional)')}`,
|
|
757
|
+
` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
|
|
758
|
+
'',
|
|
759
|
+
` ${s.bold('EXAMPLES')}`,
|
|
760
|
+
'',
|
|
761
|
+
` ${s.dim('$')} ticketlens link PROD-123 PROD-456`,
|
|
762
|
+
` ${s.dim('$')} ticketlens link PROD-123 PROD-456 --type="Duplicate" --confirm`,
|
|
763
|
+
'',
|
|
764
|
+
];
|
|
765
|
+
stream.write(lines.join('\n') + '\n');
|
|
766
|
+
}
|
|
767
|
+
|
|
705
768
|
export function printSwitchHelp({ stream = process.stdout } = {}) {
|
|
706
769
|
const s = createStyler({ isTTY: stream.isTTY });
|
|
707
770
|
const lines = [
|
|
@@ -13,6 +13,14 @@ function toText(value) {
|
|
|
13
13
|
return adfToText(value);
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Escapes a value for safe interpolation into a double-quoted JQL string
|
|
18
|
+
* literal. Single source of truth — do not duplicate in callers.
|
|
19
|
+
*/
|
|
20
|
+
export function escapeJql(s) {
|
|
21
|
+
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
22
|
+
}
|
|
23
|
+
|
|
16
24
|
/**
|
|
17
25
|
* Extracts the active sprint name from customfield_10020.
|
|
18
26
|
* Cloud v3: array of sprint objects — prefers active, falls back to last.
|
|
@@ -494,6 +502,61 @@ export async function postTransition(ticketKey, transitionId, opts = {}) {
|
|
|
494
502
|
}
|
|
495
503
|
}
|
|
496
504
|
|
|
505
|
+
/**
|
|
506
|
+
* Lists this Jira instance's issue link types (id/name/inward/outward).
|
|
507
|
+
* Link type names are per-instance customizable — never hardcode or cache
|
|
508
|
+
* this list; callers must re-fetch fresh before every link, same principle
|
|
509
|
+
* as getTransitions().
|
|
510
|
+
*/
|
|
511
|
+
export async function getIssueLinkTypes(opts = {}) {
|
|
512
|
+
const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000, allowPrivateIp = false } = opts;
|
|
513
|
+
validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
|
|
514
|
+
const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
|
|
515
|
+
const url = `${baseUrl}/rest/api/${apiVersion}/issueLinkType`;
|
|
516
|
+
|
|
517
|
+
const fetchOpts = { headers: { ...buildAuthHeader(env), 'Content-Type': 'application/json' } };
|
|
518
|
+
if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
|
|
519
|
+
|
|
520
|
+
const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
|
|
521
|
+
if (!response.ok) {
|
|
522
|
+
const err = new Error(`Jira API error ${response.status} fetching issue link types`);
|
|
523
|
+
err.status = response.status;
|
|
524
|
+
throw err;
|
|
525
|
+
}
|
|
526
|
+
const raw = await response.json();
|
|
527
|
+
return (raw.issueLinkTypes ?? []).map(t => ({ id: t.id, name: t.name, inward: t.inward, outward: t.outward }));
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Creates a link between two issues. `sourceKey` is the outwardIssue (the
|
|
532
|
+
* subject of the type's outward verb, e.g. "Duplicates"), `targetKey` is
|
|
533
|
+
* the inwardIssue — direction matters and is the caller's responsibility
|
|
534
|
+
* to get right (ticket-command.mjs documents the convention).
|
|
535
|
+
*/
|
|
536
|
+
export async function postIssueLink(sourceKey, targetKey, typeName, opts = {}) {
|
|
537
|
+
const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000, allowPrivateIp = false } = opts;
|
|
538
|
+
validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
|
|
539
|
+
const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
|
|
540
|
+
const url = `${baseUrl}/rest/api/${apiVersion}/issueLink`;
|
|
541
|
+
|
|
542
|
+
const fetchOpts = {
|
|
543
|
+
method: 'POST',
|
|
544
|
+
headers: { ...buildAuthHeader(env), 'Content-Type': 'application/json' },
|
|
545
|
+
body: JSON.stringify({ type: { name: typeName }, outwardIssue: { key: sourceKey }, inwardIssue: { key: targetKey } }),
|
|
546
|
+
};
|
|
547
|
+
if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
|
|
548
|
+
|
|
549
|
+
const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
|
|
550
|
+
if (!response.ok) {
|
|
551
|
+
let details;
|
|
552
|
+
try { details = await response.json(); } catch { /* body not JSON — fall through with no details */ }
|
|
553
|
+
const err = new Error(`Jira API error ${response.status} linking ${sourceKey} to ${targetKey}`);
|
|
554
|
+
err.status = response.status;
|
|
555
|
+
err.details = details;
|
|
556
|
+
throw err;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
497
560
|
/**
|
|
498
561
|
* Sets the issue's assignee. `assignee` is sent verbatim — the caller
|
|
499
562
|
* (jira-adapter.mjs) resolves the right shape for the API version:
|
|
@@ -23,7 +23,7 @@ import readline from 'node:readline';
|
|
|
23
23
|
import { DEFAULT_CONFIG_DIR, getVersion } from './config.mjs';
|
|
24
24
|
import { runNoteAdd } from './note-command.mjs';
|
|
25
25
|
import { runRecall } from './recall-command.mjs';
|
|
26
|
-
import { runTicketComment, runTicketTransitionList, runTicketTransition, runTicketAssign } from './ticket-command.mjs';
|
|
26
|
+
import { runTicketComment, runTicketTransitionList, runTicketTransition, runTicketAssign, runTicketDuplicates, runTicketLinkList, runTicketLink } from './ticket-command.mjs';
|
|
27
27
|
|
|
28
28
|
const PROTOCOL_VERSION = '2025-11-25';
|
|
29
29
|
|
|
@@ -90,6 +90,32 @@ const TOOLS = [
|
|
|
90
90
|
required: ['ticket', 'to'],
|
|
91
91
|
},
|
|
92
92
|
},
|
|
93
|
+
{
|
|
94
|
+
name: 'ticket_duplicates',
|
|
95
|
+
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
|
+
inputSchema: {
|
|
97
|
+
type: 'object',
|
|
98
|
+
properties: {
|
|
99
|
+
ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
|
|
100
|
+
threshold: { type: 'number', description: 'Minimum match score 0-1 to report. Defaults to 0.35.' },
|
|
101
|
+
},
|
|
102
|
+
required: ['ticket'],
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
name: 'ticket_link',
|
|
107
|
+
description: 'List or execute a link between two tickets in their tracker (Jira/GitHub/Linear). Called with only `ticket`/`target`, lists the tracker\'s current valid link types without changing anything. Destructive when `type` and `confirm: true` are both given — writes directly to the live tracker. Direction matters: `ticket` "types" `target` (e.g. ticket duplicates target). On GitHub, executing CLOSES `ticket` as a duplicate of `target` — a state change, not just a relationship add like Jira/Linear. Requires a TicketLens Pro license.',
|
|
108
|
+
inputSchema: {
|
|
109
|
+
type: 'object',
|
|
110
|
+
properties: {
|
|
111
|
+
ticket: { type: 'string', description: 'Source ticket key, e.g. PROJ-123 — the one that "types" target.' },
|
|
112
|
+
target: { type: 'string', description: 'Target ticket key, e.g. PROJ-456.' },
|
|
113
|
+
type: { type: 'string', description: 'Link type name (from the list). Omit to just list the tracker\'s current valid options. GitHub only supports "duplicate".' },
|
|
114
|
+
confirm: { type: 'boolean', description: 'Must be true, alongside `type`, to actually execute the link — a nudge and audit trail, not just a formality.' },
|
|
115
|
+
},
|
|
116
|
+
required: ['ticket', 'target'],
|
|
117
|
+
},
|
|
118
|
+
},
|
|
93
119
|
];
|
|
94
120
|
|
|
95
121
|
function jsonRpcResult(id, result) {
|
|
@@ -209,6 +235,44 @@ async function callTicketAssign(args, { configDir, runTicketAssignFn }) {
|
|
|
209
235
|
return ok ? { content } : { isError: true, content };
|
|
210
236
|
}
|
|
211
237
|
|
|
238
|
+
async function callTicketDuplicates(args, { configDir, runTicketDuplicatesFn }) {
|
|
239
|
+
if (!args.ticket) {
|
|
240
|
+
return { isError: true, content: [{ type: 'text', text: 'Missing required argument: ticket' }] };
|
|
241
|
+
}
|
|
242
|
+
const cmdArgs = [args.ticket];
|
|
243
|
+
if (args.threshold !== undefined) cmdArgs.push(`--threshold=${args.threshold}`);
|
|
244
|
+
const capture = capturingStream();
|
|
245
|
+
const { ok } = await runTicketDuplicatesFn(cmdArgs, { configDir, stream: capture });
|
|
246
|
+
const content = [{ type: 'text', text: capture.text }];
|
|
247
|
+
return ok ? { content } : { isError: true, content };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* No `type` → discovery only, dispatched to the read-only list function —
|
|
252
|
+
* never touches the mutating path. `type` present → dispatched to the
|
|
253
|
+
* executing function, which itself still refuses without `confirm: true`
|
|
254
|
+
* (the MCP layer doesn't pre-empt that check, same as ticket_transition).
|
|
255
|
+
*/
|
|
256
|
+
async function callTicketLink(args, { configDir, runTicketLinkListFn, runTicketLinkFn }) {
|
|
257
|
+
if (!args.ticket) {
|
|
258
|
+
return { isError: true, content: [{ type: 'text', text: 'Missing required argument: ticket' }] };
|
|
259
|
+
}
|
|
260
|
+
if (!args.target) {
|
|
261
|
+
return { isError: true, content: [{ type: 'text', text: 'Missing required argument: target' }] };
|
|
262
|
+
}
|
|
263
|
+
const capture = capturingStream();
|
|
264
|
+
if (!args.type) {
|
|
265
|
+
const { ok } = await runTicketLinkListFn([args.ticket, args.target], { configDir, stream: capture });
|
|
266
|
+
const content = [{ type: 'text', text: capture.text }];
|
|
267
|
+
return ok ? { content } : { isError: true, content };
|
|
268
|
+
}
|
|
269
|
+
const cmdArgs = [args.ticket, args.target, `--type=${args.type}`];
|
|
270
|
+
if (args.confirm === true) cmdArgs.push('--confirm');
|
|
271
|
+
const { ok } = await runTicketLinkFn(cmdArgs, { configDir, stream: capture });
|
|
272
|
+
const content = [{ type: 'text', text: capture.text }];
|
|
273
|
+
return ok ? { content } : { isError: true, content };
|
|
274
|
+
}
|
|
275
|
+
|
|
212
276
|
async function handleToolsCall(params, deps) {
|
|
213
277
|
const { name, arguments: args = {} } = params ?? {};
|
|
214
278
|
if (name === 'recall_add') return callRecallAdd(args, deps);
|
|
@@ -216,10 +280,12 @@ async function handleToolsCall(params, deps) {
|
|
|
216
280
|
if (name === 'ticket_comment') return callTicketComment(args, deps);
|
|
217
281
|
if (name === 'ticket_transition') return callTicketTransition(args, deps);
|
|
218
282
|
if (name === 'ticket_assign') return callTicketAssign(args, deps);
|
|
283
|
+
if (name === 'ticket_duplicates') return callTicketDuplicates(args, deps);
|
|
284
|
+
if (name === 'ticket_link') return callTicketLink(args, deps);
|
|
219
285
|
return { isError: true, content: [{ type: 'text', text: `Unknown tool: ${name}` }] };
|
|
220
286
|
}
|
|
221
287
|
|
|
222
|
-
async function handleMessage(raw, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn }) {
|
|
288
|
+
async function handleMessage(raw, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn }) {
|
|
223
289
|
let msg;
|
|
224
290
|
try {
|
|
225
291
|
msg = JSON.parse(raw);
|
|
@@ -249,7 +315,7 @@ async function handleMessage(raw, { configDir, runNoteAddFn, runRecallFn, runTic
|
|
|
249
315
|
|
|
250
316
|
if (method === 'tools/call') {
|
|
251
317
|
try {
|
|
252
|
-
const result = await handleToolsCall(params, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn });
|
|
318
|
+
const result = await handleToolsCall(params, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn });
|
|
253
319
|
return jsonRpcResult(id, result);
|
|
254
320
|
} catch (err) {
|
|
255
321
|
return jsonRpcError(id ?? null, -32603, `Internal error: ${err.message}`);
|
|
@@ -276,6 +342,9 @@ export function runMcpServer({
|
|
|
276
342
|
runTicketTransitionListFn = runTicketTransitionList,
|
|
277
343
|
runTicketTransitionFn = runTicketTransition,
|
|
278
344
|
runTicketAssignFn = runTicketAssign,
|
|
345
|
+
runTicketDuplicatesFn = runTicketDuplicates,
|
|
346
|
+
runTicketLinkListFn = runTicketLinkList,
|
|
347
|
+
runTicketLinkFn = runTicketLink,
|
|
279
348
|
} = {}) {
|
|
280
349
|
// A client can disconnect mid-write (EPIPE) at any time on a long-lived
|
|
281
350
|
// process — an unhandled 'error' event on either stream would otherwise
|
|
@@ -295,7 +364,7 @@ export function runMcpServer({
|
|
|
295
364
|
// never resolving (a dropped rejection isn't a resolution) — the
|
|
296
365
|
// server would hang on shutdown instead of exiting.
|
|
297
366
|
queue = queue.then(async () => {
|
|
298
|
-
const response = await handleMessage(line, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn });
|
|
367
|
+
const response = await handleMessage(line, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn });
|
|
299
368
|
if (response) stdout.write(response);
|
|
300
369
|
}).catch(() => {});
|
|
301
370
|
});
|
|
@@ -19,7 +19,7 @@ export function detectTrackerType(baseUrl) {
|
|
|
19
19
|
* Instantiates the correct tracker adapter for a resolved connection.
|
|
20
20
|
* @param {{ baseUrl: string, auth?: string, email?: string, apiToken?: string, pat?: string }} conn
|
|
21
21
|
* @param {{ fetcher?: Function }} [opts]
|
|
22
|
-
* @returns {{ type: string, fetchTicket: Function, fetchCurrentUser: Function, searchTickets: Function, fetchStatuses: Function, addComment: Function, getTransitions: Function, transition: Function, assignToSelf: Function }}
|
|
22
|
+
* @returns {{ type: string, fetchTicket: Function, fetchCurrentUser: Function, searchTickets: Function, fetchStatuses: Function, addComment: Function, getTransitions: Function, transition: Function, assignToSelf: Function, findCandidates: Function }}
|
|
23
23
|
*/
|
|
24
24
|
export function resolveAdapter(conn, opts = {}) {
|
|
25
25
|
const type = detectTrackerType(conn?.baseUrl);
|
|
@@ -17,6 +17,7 @@ import { resolveAdapter } from './resolve-adapter.mjs';
|
|
|
17
17
|
import { checkCooldown, recordAction } from './ticket-action-cooldown.mjs';
|
|
18
18
|
import { logAction } from './ticket-action-log.mjs';
|
|
19
19
|
import { TICKET_KEY_PATTERN } from './cli.mjs';
|
|
20
|
+
import { scoreCandidates } from './duplicate-scorer.mjs';
|
|
20
21
|
|
|
21
22
|
function parseFlag(cmdArgs, name) {
|
|
22
23
|
return cmdArgs.find(a => a.startsWith(`--${name}=`))?.slice(name.length + 3);
|
|
@@ -63,6 +64,39 @@ function formatWriteFailure(ticketKey, err) {
|
|
|
63
64
|
}
|
|
64
65
|
}
|
|
65
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Read-path counterpart to formatWriteFailure — reuses the same
|
|
69
|
+
* classification (rate-limit/timeout/server-error metadata is real and
|
|
70
|
+
* worth keeping, not specific to writes) but with read-appropriate wording,
|
|
71
|
+
* parameterized by what's being checked (e.g. "for duplicates", "for link
|
|
72
|
+
* options") since neither duplicates nor link-list ever writes anything.
|
|
73
|
+
*/
|
|
74
|
+
function formatReadFailure(ticketKey, err, actionPhrase) {
|
|
75
|
+
const classification = classifyWriteFailure(err);
|
|
76
|
+
switch (classification.kind) {
|
|
77
|
+
case 'rate-limited': {
|
|
78
|
+
const wait = classification.detail.retryAfterSeconds ?? null;
|
|
79
|
+
return wait
|
|
80
|
+
? ` Rate limited by the tracker — retry checking ${ticketKey} ${actionPhrase} after ~${wait}s.\n`
|
|
81
|
+
: ` Rate limited by the tracker — try checking ${ticketKey} ${actionPhrase} again later.\n`;
|
|
82
|
+
}
|
|
83
|
+
case 'network-or-timeout':
|
|
84
|
+
return ` Network error or timeout checking ${ticketKey} ${actionPhrase}. Try again.\n`;
|
|
85
|
+
case 'server-error':
|
|
86
|
+
return ` Tracker returned a server error (${classification.status}) checking ${ticketKey} ${actionPhrase}. Try again later.\n`;
|
|
87
|
+
default:
|
|
88
|
+
return ` Error checking ${ticketKey} ${actionPhrase}: ${err.message}\n`;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function formatDuplicatesFailure(ticketKey, err) {
|
|
93
|
+
return formatReadFailure(ticketKey, err, 'for duplicates');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function formatLinkListFailure(ticketKey, err) {
|
|
97
|
+
return formatReadFailure(ticketKey, err, 'for link options');
|
|
98
|
+
}
|
|
99
|
+
|
|
66
100
|
function requireLicense(isLicensedFn, configDir, commandName, stream) {
|
|
67
101
|
if (isLicensedFn('pro', configDir)) return true;
|
|
68
102
|
showUpgradePrompt('pro', commandName, { stream });
|
|
@@ -288,3 +322,186 @@ export async function runTicketAssign(cmdArgs, {
|
|
|
288
322
|
return { ok: false };
|
|
289
323
|
}
|
|
290
324
|
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Read-only — no cooldown, no action-log entry. Nothing is mutated, so
|
|
328
|
+
* there's nothing to debounce or audit, unlike comment/transition/assign.
|
|
329
|
+
*
|
|
330
|
+
* @param {string[]} cmdArgs - [ticketKey, ...flags], e.g. ["PROJ-1", '--threshold=0.4']
|
|
331
|
+
* @returns {Promise<{ ok: boolean, results?: Array<{key: string, summary: string, score: number}> }>}
|
|
332
|
+
*/
|
|
333
|
+
export async function runTicketDuplicates(cmdArgs, {
|
|
334
|
+
configDir = DEFAULT_CONFIG_DIR,
|
|
335
|
+
stream = process.stderr,
|
|
336
|
+
isLicensedFn = isLicensed,
|
|
337
|
+
resolveConnectionFn = resolveConnection,
|
|
338
|
+
resolveAdapterFn = resolveAdapter,
|
|
339
|
+
} = {}) {
|
|
340
|
+
const usage = 'Usage: ticketlens duplicates TICKET-KEY [--threshold=0.35]\n';
|
|
341
|
+
if (!requireLicense(isLicensedFn, configDir, 'ticketlens duplicates', stream)) return { ok: false };
|
|
342
|
+
|
|
343
|
+
const ticketKey = requireTicketKey(cmdArgs, usage, stream);
|
|
344
|
+
if (!ticketKey) return { ok: false };
|
|
345
|
+
|
|
346
|
+
const thresholdArg = parseFlag(cmdArgs, 'threshold');
|
|
347
|
+
let threshold;
|
|
348
|
+
if (thresholdArg !== undefined) {
|
|
349
|
+
threshold = Number(thresholdArg);
|
|
350
|
+
if (Number.isNaN(threshold) || threshold < 0 || threshold > 1) {
|
|
351
|
+
stream.write(` --threshold must be a number between 0 and 1 (got "${thresholdArg}").\n`);
|
|
352
|
+
return { ok: false };
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const adapter = resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
|
|
357
|
+
if (!adapter) return { ok: false };
|
|
358
|
+
|
|
359
|
+
try {
|
|
360
|
+
const source = await adapter.fetchTicket(ticketKey);
|
|
361
|
+
const searchText = [source.summary, source.description].filter(Boolean).join(' ');
|
|
362
|
+
const candidates = await adapter.findCandidates(searchText, ticketKey);
|
|
363
|
+
const scoreOpts = threshold !== undefined ? { threshold } : {};
|
|
364
|
+
const results = scoreCandidates({ key: ticketKey, summary: source.summary, description: source.description }, candidates, scoreOpts);
|
|
365
|
+
|
|
366
|
+
if (results.length === 0) {
|
|
367
|
+
stream.write(` No likely duplicates found for ${ticketKey}.\n`);
|
|
368
|
+
return { ok: true, results: [] };
|
|
369
|
+
}
|
|
370
|
+
stream.write(` Possible duplicates of ${ticketKey}:\n`);
|
|
371
|
+
for (const r of results) {
|
|
372
|
+
stream.write(` ${r.key} (${Math.round(r.score * 100)}% match) — ${r.summary}\n`);
|
|
373
|
+
}
|
|
374
|
+
return { ok: true, results };
|
|
375
|
+
} catch (err) {
|
|
376
|
+
stream.write(formatDuplicatesFailure(ticketKey, err));
|
|
377
|
+
return { ok: false };
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Discovery only — never mutates. Lists the tracker's current available
|
|
383
|
+
* link types for sourceKey→targetKey. Jira's list is always fetched live
|
|
384
|
+
* (per-instance customizable — never cached, same principle as
|
|
385
|
+
* getTransitions). GitHub's "list" is really a single-item warning: its
|
|
386
|
+
* only link action closes sourceKey as a duplicate of targetKey, a
|
|
387
|
+
* materially louder operation than Jira/Linear's pure relationship-add,
|
|
388
|
+
* so that asymmetry is surfaced here before a caller ever reaches --confirm.
|
|
389
|
+
*
|
|
390
|
+
* @param {string[]} cmdArgs - [sourceKey, targetKey]
|
|
391
|
+
* @returns {Promise<{ ok: boolean, types?: string[] }>}
|
|
392
|
+
*/
|
|
393
|
+
export async function runTicketLinkList(cmdArgs, {
|
|
394
|
+
configDir = DEFAULT_CONFIG_DIR,
|
|
395
|
+
stream = process.stderr,
|
|
396
|
+
isLicensedFn = isLicensed,
|
|
397
|
+
resolveConnectionFn = resolveConnection,
|
|
398
|
+
resolveAdapterFn = resolveAdapter,
|
|
399
|
+
} = {}) {
|
|
400
|
+
const usage = 'Usage: ticketlens link SOURCE-KEY TARGET-KEY [--type="..." --confirm]\n';
|
|
401
|
+
if (!requireLicense(isLicensedFn, configDir, 'ticketlens link', stream)) return { ok: false };
|
|
402
|
+
|
|
403
|
+
const sourceKey = requireTicketKey(cmdArgs, usage, stream);
|
|
404
|
+
if (!sourceKey) return { ok: false };
|
|
405
|
+
const targetKey = requireTicketKey(cmdArgs.slice(1), usage, stream);
|
|
406
|
+
if (!targetKey) return { ok: false };
|
|
407
|
+
|
|
408
|
+
const adapter = resolveTicketAdapter(sourceKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
|
|
409
|
+
if (!adapter) return { ok: false };
|
|
410
|
+
|
|
411
|
+
try {
|
|
412
|
+
const types = await adapter.getLinkTypes();
|
|
413
|
+
if (types.length === 0) {
|
|
414
|
+
stream.write(` No link types available for ${sourceKey} → ${targetKey} on ${adapter.type}.\n`);
|
|
415
|
+
return { ok: true, types: [] };
|
|
416
|
+
}
|
|
417
|
+
stream.write(` Available link types for ${sourceKey} → ${targetKey} (${adapter.type}):\n`);
|
|
418
|
+
for (const t of types) stream.write(` - ${t}\n`);
|
|
419
|
+
if (adapter.type === 'github') {
|
|
420
|
+
stream.write(` Note: GitHub has no generic link relationship — linking will CLOSE ${sourceKey} as a duplicate of ${targetKey}.\n`);
|
|
421
|
+
}
|
|
422
|
+
stream.write(` Run again with --type="<name>" --confirm to execute — ${sourceKey} will be recorded as the one that "types" ${targetKey}.\n`);
|
|
423
|
+
return { ok: true, types };
|
|
424
|
+
} catch (err) {
|
|
425
|
+
stream.write(formatLinkListFailure(sourceKey, err));
|
|
426
|
+
return { ok: false };
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Executes a link. Requires both --type and --confirm — a type without
|
|
432
|
+
* confirm is incomplete input, never silently executed. Cooldown is keyed
|
|
433
|
+
* on the source:target pair (not sourceKey alone) so a second link to a
|
|
434
|
+
* different target isn't blocked by the debounce window; the audit log
|
|
435
|
+
* keeps ticketKey as the single valid sourceKey (logAction throws on
|
|
436
|
+
* anything else) with targetKey/type carried in detail instead.
|
|
437
|
+
*
|
|
438
|
+
* @param {string[]} cmdArgs - [sourceKey, targetKey, '--type=...', '--confirm']
|
|
439
|
+
* @returns {Promise<{ ok: boolean, reason?: string }>}
|
|
440
|
+
*/
|
|
441
|
+
export async function runTicketLink(cmdArgs, {
|
|
442
|
+
configDir = DEFAULT_CONFIG_DIR,
|
|
443
|
+
stream = process.stderr,
|
|
444
|
+
isLicensedFn = isLicensed,
|
|
445
|
+
resolveConnectionFn = resolveConnection,
|
|
446
|
+
resolveAdapterFn = resolveAdapter,
|
|
447
|
+
checkCooldownFn = checkCooldown,
|
|
448
|
+
recordActionFn = recordAction,
|
|
449
|
+
logActionFn = logAction,
|
|
450
|
+
actor = os.userInfo().username,
|
|
451
|
+
} = {}) {
|
|
452
|
+
const usage = 'Usage: ticketlens link SOURCE-KEY TARGET-KEY --type="..." --confirm\n';
|
|
453
|
+
if (!requireLicense(isLicensedFn, configDir, 'ticketlens link', stream)) return { ok: false };
|
|
454
|
+
|
|
455
|
+
const sourceKey = requireTicketKey(cmdArgs, usage, stream);
|
|
456
|
+
if (!sourceKey) return { ok: false };
|
|
457
|
+
const targetKey = requireTicketKey(cmdArgs.slice(1), usage, stream);
|
|
458
|
+
if (!targetKey) return { ok: false };
|
|
459
|
+
|
|
460
|
+
const type = parseFlag(cmdArgs, 'type');
|
|
461
|
+
if (!type) {
|
|
462
|
+
stream.write(usage);
|
|
463
|
+
return { ok: false };
|
|
464
|
+
}
|
|
465
|
+
if (!cmdArgs.includes('--confirm')) {
|
|
466
|
+
stream.write(` Refusing to link ${sourceKey} to ${targetKey} as "${type}" without --confirm. Re-run with --confirm once you've reviewed the target.\n`);
|
|
467
|
+
return { ok: false };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const cooldownKey = `${sourceKey}:${targetKey}`;
|
|
471
|
+
const cooldown = checkCooldownFn(cooldownKey, 'link', { configDir });
|
|
472
|
+
if (cooldown.active) {
|
|
473
|
+
stream.write(` Skipped — ${sourceKey} was already linked to ${targetKey} ${Math.ceil(cooldown.remainingMs / 1000)}s ago. Wait a moment before retrying.\n`);
|
|
474
|
+
return { ok: false };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const adapter = resolveTicketAdapter(sourceKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
|
|
478
|
+
if (!adapter) return { ok: false };
|
|
479
|
+
|
|
480
|
+
if (adapter.type === 'github' && type.toLowerCase() !== 'duplicate') {
|
|
481
|
+
stream.write(` GitHub only supports linking as a duplicate — no generic link types. Got type "${type}".\n`);
|
|
482
|
+
return { ok: false };
|
|
483
|
+
}
|
|
484
|
+
if (adapter.type === 'github') {
|
|
485
|
+
stream.write(` Note: this will CLOSE ${sourceKey} as a duplicate of ${targetKey} on GitHub.\n`);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
try {
|
|
489
|
+
const result = await adapter.linkTo(sourceKey, targetKey, type);
|
|
490
|
+
if (!result.executed) {
|
|
491
|
+
const optionsHint = result.options?.length ? ` Valid options: ${result.options.join(', ')}.` : '';
|
|
492
|
+
stream.write(` Not linked — ${result.reason}.${optionsHint}\n`);
|
|
493
|
+
return { ok: false, reason: result.reason };
|
|
494
|
+
}
|
|
495
|
+
recordActionFn(cooldownKey, 'link', { configDir });
|
|
496
|
+
logActionFn({ ticketKey: sourceKey, action: 'link', actor, tracker: adapter.type, detail: { targetKey, type } }, { configDir });
|
|
497
|
+
stream.write(
|
|
498
|
+
adapter.type === 'github'
|
|
499
|
+
? ` ${sourceKey} closed as a duplicate of ${targetKey}.\n`
|
|
500
|
+
: ` ${sourceKey} linked to ${targetKey} as "${type}".\n`,
|
|
501
|
+
);
|
|
502
|
+
return { ok: true };
|
|
503
|
+
} catch (err) {
|
|
504
|
+
stream.write(formatWriteFailure(sourceKey, err));
|
|
505
|
+
return { ok: false };
|
|
506
|
+
}
|
|
507
|
+
}
|