ticketlens 0.26.0 → 0.30.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`, `ticket_link`, `ticket_update`, and `ticket_create` 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, Update & Create
423
423
 
424
424
  ```bash
425
425
  ticketlens comment PROJ-123 --body="Looks good, merging." # Post a comment to the tracker
@@ -427,9 +427,15 @@ 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
432
+ ticketlens update PROJ-123 --title="Fix login on mobile" # Update title/description/labels/priority
433
+ ticketlens update PROJ-123 --add-labels=urgent,backend --remove-labels=stale
434
+ ticketlens create --project=PROJ --type="Task" --summary="Fix login on mobile" # Create a new ticket
435
+ ticketlens create --project=ENG --summary="New Linear issue" --profile=linear-team
430
436
  ```
431
437
 
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.
438
+ 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`/`ticket_update`/`ticket_create` MCP tools. Requires a Pro license.
433
439
 
434
440
  `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
441
 
@@ -437,7 +443,15 @@ Write directly to the ticket in its real tracker — Jira, GitHub, or Linear —
437
443
 
438
444
  `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
445
 
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.
446
+ `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.
447
+
448
+ `ticketlens update TICKET-KEY` updates a narrow, named field set — title, description, labels, priority. At least one field is required. Labels are always add/remove (`--add-labels=a,b` / `--remove-labels=c`), never a wholesale replace — an unnamed existing label is left alone, never silently dropped. No `--confirm` needed: unlike transition/link, update has no discovery step and only makes reversible metadata edits, the same risk tier as `assign`. GitHub has no priority field on issues, so `--priority` against a GitHub-tracked ticket is refused up front. Each tracker's label mechanics genuinely differ — Jira and Linear apply everything in one atomic call; GitHub's title/description and each label operation are independent, so a call can partially succeed (e.g. the title updates but one label name doesn't resolve) — the result always reports exactly which fields landed.
449
+
450
+ `ticketlens create` creates a new ticket with a fixed minimal field set — no arbitrary custom fields. Unlike every other write command, there's no existing ticket to target, so `--profile` (or your default profile) picks the tracker instead of a ticket key. `--project` is the Jira project key or Linear team key — required for both, ignored on GitHub since its target repo is already fixed by the profile. `--type` is Jira's issue type (e.g. `"Task"`, `"Bug"`) — required for Jira, ignored elsewhere. No `--confirm` gate, same risk tier as `update`/`assign` — but this is the highest-blast-radius command in the whole family: a bad `--project`/`--type` fabricates a real, hard-to-walk-back item in a live tracker, so an invalid value surfaces the tracker's own error rather than a silent guess.
451
+
452
+ **A bad `--project`/`--type` gets a better error, automatically.** If create fails because the project or issue type doesn't exist, TicketLens fetches your tracker's real, current project list (and, for Jira, the real issue types for that project) and shows them alongside the failure — e.g. `Known creatable projects: CNV1, ECNT.` — rather than a bare tracker error. This is reactive only: it never runs on a successful create, never auto-retries the write, and is cached locally per profile for 24h so a burst of failed attempts doesn't re-fetch every time.
453
+
454
+ All six write actions (comment/transition/assign/link/update/create) 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
455
 
442
456
  ---
443
457
 
@@ -718,13 +732,19 @@ ticketlens mcp # Start the MCP stdio server (reca
718
732
  ticketlens mcp install # Register it into the current project's .mcp.json
719
733
  ticketlens mcp install --dry-run # Preview the registration without writing
720
734
 
721
- # ── Comment, Transition, Assign & Duplicates ────────────────────────────────────
735
+ # ── Comment, Transition, Assign, Duplicates, Link, Update & Create ──────────────
722
736
  ticketlens comment CNV1-2 --body="Looks good, merging." # Post a comment to the tracker [Pro]
723
737
  ticketlens transition CNV1-2 # List valid transitions (read-only) [Pro]
724
738
  ticketlens transition CNV1-2 --target="Done" --confirm # Execute the transition [Pro]
725
739
  ticketlens assign CNV1-2 --to=me # Assign the ticket to yourself [Pro]
726
740
  ticketlens duplicates CNV1-2 # Find likely duplicates (read-only) [Pro]
727
741
  ticketlens duplicates CNV1-2 --threshold=0.5 # Tighten the match threshold [Pro]
742
+ ticketlens link CNV1-2 CNV1-3 # List valid link types (read-only) [Pro]
743
+ ticketlens link CNV1-2 CNV1-3 --type="Duplicate" --confirm # Execute the link [Pro]
744
+ ticketlens update CNV1-2 --title="New title" # Update title/description/labels/priority [Pro]
745
+ ticketlens update CNV1-2 --add-labels=urgent --remove-labels=stale # Add/remove labels [Pro]
746
+ ticketlens create --project=CNV1 --type="Task" --summary="New ticket" # Create a new ticket [Pro]
747
+ ticketlens create --project=ENG --summary="New issue" --profile=linear-team # Create on a different profile [Pro]
728
748
 
729
749
  # ── Stats ──────────────────────────────────────────────────────────────────────
730
750
  ticketlens stats # Response-time metrics from local history
@@ -811,6 +831,9 @@ ticketlens comment CNV1-2 --body="..." # Post a comment to the tracker
811
831
  ticketlens transition CNV1-2 --target="Done" --confirm # Transition ticket status
812
832
  ticketlens assign CNV1-2 --to=me # Assign the ticket to yourself
813
833
  ticketlens duplicates CNV1-2 # Find likely duplicates (read-only)
834
+ ticketlens link CNV1-2 CNV1-3 --type="Duplicate" --confirm # Link two tickets
835
+ ticketlens update CNV1-2 --title="..." # Update title/description/labels/priority
836
+ ticketlens create --project=CNV1 --type="Task" --summary="..." # Create a new ticket
814
837
  ticketlens activate YOUR-LICENSE-KEY # Activate Pro license
815
838
  ```
816
839
 
@@ -18,7 +18,7 @@ import { activateLicense, checkLicense, revalidateIfStale, isLicensed, showUpgra
18
18
  import { deleteProfile, loadProfiles, saveCredentialKey } from '../skills/jtb/scripts/lib/profile-resolver.mjs';
19
19
  import { run as runCache } from '../skills/jtb/scripts/lib/cache-manager.mjs';
20
20
  import {
21
- printHelp, printProfiles,
21
+ printHelp, printProfiles, printHistoryHelp,
22
22
  printLoginHelp, printLogoutHelp, printSyncHelp,
23
23
  printActivateHelp, printLicenseHelp, printDeleteHelp,
24
24
  printProfilesHelp, printScheduleHelp,
@@ -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, printUpdateHelp, printCreateHelp,
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';
@@ -134,6 +134,7 @@ switch (command) {
134
134
  }
135
135
 
136
136
  case 'history': {
137
+ if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printHistoryHelp(); break; }
137
138
  if (!isLicensed('pro')) { showUpgradePrompt('pro', 'ticketlens history'); break; }
138
139
  const ticketKey = cmdArgs[0];
139
140
  if (!ticketKey || ticketKey.startsWith('-')) {
@@ -790,6 +791,46 @@ switch (command) {
790
791
  break;
791
792
  }
792
793
 
794
+ case 'link': {
795
+ if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printLinkHelp(); break; }
796
+ const { runTicketLinkList, runTicketLink } = await import('../skills/jtb/scripts/lib/ticket-command.mjs');
797
+ // No --type → discovery only, never mutates. --type present → execute
798
+ // (runTicketLink itself still refuses without --confirm).
799
+ const hasType = cmdArgs.some(a => a.startsWith('--type='));
800
+ const runFn = hasType ? runTicketLink : runTicketLinkList;
801
+ runFn(cmdArgs).then(({ ok }) => {
802
+ if (!ok) process.exitCode = 1;
803
+ }).catch(err => {
804
+ process.stderr.write(`Error: ${err.message}\n`);
805
+ process.exitCode = 1;
806
+ });
807
+ break;
808
+ }
809
+
810
+ case 'update': {
811
+ if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printUpdateHelp(); break; }
812
+ const { runTicketUpdate } = await import('../skills/jtb/scripts/lib/ticket-command.mjs');
813
+ runTicketUpdate(cmdArgs).then(({ ok }) => {
814
+ if (!ok) process.exitCode = 1;
815
+ }).catch(err => {
816
+ process.stderr.write(`Error: ${err.message}\n`);
817
+ process.exitCode = 1;
818
+ });
819
+ break;
820
+ }
821
+
822
+ case 'create': {
823
+ if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printCreateHelp(); break; }
824
+ const { runTicketCreate } = await import('../skills/jtb/scripts/lib/ticket-command.mjs');
825
+ runTicketCreate(cmdArgs).then(({ ok }) => {
826
+ if (!ok) process.exitCode = 1;
827
+ }).catch(err => {
828
+ process.stderr.write(`Error: ${err.message}\n`);
829
+ process.exitCode = 1;
830
+ });
831
+ break;
832
+ }
833
+
793
834
  case 'help':
794
835
  default: {
795
836
  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.30.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,134 @@ 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
+ },
275
+
276
+ /**
277
+ * Unlike Jira/Linear's single atomic mutation, GitHub genuinely has no
278
+ * one-call way to do this: title/description share a PATCH, but labels
279
+ * use their own dedicated additive (POST) and per-label (DELETE)
280
+ * endpoints — never the full-replace PUT, which would force an unsafe
281
+ * read-then-write race. Each operation is independent and best-effort:
282
+ * one failing must never prevent the others from being attempted, and
283
+ * the caller needs to know exactly which fields actually landed.
284
+ */
285
+ async updateFields(key, { title, description, priority, addLabels, removeLabels } = {}, opts = {}) {
286
+ if (priority !== undefined) {
287
+ throw new Error('GitHub Issues have no native priority field — cannot update priority.');
288
+ }
289
+ const number = parseInt(key.split('-').pop(), 10);
290
+ const signal = () => AbortSignal.timeout(opts.timeoutMs ?? 10_000);
291
+ const applied = {};
292
+ const errors = {};
293
+
294
+ if (title !== undefined || description !== undefined) {
295
+ try {
296
+ const body = {};
297
+ if (title !== undefined) body.title = title;
298
+ if (description !== undefined) body.body = description;
299
+ const res = await fetcher(`${GITHUB_API}/repos/${owner}/${repo}/issues/${number}`, {
300
+ method: 'PATCH',
301
+ headers: { ...headers, 'Content-Type': 'application/json' },
302
+ body: JSON.stringify(body),
303
+ signal: signal(),
304
+ });
305
+ if (!res.ok) await throwGitHubWriteError(res, 'updating', key);
306
+ if (title !== undefined) applied.title = true;
307
+ if (description !== undefined) applied.description = true;
308
+ } catch (err) {
309
+ if (title !== undefined) errors.title = err;
310
+ if (description !== undefined) errors.description = err;
311
+ }
312
+ }
313
+
314
+ if (addLabels?.length) {
315
+ try {
316
+ const res = await fetcher(`${GITHUB_API}/repos/${owner}/${repo}/issues/${number}/labels`, {
317
+ method: 'POST',
318
+ headers: { ...headers, 'Content-Type': 'application/json' },
319
+ body: JSON.stringify({ labels: addLabels }),
320
+ signal: signal(),
321
+ });
322
+ if (!res.ok) await throwGitHubWriteError(res, 'adding labels to', key);
323
+ applied.addLabels = addLabels;
324
+ } catch (err) {
325
+ errors.addLabels = err;
326
+ }
327
+ }
328
+
329
+ if (removeLabels?.length) {
330
+ const removed = [];
331
+ const removeErrors = {};
332
+ for (const label of removeLabels) {
333
+ try {
334
+ const res = await fetcher(`${GITHUB_API}/repos/${owner}/${repo}/issues/${number}/labels/${encodeURIComponent(label)}`, {
335
+ method: 'DELETE',
336
+ headers,
337
+ signal: signal(),
338
+ });
339
+ // A 404 here means the label was already absent — the caller's
340
+ // goal ("this label is gone") is already true, so this is
341
+ // treated as success, not a failure to surface.
342
+ if (!res.ok && res.status !== 404) await throwGitHubWriteError(res, 'removing a label from', key);
343
+ removed.push(label);
344
+ } catch (err) {
345
+ removeErrors[label] = err;
346
+ }
347
+ }
348
+ if (removed.length) applied.removeLabels = removed;
349
+ if (Object.keys(removeErrors).length) errors.removeLabels = removeErrors;
350
+ }
351
+
352
+ return { applied, errors };
353
+ },
354
+
355
+ /**
356
+ * `project`/`type` are ignored — GitHub has no such concepts here: the
357
+ * target repo is fixed by the profile's baseUrl, and issues have no
358
+ * type field in this MVP scope.
359
+ */
360
+ async createTicket({ summary, description } = {}, opts = {}) {
361
+ const body = { title: summary };
362
+ if (description !== undefined) body.body = description;
363
+ const res = await fetcher(`${GITHUB_API}/repos/${owner}/${repo}/issues`, {
364
+ method: 'POST',
365
+ headers: { ...headers, 'Content-Type': 'application/json' },
366
+ body: JSON.stringify(body),
367
+ signal: AbortSignal.timeout(opts.timeoutMs ?? 10_000),
368
+ });
369
+ if (!res.ok) await throwGitHubWriteError(res, 'creating an issue in', `${owner}/${repo}`);
370
+ const raw = await res.json();
371
+ return { key: `${keyPrefix}-${raw.number}`, id: String(raw.id), url: raw.html_url ?? null };
372
+ },
243
373
  };
244
374
  }
@@ -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, fetchProjects, fetchIssueTypes, postComment, getTransitions, postTransition, assignIssue, escapeJql, getIssueLinkTypes, postIssueLink, updateIssue, createIssue } from '../jira-client.mjs';
2
2
  import { buildJiraEnv } from '../config.mjs';
3
3
 
4
4
  /**
@@ -83,5 +83,71 @@ 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
+ },
113
+
114
+ /**
115
+ * Jira does this in a single atomic PUT (fields + update.labels in one
116
+ * request, confirmed via Jira's own docs) — unlike GitHub, there is no
117
+ * per-field partial-failure surface here: either the whole call
118
+ * succeeds and every requested field is applied, or it throws and
119
+ * ticket-command.mjs's existing formatWriteFailure handles it exactly
120
+ * like every other write. Priority-name validity is not pre-checked —
121
+ * an invalid name surfaces Jira's own 400 with details, same design
122
+ * choice already made for ticket_create's issuetype field.
123
+ */
124
+ async updateFields(key, { title, description, priority, addLabels, removeLabels } = {}, opts = {}) {
125
+ await updateIssue(key, { summary: title, description, priority, addLabels, removeLabels }, { ...base, ...opts });
126
+ const applied = {};
127
+ if (title !== undefined) applied.title = true;
128
+ if (description !== undefined) applied.description = true;
129
+ if (priority !== undefined) applied.priority = priority;
130
+ if (addLabels?.length) applied.addLabels = addLabels;
131
+ if (removeLabels?.length) applied.removeLabels = removeLabels;
132
+ return { applied, errors: {} };
133
+ },
134
+
135
+ /**
136
+ * project/type are passed straight through — never pre-validated
137
+ * client-side, same design choice already made for updateFields'
138
+ * priority field. An invalid issuetype surfaces Jira's own 400.
139
+ */
140
+ createTicket: ({ project, type, summary, description } = {}, opts = {}) =>
141
+ createIssue({ project, type, summary, description }, { ...base, ...opts }),
142
+
143
+ /**
144
+ * Real, currently-creatable projects for this token — used to enrich a
145
+ * ticket_create failure message with actual options, never to
146
+ * pre-validate before writing.
147
+ */
148
+ listCreatableProjects: (opts = {}) => fetchProjects({ ...base, ...opts }),
149
+
150
+ /** Real, currently-configured issue types for one project. */
151
+ listIssueTypes: (projectKey, opts = {}) => fetchIssueTypes(projectKey, { ...base, ...opts }),
86
152
  };
87
153
  }
@@ -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
@@ -87,6 +90,17 @@ async function fetchIssueStateInfo(key, { token, fetcher, signal }) {
87
90
  return node;
88
91
  }
89
92
 
93
+ /** Splits a list of label names into those found in `byName` (with their resolved id) and those not. */
94
+ function resolveLabelNames(names, byName) {
95
+ const resolved = [];
96
+ const missing = [];
97
+ for (const name of names) {
98
+ const id = byName.get(name.toLowerCase());
99
+ if (id) resolved.push({ id, name }); else missing.push(name);
100
+ }
101
+ return { resolved, missing };
102
+ }
103
+
90
104
  async function fetchTeamWorkflowStates(teamId, { token, fetcher, signal }) {
91
105
  const data = await gql(
92
106
  `query ($teamId: ID!) {
@@ -295,5 +309,183 @@ export function createLinearAdapter(conn, { fetcher = globalThis.fetch } = {}) {
295
309
  .filter(node => node.identifier !== sourceKey)
296
310
  .map(normalizeLinearIssue);
297
311
  },
312
+
313
+ /** Fixed schema-level enum (confirmed via GraphQL introspection) — never per-instance configurable, unlike Jira's link types. */
314
+ async getLinkTypes() {
315
+ return LINK_TYPES;
316
+ },
317
+
318
+ /**
319
+ * Validates typeName against the fixed enum before ever touching the
320
+ * network — an invalid type must never reach gql(), which throws a
321
+ * plain Error with no .status, and would otherwise be misclassified
322
+ * as a network/timeout failure by classifyWriteFailure. Resolves both
323
+ * issues' internal UUIDs via fetchIssueStateInfo — issueRelationCreate
324
+ * needs the UUID, never the human identifier. Explicitly checks
325
+ * `success`: Linear can return HTTP 200 with no top-level GraphQL
326
+ * errors and still report success:false.
327
+ */
328
+ async linkTo(sourceKey, targetKey, typeName, opts = {}) {
329
+ const normalizedType = typeName.toLowerCase();
330
+ if (!LINK_TYPES.includes(normalizedType)) {
331
+ return { executed: false, reason: 'not-found', options: LINK_TYPES };
332
+ }
333
+ const signal = AbortSignal.timeout(opts.timeoutMs ?? 10_000);
334
+ const source = await fetchIssueStateInfo(sourceKey, { token, fetcher, signal });
335
+ const target = await fetchIssueStateInfo(targetKey, { token, fetcher, signal });
336
+ const data = await gql(
337
+ `mutation ($issueId: String!, $relatedIssueId: String!, $type: IssueRelationType!) {
338
+ issueRelationCreate(input: { issueId: $issueId, relatedIssueId: $relatedIssueId, type: $type }) {
339
+ success
340
+ }
341
+ }`,
342
+ { issueId: source.id, relatedIssueId: target.id, type: normalizedType },
343
+ { token, fetcher, signal },
344
+ );
345
+ if (!data.issueRelationCreate?.success) {
346
+ throw new Error(`Linear issueRelationCreate reported success:false linking ${sourceKey} to ${targetKey}`);
347
+ }
348
+ return { executed: true };
349
+ },
350
+
351
+ /**
352
+ * Unlike Jira's freeform label strings, Linear's addedLabelIds/
353
+ * removedLabelIds (IssueUpdateInput) take real label UUIDs and never
354
+ * auto-create a missing one — a name must be resolved against the
355
+ * issue's own team first (same shape as fetchTeamWorkflowStates).
356
+ * Resolution failures (unknown label name, unresolvable priority name)
357
+ * are collected into `errors` and simply excluded from the mutation
358
+ * input rather than blocking it — whatever DID resolve still lands in
359
+ * one atomic issueUpdate call. Empty input skips the mutation entirely
360
+ * (nothing valid to send). Priority is accepted as a display name for
361
+ * cross-tracker consistency and reverse-mapped via the same
362
+ * PRIORITY_LABELS table normalizeLinearIssue already uses for reads.
363
+ */
364
+ async updateFields(key, { title, description, priority, addLabels, removeLabels } = {}, opts = {}) {
365
+ const signal = AbortSignal.timeout(opts.timeoutMs ?? 10_000);
366
+ const info = await fetchIssueStateInfo(key, { token, fetcher, signal });
367
+
368
+ const input = {};
369
+ const applied = {};
370
+ const errors = {};
371
+
372
+ if (title !== undefined) input.title = title;
373
+ if (description !== undefined) input.description = description;
374
+
375
+ let resolvedPriorityLabel;
376
+ if (priority !== undefined) {
377
+ const normalized = priority.trim().toLowerCase();
378
+ if (normalized === 'none') {
379
+ input.priority = 0;
380
+ resolvedPriorityLabel = 'None';
381
+ } else {
382
+ const entry = Object.entries(PRIORITY_LABELS).find(([, label]) => label.toLowerCase() === normalized);
383
+ if (entry) {
384
+ input.priority = Number(entry[0]);
385
+ resolvedPriorityLabel = entry[1];
386
+ } else {
387
+ errors.priority = { reason: 'not-found', options: ['None', ...Object.values(PRIORITY_LABELS)] };
388
+ }
389
+ }
390
+ }
391
+
392
+ let addLabelsResolved, addLabelsMissing, removeLabelsResolved, removeLabelsMissing;
393
+ if (addLabels?.length || removeLabels?.length) {
394
+ const labelData = await gql(
395
+ `query ($teamId: ID!) { issueLabels(filter: { team: { id: { eq: $teamId } } }, first: 250) { nodes { id name } } }`,
396
+ { teamId: info.team.id },
397
+ { token, fetcher, signal },
398
+ );
399
+ const byName = new Map((labelData.issueLabels?.nodes ?? []).map(l => [l.name.toLowerCase(), l.id]));
400
+
401
+ if (addLabels?.length) {
402
+ ({ resolved: addLabelsResolved, missing: addLabelsMissing } = resolveLabelNames(addLabels, byName));
403
+ if (addLabelsResolved.length) input.addedLabelIds = addLabelsResolved.map(l => l.id);
404
+ if (addLabelsMissing.length) errors.addLabels = { reason: 'not-found', missing: addLabelsMissing };
405
+ }
406
+ if (removeLabels?.length) {
407
+ ({ resolved: removeLabelsResolved, missing: removeLabelsMissing } = resolveLabelNames(removeLabels, byName));
408
+ if (removeLabelsResolved.length) input.removedLabelIds = removeLabelsResolved.map(l => l.id);
409
+ if (removeLabelsMissing.length) errors.removeLabels = { reason: 'not-found', missing: removeLabelsMissing };
410
+ }
411
+ }
412
+
413
+ if (Object.keys(input).length === 0) {
414
+ return { applied, errors };
415
+ }
416
+
417
+ const data = await gql(
418
+ `mutation ($id: String!, $input: IssueUpdateInput!) { issueUpdate(id: $id, input: $input) { success } }`,
419
+ { id: info.id, input },
420
+ { token, fetcher, signal },
421
+ );
422
+ if (!data.issueUpdate?.success) {
423
+ throw new Error(`Linear issueUpdate reported success:false updating ${key}`);
424
+ }
425
+
426
+ if (title !== undefined) applied.title = true;
427
+ if (description !== undefined) applied.description = true;
428
+ if (input.priority !== undefined) applied.priority = resolvedPriorityLabel;
429
+ if (addLabelsResolved?.length) applied.addLabels = addLabelsResolved.map(l => l.name);
430
+ if (removeLabelsResolved?.length) applied.removeLabels = removeLabelsResolved.map(l => l.name);
431
+
432
+ return { applied, errors };
433
+ },
434
+
435
+ /**
436
+ * `project` is the team's short key (e.g. "ENG") — mutations need the
437
+ * UUID, never the key, so it's resolved via TeamFilter.key first
438
+ * (confirmed against Linear's own official generated GraphQL schema).
439
+ * No discovery call on an unresolvable key, same "surface a clear
440
+ * terminal error" choice already made for Jira's issuetype — there is
441
+ * no partial-creation concept here, unlike updateFields' best-effort
442
+ * shape. `type` has no Linear equivalent and is intentionally ignored.
443
+ */
444
+ async createTicket({ project, summary, description } = {}, opts = {}) {
445
+ const signal = AbortSignal.timeout(opts.timeoutMs ?? 10_000);
446
+ const teamData = await gql(
447
+ `query ($key: String!) { teams(filter: { key: { eq: $key } }, first: 1) { nodes { id } } }`,
448
+ { key: project },
449
+ { token, fetcher, signal },
450
+ );
451
+ const team = teamData.teams?.nodes?.[0];
452
+ if (!team) {
453
+ const err = new Error(`Linear team not found for project "${project}".`);
454
+ // Marked, not message-sniffed — ticket-command.mjs's cache-refresh
455
+ // enrichment needs a clean way to detect "the project/team didn't
456
+ // resolve" without parsing this string.
457
+ err.code = 'PROJECT_NOT_FOUND';
458
+ throw err;
459
+ }
460
+ const input = { teamId: team.id, title: summary };
461
+ if (description !== undefined) input.description = description;
462
+ const data = await gql(
463
+ `mutation ($input: IssueCreateInput!) { issueCreate(input: $input) { success issue { identifier id url } } }`,
464
+ { input },
465
+ { token, fetcher, signal },
466
+ );
467
+ if (!data.issueCreate?.success) {
468
+ throw new Error(`Linear issueCreate reported success:false creating an issue in team "${project}".`);
469
+ }
470
+ const issue = data.issueCreate.issue;
471
+ return { key: issue.identifier, id: issue.id, url: issue.url ?? null };
472
+ },
473
+
474
+ /**
475
+ * Real, currently-accessible teams for this token — unfiltered, used
476
+ * only to enrich a ticket_create failure message with actual options
477
+ * (Jira calls the equivalent concept "projects"; --project already
478
+ * conflates the two at the CLI level, so the shape matches for a
479
+ * uniform cache).
480
+ */
481
+ async listCreatableProjects(opts = {}) {
482
+ const signal = AbortSignal.timeout(opts.timeoutMs ?? 10_000);
483
+ const data = await gql(
484
+ `query { teams(first: 250) { nodes { key name } } }`,
485
+ {},
486
+ { token, fetcher, signal },
487
+ );
488
+ return (data.teams?.nodes ?? []).map(t => ({ key: t.key, name: t.name }));
489
+ },
298
490
  };
299
491
  }
@@ -146,6 +146,18 @@ 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
+
153
+ if (first === 'update') {
154
+ return { command: 'update', args: args.slice(1) };
155
+ }
156
+
157
+ if (first === 'create') {
158
+ return { command: 'create', args: args.slice(1) };
159
+ }
160
+
149
161
  // Anything that looks like a ticket key or any non-flag arg → fetch
150
162
  return { command: 'fetch', args };
151
163
  }