ticketlens 0.27.0 → 0.31.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,15 +411,17 @@ 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
- `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.
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. Each result shows a relative time (`2h ago`, `3d ago`) rather than a bare date — the full-precision timestamp is always in the note file's own frontmatter.
417
+
418
+ **Tags matter for search relevance.** `--tags=a,b` accepts anything, but a generic tag (the project name, "gotcha", "bug") gives future search almost nothing to match on. Tag with what the note is actually *about* — the specific technology, error type, or root cause (`retry-backoff`, `null-pointer`, `auth-middleware`) — so it surfaces when someone else hits the same problem.
417
419
 
418
420
  **Gaps** — every `ticketlens PROJ-123` brief also diffs the ticket's own description against its linked tickets (from the depth traversal you already requested) and its own downloaded attachments, looking for requirements mentioned there but missing here. Anything uncovered shows up under a `## Gaps` section, citing exactly where it came from — a linked ticket key or an attachment filename — as evidence, never an instruction to act on. Nothing is saved anywhere; it's recomputed fresh on every fetch. Requires a Pro license, same as Recall. No network call beyond what the brief already made.
419
421
 
420
422
  ---
421
423
 
422
- ### Comment, Transition, Assign, Duplicates & Link
424
+ ### Comment, Transition, Assign, Duplicates, Link, Update & Create
423
425
 
424
426
  ```bash
425
427
  ticketlens comment PROJ-123 --body="Looks good, merging." # Post a comment to the tracker
@@ -429,9 +431,13 @@ ticketlens assign PROJ-123 --to=me # Assign the ticket
429
431
  ticketlens duplicates PROJ-123 # Find likely duplicates (read-only)
430
432
  ticketlens link PROJ-123 PROJ-456 # List valid link types (read-only)
431
433
  ticketlens link PROJ-123 PROJ-456 --type="Duplicate" --confirm # Execute the link
434
+ ticketlens update PROJ-123 --title="Fix login on mobile" # Update title/description/labels/priority
435
+ ticketlens update PROJ-123 --add-labels=urgent,backend --remove-labels=stale
436
+ ticketlens create --project=PROJ --type="Task" --summary="Fix login on mobile" # Create a new ticket
437
+ ticketlens create --project=ENG --summary="New Linear issue" --profile=linear-team
432
438
  ```
433
439
 
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.
440
+ 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
441
 
436
442
  `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
443
 
@@ -441,7 +447,13 @@ Write directly to the ticket in its real tracker — Jira, GitHub, or Linear —
441
447
 
442
448
  `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
449
 
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.
450
+ `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.
451
+
452
+ `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.
453
+
454
+ **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.
455
+
456
+ 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
457
 
446
458
  ---
447
459
 
@@ -722,7 +734,7 @@ ticketlens mcp # Start the MCP stdio server (reca
722
734
  ticketlens mcp install # Register it into the current project's .mcp.json
723
735
  ticketlens mcp install --dry-run # Preview the registration without writing
724
736
 
725
- # ── Comment, Transition, Assign, Duplicates & Link ──────────────────────────────
737
+ # ── Comment, Transition, Assign, Duplicates, Link, Update & Create ──────────────
726
738
  ticketlens comment CNV1-2 --body="Looks good, merging." # Post a comment to the tracker [Pro]
727
739
  ticketlens transition CNV1-2 # List valid transitions (read-only) [Pro]
728
740
  ticketlens transition CNV1-2 --target="Done" --confirm # Execute the transition [Pro]
@@ -731,6 +743,10 @@ ticketlens duplicates CNV1-2 # Find likely duplic
731
743
  ticketlens duplicates CNV1-2 --threshold=0.5 # Tighten the match threshold [Pro]
732
744
  ticketlens link CNV1-2 CNV1-3 # List valid link types (read-only) [Pro]
733
745
  ticketlens link CNV1-2 CNV1-3 --type="Duplicate" --confirm # Execute the link [Pro]
746
+ ticketlens update CNV1-2 --title="New title" # Update title/description/labels/priority [Pro]
747
+ ticketlens update CNV1-2 --add-labels=urgent --remove-labels=stale # Add/remove labels [Pro]
748
+ ticketlens create --project=CNV1 --type="Task" --summary="New ticket" # Create a new ticket [Pro]
749
+ ticketlens create --project=ENG --summary="New issue" --profile=linear-team # Create on a different profile [Pro]
734
750
 
735
751
  # ── Stats ──────────────────────────────────────────────────────────────────────
736
752
  ticketlens stats # Response-time metrics from local history
@@ -818,6 +834,8 @@ ticketlens transition CNV1-2 --target="Done" --confirm # Transition ticket stat
818
834
  ticketlens assign CNV1-2 --to=me # Assign the ticket to yourself
819
835
  ticketlens duplicates CNV1-2 # Find likely duplicates (read-only)
820
836
  ticketlens link CNV1-2 CNV1-3 --type="Duplicate" --confirm # Link two tickets
837
+ ticketlens update CNV1-2 --title="..." # Update title/description/labels/priority
838
+ ticketlens create --project=CNV1 --type="Task" --summary="..." # Create a new ticket
821
839
  ticketlens activate YOUR-LICENSE-KEY # Activate Pro license
822
840
  ```
823
841
 
@@ -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('-')) {
@@ -541,7 +542,7 @@ switch (command) {
541
542
  case 'cloud-keys': {
542
543
  if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printCloudKeysHelp(); break; }
543
544
 
544
- const { listCloudKeys, addCloudKey, removeCloudKey, setPriority, setTimeout_, testCloudKey } =
545
+ const { listCloudKeys, addCloudKey, runCloudKeysRemove, setPriority, setTimeout_, testCloudKey } =
545
546
  await import('../skills/jtb/scripts/lib/cloud-keys.mjs');
546
547
 
547
548
  const cliToken = readCliToken();
@@ -591,12 +592,14 @@ switch (command) {
591
592
  if (subCmd === 'remove') {
592
593
  const provider = cmdArgs[1];
593
594
  if (!provider) {
594
- process.stderr.write('Usage: ticketlens cloud-keys remove <provider>\n');
595
+ process.stderr.write('Usage: ticketlens cloud-keys remove <provider> [--yes|-y]\n');
595
596
  process.exitCode = 1;
596
597
  return;
597
598
  }
598
- await removeCloudKey(cfg, provider);
599
- process.stdout.write(`${s.brand('✓')} ${provider} key removed.\n`);
599
+ const forceYes = cmdArgs.includes('--yes') || cmdArgs.includes('-y');
600
+ const { removed } = await runCloudKeysRemove(cfg, provider, { forceYes });
601
+ if (removed) process.stdout.write(`${s.brand('✓')} ${provider} key removed.\n`);
602
+ else process.exitCode = 1;
600
603
  return;
601
604
  }
602
605
 
@@ -806,6 +809,30 @@ switch (command) {
806
809
  break;
807
810
  }
808
811
 
812
+ case 'update': {
813
+ if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printUpdateHelp(); break; }
814
+ const { runTicketUpdate } = await import('../skills/jtb/scripts/lib/ticket-command.mjs');
815
+ runTicketUpdate(cmdArgs).then(({ ok }) => {
816
+ if (!ok) process.exitCode = 1;
817
+ }).catch(err => {
818
+ process.stderr.write(`Error: ${err.message}\n`);
819
+ process.exitCode = 1;
820
+ });
821
+ break;
822
+ }
823
+
824
+ case 'create': {
825
+ if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printCreateHelp(); break; }
826
+ const { runTicketCreate } = await import('../skills/jtb/scripts/lib/ticket-command.mjs');
827
+ runTicketCreate(cmdArgs).then(({ ok }) => {
828
+ if (!ok) process.exitCode = 1;
829
+ }).catch(err => {
830
+ process.stderr.write(`Error: ${err.message}\n`);
831
+ process.exitCode = 1;
832
+ });
833
+ break;
834
+ }
835
+
809
836
  case 'help':
810
837
  default: {
811
838
  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.31.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": {
@@ -1,4 +1,4 @@
1
- <!-- jtb-skill-version: 0.26.0 -->
1
+ <!-- jtb-skill-version: 0.27.0 -->
2
2
  ---
3
3
  name: jtb
4
4
  description: Fetch a Jira ticket's full context (description, comments, linked issues, code references) and assemble a structured TicketBrief for implementation planning. Use when user types /jtb, mentions a Jira ticket key, or wants to plan work from a Jira ticket.
@@ -66,6 +66,8 @@ Fetches a Jira ticket and produces a structured brief with code references, then
66
66
  /jtb assign PROD-1234 --to=me # assign the ticket to yourself (Pro)
67
67
  ```
68
68
 
69
+ **Destructive commands** (`note delete`, `cloud-keys remove`, `delete <profile>`) prompt for interactive y/N confirmation and refuse outright in a non-interactive shell unless `--yes` is passed — there is no way to silently skip this. Only pass `--yes` when the user has explicitly asked for that specific deletion in this conversation (their message *is* the confirmation); never add it to route around the prompt for a deletion you decided to make on your own.
70
+
69
71
  ## Prerequisites
70
72
 
71
73
  TicketLens supports two connection methods — check in this order:
@@ -91,7 +93,7 @@ If the first argument is `triage`:
91
93
 
92
94
  Run:
93
95
  ```bash
94
- node ~/.agents/skills/jtb/scripts/fetch-my-tickets.mjs $EXTRA_ARGS
96
+ ticketlens triage $EXTRA_ARGS
95
97
  ```
96
98
 
97
99
  Where `$EXTRA_ARGS` are any flags passed (e.g. `--stale=3 --status=QA --profile=acme`).
@@ -112,7 +114,7 @@ If the first argument is `collisions`:
112
114
 
113
115
  Run:
114
116
  ```bash
115
- node ~/.agents/skills/jtb/scripts/lib/run-collisions.mjs $EXTRA_ARGS
117
+ ticketlens collisions $EXTRA_ARGS
116
118
  ```
117
119
 
118
120
  Where `$EXTRA_ARGS` are any flags passed (e.g. `--json`, `--plain`).
@@ -136,7 +138,7 @@ Follow the Prerequisites section above:
136
138
 
137
139
  Run:
138
140
  ```bash
139
- node ~/.agents/skills/jtb/scripts/fetch-ticket.mjs "$TICKET_KEY" $EXTRA_ARGS
141
+ ticketlens "$TICKET_KEY" $EXTRA_ARGS
140
142
  ```
141
143
 
142
144
  Where `$TICKET_KEY` is the first argument (e.g. `PROD-1234`) and `$EXTRA_ARGS` are any flags passed (e.g. `--depth=0`).
@@ -234,9 +236,11 @@ echo "The body text of the note, one or more paragraphs." | \
234
236
  ticketlens note add --title="Short title" --ticket=TICKET-KEY --tags=a,b
235
237
  ```
236
238
 
239
+ **Choosing tags:** derive them from this note's actual content — the specific technology, error type, root cause, or affected component (e.g. `retry-backoff`, `null-pointer`, `auth-middleware`) — never the project name or a generic category word like `gotcha` or `bug`. A tag like `jtb` or `ticketlens` tells a future search nothing that the ticket/project context doesn't already say; a tag like `retry-backoff` is what actually surfaces this note when someone else hits the same problem. Same rule whether you're constructing the bash command above or calling `recall_add` directly — see its tool description for the same guidance.
240
+
237
241
  To search saved notes directly (outside of automatic brief injection): `ticketlens recall "<query>"`.
238
242
 
239
- **Pick exactly one path per capture — never both.** If this harness has TicketLens's MCP server configured (`ticketlens mcp` — `recall_add`/`recall_search` as native tools, see `ticketlens mcp --help`), prefer calling those tools directly over the bash commands above — same license gate, same secret scan, same vault, same team sync, just no shell command to construct. Fall back to the bash form only when the MCP tools aren't available. Calling both for the same insight creates two near-duplicate notes (no dedup exists between the two paths) and, with team sync on, two separate pushes for a manager to review.
243
+ **Pick exactly one path per capture — never both.** If this harness has TicketLens's MCP server configured (tools named `recall_add`/`recall_search` — often shown as `mcp__ticketlens__recall_add` — visible in your tool list), **use those tools, not the bash commands above** — same license gate, same secret scan, same vault, same team sync, just no shell command to construct. Only fall back to the bash form when the MCP tools are genuinely absent from your tool list. If they're absent because this project has never registered the server, tell the user once: `ticketlens mcp install` writes (or merges into) this project's `.mcp.json` — don't run it yourself unprompted, since it changes what your harness auto-connects to on next launch, and the user should be the one deciding that. Calling both for the same insight creates two near-duplicate notes (no dedup exists between the two paths) and, with team sync on, two separate pushes for a manager to review.
240
244
 
241
245
  ### Quality loop (Pro, in-session only)
242
246
 
@@ -284,7 +288,7 @@ ticketlens duplicates PROD-1234 # find likely dupl
284
288
 
285
289
  The three write actions (comment/transition/assign) have a short local debounce (10s) against an accidental double-fire, and every write is appended to a local audit log (`~/.ticketlens/ticket-action-log.jsonl`). A write that times out is never retried automatically — surface the failure to the user rather than silently re-attempting, since a ticket write isn't naturally idempotent the way a Recall note save is. `duplicates` has neither, since nothing is written.
286
290
 
287
- **Pick exactly one path per action — never both.** If this harness has TicketLens's MCP server configured (`ticketlens mcp` — `ticket_comment`/`ticket_transition`/`ticket_assign`/`ticket_duplicates` as native tools, see `ticketlens mcp --help`), prefer calling those tools directly over the bash commands above — same license gate, same cooldown, same audit log. Fall back to the bash form only when the MCP tools aren't available.
291
+ **Pick exactly one path per action — never both.** If this harness has TicketLens's MCP server configured (tools named `ticket_comment`/`ticket_transition`/`ticket_assign`/`ticket_duplicates`/`ticket_link`/`ticket_update`/`ticket_create` — often shown as `mcp__ticketlens__ticket_comment` etc. — visible in your tool list), **use those tools, not the bash commands above** — same license gate, same cooldown, same audit log. Only fall back to the bash form when the MCP tools are genuinely absent from your tool list; if that's because this project has never registered the server, see the `ticketlens mcp install` note above (Recall section) — same guidance applies here.
288
292
 
289
293
  Requires a Pro license — on Free, all four no-op with an upgrade hint on stderr.
290
294
 
@@ -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,
@@ -130,6 +130,10 @@ export function parseCommand(args) {
130
130
  return { command: 'mcp', args: args.slice(1) };
131
131
  }
132
132
 
133
+ if (first === 'cloud-keys') {
134
+ return { command: 'cloud-keys', args: args.slice(1) };
135
+ }
136
+
133
137
  if (first === 'comment') {
134
138
  return { command: 'comment', args: args.slice(1) };
135
139
  }
@@ -150,6 +154,14 @@ export function parseCommand(args) {
150
154
  return { command: 'link', args: args.slice(1) };
151
155
  }
152
156
 
157
+ if (first === 'update') {
158
+ return { command: 'update', args: args.slice(1) };
159
+ }
160
+
161
+ if (first === 'create') {
162
+ return { command: 'create', args: args.slice(1) };
163
+ }
164
+
153
165
  // Anything that looks like a ticket key or any non-flag arg → fetch
154
166
  return { command: 'fetch', args };
155
167
  }
@@ -4,6 +4,8 @@
4
4
  * All operations require a CLI token (set via `ticketlens login`).
5
5
  */
6
6
 
7
+ import { confirmDestructive } from './confirm.mjs';
8
+
7
9
  const SUPPORTED_PROVIDERS = ['groq', 'anthropic', 'openai'];
8
10
 
9
11
  function apiBase(config) {
@@ -84,6 +86,29 @@ export async function removeCloudKey(config, provider) {
84
86
  await fetchApi(config, `/v1/ai-providers/${target.id}`, 'DELETE');
85
87
  }
86
88
 
89
+ /**
90
+ * CLI-facing wrapper around removeCloudKey — gates the irreversible remote
91
+ * deletion behind a y/N confirmation (or --yes) before calling it.
92
+ */
93
+ export async function runCloudKeysRemove(config, provider, opts = {}) {
94
+ const {
95
+ stream = process.stderr,
96
+ stdin = process.stdin,
97
+ forceYes = false,
98
+ confirmFn = confirmDestructive,
99
+ removeCloudKeyFn = removeCloudKey,
100
+ } = opts;
101
+
102
+ const confirmed = await confirmFn(`Remove the ${provider} key`, { stdin, stream, forceYes });
103
+ if (!confirmed) {
104
+ stream.write(' Aborted — key was not removed.\n');
105
+ return { removed: false };
106
+ }
107
+
108
+ await removeCloudKeyFn(config, provider);
109
+ return { removed: true };
110
+ }
111
+
87
112
  export async function setPriority(config, provider, priority) {
88
113
  const target = await findProvider(config, provider);
89
114
  await fetchApi(config, `/v1/ai-providers/${target.id}`, 'PUT', { priority: Number(priority) });