ticketlens 0.27.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`, `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).
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 & Link
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
@@ -429,9 +429,13 @@ ticketlens assign PROJ-123 --to=me # Assign the ticket
429
429
  ticketlens duplicates PROJ-123 # Find likely duplicates (read-only)
430
430
  ticketlens link PROJ-123 PROJ-456 # List valid link types (read-only)
431
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
432
436
  ```
433
437
 
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.
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.
435
439
 
436
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.
437
441
 
@@ -441,7 +445,13 @@ Write directly to the ticket in its real tracker — Jira, GitHub, or Linear —
441
445
 
442
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.
443
447
 
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.
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.
445
455
 
446
456
  ---
447
457
 
@@ -722,7 +732,7 @@ ticketlens mcp # Start the MCP stdio server (reca
722
732
  ticketlens mcp install # Register it into the current project's .mcp.json
723
733
  ticketlens mcp install --dry-run # Preview the registration without writing
724
734
 
725
- # ── Comment, Transition, Assign, Duplicates & Link ──────────────────────────────
735
+ # ── Comment, Transition, Assign, Duplicates, Link, Update & Create ──────────────
726
736
  ticketlens comment CNV1-2 --body="Looks good, merging." # Post a comment to the tracker [Pro]
727
737
  ticketlens transition CNV1-2 # List valid transitions (read-only) [Pro]
728
738
  ticketlens transition CNV1-2 --target="Done" --confirm # Execute the transition [Pro]
@@ -731,6 +741,10 @@ ticketlens duplicates CNV1-2 # Find likely duplic
731
741
  ticketlens duplicates CNV1-2 --threshold=0.5 # Tighten the match threshold [Pro]
732
742
  ticketlens link CNV1-2 CNV1-3 # List valid link types (read-only) [Pro]
733
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]
734
748
 
735
749
  # ── Stats ──────────────────────────────────────────────────────────────────────
736
750
  ticketlens stats # Response-time metrics from local history
@@ -818,6 +832,8 @@ ticketlens transition CNV1-2 --target="Done" --confirm # Transition ticket stat
818
832
  ticketlens assign CNV1-2 --to=me # Assign the ticket to yourself
819
833
  ticketlens duplicates CNV1-2 # Find likely duplicates (read-only)
820
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
821
837
  ticketlens activate YOUR-LICENSE-KEY # Activate Pro license
822
838
  ```
823
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, printLinkHelp,
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('-')) {
@@ -806,6 +807,30 @@ switch (command) {
806
807
  break;
807
808
  }
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
+
809
834
  case 'help':
810
835
  default: {
811
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.27.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": {
@@ -272,5 +272,103 @@ export function createGitHubAdapter(conn, { fetcher = globalThis.fetch } = {}) {
272
272
  if (!res.ok) await throwGitHubWriteError(res, 'linking', sourceKey);
273
273
  return { executed: true, closedAsDuplicateOf: targetKey };
274
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
+ },
275
373
  };
276
374
  }
@@ -1,4 +1,4 @@
1
- import { fetchTicket, fetchCurrentUser, searchTickets, fetchStatuses, postComment, getTransitions, postTransition, assignIssue, escapeJql, getIssueLinkTypes, postIssueLink } 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
  /**
@@ -110,5 +110,44 @@ export function createJiraAdapter(conn, { fetcher = globalThis.fetch } = {}) {
110
110
  await postIssueLink(sourceKey, targetKey, match.name, { ...base, ...opts });
111
111
  return { executed: true };
112
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 }),
113
152
  };
114
153
  }
@@ -90,6 +90,17 @@ async function fetchIssueStateInfo(key, { token, fetcher, signal }) {
90
90
  return node;
91
91
  }
92
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
+
93
104
  async function fetchTeamWorkflowStates(teamId, { token, fetcher, signal }) {
94
105
  const data = await gql(
95
106
  `query ($teamId: ID!) {
@@ -336,5 +347,145 @@ export function createLinearAdapter(conn, { fetcher = globalThis.fetch } = {}) {
336
347
  }
337
348
  return { executed: true };
338
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
+ },
339
490
  };
340
491
  }
@@ -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 = 'http://api.ticketlens.test';
6
+ export const DEFAULT_API_BASE = 'https://api.ticketlens.app';
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,
@@ -150,6 +150,14 @@ export function parseCommand(args) {
150
150
  return { command: 'link', args: args.slice(1) };
151
151
  }
152
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
+
153
161
  // Anything that looks like a ticket key or any non-flag arg → fetch
154
162
  return { command: 'fetch', args };
155
163
  }