ticketlens 0.26.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 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`, `ticket_assign`, and `ticket_duplicates` 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).
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,7 +419,7 @@ Every note is scanned before saving — anything shaped like a real secret (API
419
419
 
420
420
  ---
421
421
 
422
- ### Comment, Transition, Assign & Duplicates
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
@@ -427,9 +427,11 @@ ticketlens transition PROJ-123 # List valid transit
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
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
430
432
  ```
431
433
 
432
- 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` 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.
433
435
 
434
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.
435
437
 
@@ -437,7 +439,9 @@ Write directly to the ticket in its real tracker — Jira, GitHub, or Linear —
437
439
 
438
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.
439
441
 
440
- All three write actions (comment/transition/assign) 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.
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.
441
445
 
442
446
  ---
443
447
 
@@ -718,13 +722,15 @@ ticketlens mcp # Start the MCP stdio server (reca
718
722
  ticketlens mcp install # Register it into the current project's .mcp.json
719
723
  ticketlens mcp install --dry-run # Preview the registration without writing
720
724
 
721
- # ── Comment, Transition, Assign & Duplicates ────────────────────────────────────
725
+ # ── Comment, Transition, Assign, Duplicates & Link ──────────────────────────────
722
726
  ticketlens comment CNV1-2 --body="Looks good, merging." # Post a comment to the tracker [Pro]
723
727
  ticketlens transition CNV1-2 # List valid transitions (read-only) [Pro]
724
728
  ticketlens transition CNV1-2 --target="Done" --confirm # Execute the transition [Pro]
725
729
  ticketlens assign CNV1-2 --to=me # Assign the ticket to yourself [Pro]
726
730
  ticketlens duplicates CNV1-2 # Find likely duplicates (read-only) [Pro]
727
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]
728
734
 
729
735
  # ── Stats ──────────────────────────────────────────────────────────────────────
730
736
  ticketlens stats # Response-time metrics from local history
@@ -811,6 +817,7 @@ ticketlens comment CNV1-2 --body="..." # Post a comment to the tracker
811
817
  ticketlens transition CNV1-2 --target="Done" --confirm # Transition ticket status
812
818
  ticketlens assign CNV1-2 --to=me # Assign the ticket to yourself
813
819
  ticketlens duplicates CNV1-2 # Find likely duplicates (read-only)
820
+ ticketlens link CNV1-2 CNV1-3 --type="Duplicate" --confirm # Link two tickets
814
821
  ticketlens activate YOUR-LICENSE-KEY # Activate Pro license
815
822
  ```
816
823
 
@@ -28,7 +28,7 @@ import {
28
28
  printCollisionsHelp, printStatsHelp,
29
29
  printCloudKeysHelp,
30
30
  printNoteHelp, printRecallHelp, printMcpHelp,
31
- printCommentHelp, printTransitionHelp, printAssignHelp, printDuplicatesHelp,
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';
@@ -790,6 +790,22 @@ switch (command) {
790
790
  break;
791
791
  }
792
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
+
793
809
  case 'help':
794
810
  default: {
795
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.26.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": {
@@ -29,6 +29,7 @@ export function parseGitHubRepo(baseUrl) {
29
29
  export function normalizeGitHubIssue(raw, comments = [], keyPrefix = 'GH') {
30
30
  return {
31
31
  key: `${keyPrefix}-${raw.number}`,
32
+ id: raw.id,
32
33
  summary: raw.title,
33
34
  type: 'Issue',
34
35
  status: raw.state,
@@ -240,5 +241,36 @@ export function createGitHubAdapter(conn, { fetcher = globalThis.fetch } = {}) {
240
241
  .filter(item => item.number !== sourceNumber)
241
242
  .map(item => normalizeGitHubIssue(item, [], keyPrefix));
242
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
+ },
243
275
  };
244
276
  }
@@ -1,4 +1,4 @@
1
- import { fetchTicket, fetchCurrentUser, searchTickets, fetchStatuses, postComment, getTransitions, postTransition, assignIssue, escapeJql } 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
  /**
@@ -83,5 +83,32 @@ export function createJiraAdapter(conn, { fetcher = globalThis.fetch } = {}) {
83
83
  const jql = `project = "${escapeJql(project)}" AND key != "${escapeJql(sourceKey)}" AND text ~ "${escapeJql(searchText)}" ORDER BY updated DESC`;
84
84
  return searchTickets(jql, { ...base, ...opts });
85
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
+ },
86
113
  };
87
114
  }
@@ -4,6 +4,9 @@ const LINEAR_API = 'https://api.linear.app/graphql';
4
4
 
5
5
  const PRIORITY_LABELS = { 1: 'Urgent', 2: 'High', 3: 'Medium', 4: 'Low' };
6
6
 
7
+ /** Linear's IssueRelationType enum — fixed schema-level values, confirmed via GraphQL introspection. */
8
+ const LINK_TYPES = ['blocks', 'duplicate', 'related'];
9
+
7
10
  const ISSUE_FIELDS = `
8
11
  identifier
9
12
  title
@@ -295,5 +298,43 @@ export function createLinearAdapter(conn, { fetcher = globalThis.fetch } = {}) {
295
298
  .filter(node => node.identifier !== sourceKey)
296
299
  .map(normalizeLinearIssue);
297
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
+ },
298
339
  };
299
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 = 'https://api.ticketlens.app';
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,
@@ -146,6 +146,10 @@ export function parseCommand(args) {
146
146
  return { command: 'duplicates', args: args.slice(1) };
147
147
  }
148
148
 
149
+ if (first === 'link') {
150
+ return { command: 'link', args: args.slice(1) };
151
+ }
152
+
149
153
  // Anything that looks like a ticket key or any non-flag arg → fetch
150
154
  return { command: 'fetch', args };
151
155
  }
@@ -58,6 +58,7 @@ export function printHelp({ stream = process.stdout } = {}) {
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
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]')}`,
61
62
  '',
62
63
  ` ${s.brand('ticketlens')} delete ${s.dim('<PROFILE-NAME>')} Remove a profile`,
63
64
  ` ${s.brand('ticketlens')} activate ${s.dim('<KEY>')} Activate a license key`,
@@ -598,12 +599,13 @@ export function printMcpHelp({ stream = process.stdout } = {}) {
598
599
  '',
599
600
  ` Start an MCP (Model Context Protocol) stdio server exposing Recall and`,
600
601
  ` ticket writes as native tools — ${s.cyan('recall_add')}, ${s.cyan('recall_search')}, ${s.cyan('ticket_comment')},`,
601
- ` ${s.cyan('ticket_transition')}, ${s.cyan('ticket_assign')}, ${s.cyan('ticket_duplicates')} — for any MCP-compatible AI`,
602
- ` harness, not just Claude Code. Thin adapter over the same code as`,
603
- ` ${s.cyan('note add')}/${s.cyan('recall')}/${s.cyan('comment')}/${s.cyan('transition')}/${s.cyan('assign')}/${s.cyan('duplicates')} above: same Pro gate, same`,
604
- ` local vault/tracker writes, same team sync. ${s.cyan('ticket_transition')} is destructive when`,
605
- ` called with \`target\`+\`confirm: true\`; ${s.cyan('ticket_assign')} is currently self-assign only;`,
606
- ` ${s.cyan('ticket_duplicates')} is read-only.`,
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.`,
607
609
  ` Long-running — exits when the client closes stdin.`,
608
610
  '',
609
611
  ` ${s.bold('OPTIONS')}`,
@@ -730,6 +732,39 @@ export function printDuplicatesHelp({ stream = process.stdout } = {}) {
730
732
  stream.write(lines.join('\n') + '\n');
731
733
  }
732
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
+
733
768
  export function printSwitchHelp({ stream = process.stdout } = {}) {
734
769
  const s = createStyler({ isTTY: stream.isTTY });
735
770
  const lines = [
@@ -502,6 +502,61 @@ export async function postTransition(ticketKey, transitionId, opts = {}) {
502
502
  }
503
503
  }
504
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
+
505
560
  /**
506
561
  * Sets the issue's assignee. `assignee` is sent verbatim — the caller
507
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, runTicketDuplicates } 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
 
@@ -102,6 +102,20 @@ const TOOLS = [
102
102
  required: ['ticket'],
103
103
  },
104
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
+ },
105
119
  ];
106
120
 
107
121
  function jsonRpcResult(id, result) {
@@ -233,6 +247,32 @@ async function callTicketDuplicates(args, { configDir, runTicketDuplicatesFn })
233
247
  return ok ? { content } : { isError: true, content };
234
248
  }
235
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
+
236
276
  async function handleToolsCall(params, deps) {
237
277
  const { name, arguments: args = {} } = params ?? {};
238
278
  if (name === 'recall_add') return callRecallAdd(args, deps);
@@ -241,10 +281,11 @@ async function handleToolsCall(params, deps) {
241
281
  if (name === 'ticket_transition') return callTicketTransition(args, deps);
242
282
  if (name === 'ticket_assign') return callTicketAssign(args, deps);
243
283
  if (name === 'ticket_duplicates') return callTicketDuplicates(args, deps);
284
+ if (name === 'ticket_link') return callTicketLink(args, deps);
244
285
  return { isError: true, content: [{ type: 'text', text: `Unknown tool: ${name}` }] };
245
286
  }
246
287
 
247
- async function handleMessage(raw, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn }) {
288
+ async function handleMessage(raw, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn }) {
248
289
  let msg;
249
290
  try {
250
291
  msg = JSON.parse(raw);
@@ -274,7 +315,7 @@ async function handleMessage(raw, { configDir, runNoteAddFn, runRecallFn, runTic
274
315
 
275
316
  if (method === 'tools/call') {
276
317
  try {
277
- const result = await handleToolsCall(params, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn });
318
+ const result = await handleToolsCall(params, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn });
278
319
  return jsonRpcResult(id, result);
279
320
  } catch (err) {
280
321
  return jsonRpcError(id ?? null, -32603, `Internal error: ${err.message}`);
@@ -302,6 +343,8 @@ export function runMcpServer({
302
343
  runTicketTransitionFn = runTicketTransition,
303
344
  runTicketAssignFn = runTicketAssign,
304
345
  runTicketDuplicatesFn = runTicketDuplicates,
346
+ runTicketLinkListFn = runTicketLinkList,
347
+ runTicketLinkFn = runTicketLink,
305
348
  } = {}) {
306
349
  // A client can disconnect mid-write (EPIPE) at any time on a long-lived
307
350
  // process — an unhandled 'error' event on either stream would otherwise
@@ -321,7 +364,7 @@ export function runMcpServer({
321
364
  // never resolving (a dropped rejection isn't a resolution) — the
322
365
  // server would hang on shutdown instead of exiting.
323
366
  queue = queue.then(async () => {
324
- const response = await handleMessage(line, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn });
367
+ const response = await handleMessage(line, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn });
325
368
  if (response) stdout.write(response);
326
369
  }).catch(() => {});
327
370
  });
@@ -68,26 +68,35 @@ function formatWriteFailure(ticketKey, err) {
68
68
  * Read-path counterpart to formatWriteFailure — reuses the same
69
69
  * classification (rate-limit/timeout/server-error metadata is real and
70
70
  * worth keeping, not specific to writes) but with read-appropriate wording,
71
- * since "duplicates" never writes anything.
71
+ * parameterized by what's being checked (e.g. "for duplicates", "for link
72
+ * options") since neither duplicates nor link-list ever writes anything.
72
73
  */
73
- function formatDuplicatesFailure(ticketKey, err) {
74
+ function formatReadFailure(ticketKey, err, actionPhrase) {
74
75
  const classification = classifyWriteFailure(err);
75
76
  switch (classification.kind) {
76
77
  case 'rate-limited': {
77
78
  const wait = classification.detail.retryAfterSeconds ?? null;
78
79
  return wait
79
- ? ` Rate limited by the tracker — retry checking ${ticketKey} after ~${wait}s.\n`
80
- : ` Rate limited by the tracker — try checking ${ticketKey} again later.\n`;
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`;
81
82
  }
82
83
  case 'network-or-timeout':
83
- return ` Network error or timeout checking ${ticketKey} for duplicates. Try again.\n`;
84
+ return ` Network error or timeout checking ${ticketKey} ${actionPhrase}. Try again.\n`;
84
85
  case 'server-error':
85
- return ` Tracker returned a server error (${classification.status}) checking ${ticketKey} for duplicates. Try again later.\n`;
86
+ return ` Tracker returned a server error (${classification.status}) checking ${ticketKey} ${actionPhrase}. Try again later.\n`;
86
87
  default:
87
- return ` Error checking ${ticketKey} for duplicates: ${err.message}\n`;
88
+ return ` Error checking ${ticketKey} ${actionPhrase}: ${err.message}\n`;
88
89
  }
89
90
  }
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
+
91
100
  function requireLicense(isLicensedFn, configDir, commandName, stream) {
92
101
  if (isLicensedFn('pro', configDir)) return true;
93
102
  showUpgradePrompt('pro', commandName, { stream });
@@ -368,3 +377,131 @@ export async function runTicketDuplicates(cmdArgs, {
368
377
  return { ok: false };
369
378
  }
370
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
+ }