ticketlens 0.24.0 → 0.25.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`, and `ticket_transition` 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`, and `ticket_assign` 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,19 +419,22 @@ Every note is scanned before saving — anything shaped like a real secret (API
419
419
 
420
420
  ---
421
421
 
422
- ### Comment & Transition
422
+ ### Comment, Transition & Assign
423
423
 
424
424
  ```bash
425
425
  ticketlens comment PROJ-123 --body="Looks good, merging." # Post a comment to the tracker
426
426
  ticketlens transition PROJ-123 # List valid transitions (read-only)
427
427
  ticketlens transition PROJ-123 --target="Done" --confirm # Execute the transition
428
+ ticketlens assign PROJ-123 --to=me # Assign the ticket to yourself
428
429
  ```
429
430
 
430
- 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` MCP tools. Requires a Pro license.
431
+ Write directly to the ticket in its real tracker — Jira, GitHub, or Linear — from your terminal or an AI session via `ticket_comment`/`ticket_transition`/`ticket_assign` MCP tools. Requires a Pro license.
431
432
 
432
433
  `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.
433
434
 
434
- Both actions 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 comments/transitions aren't naturally idempotent, so a timed-out attempt is surfaced to you instead of silently repeated.
435
+ `ticketlens assign` is self-assign only for now — `--to` must be `me`. Assigning to someone else needs a per-tracker user-lookup step this doesn't do yet, so it's deliberately out of scope until that's built.
436
+
437
+ All three actions 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.
435
438
 
436
439
  ---
437
440
 
@@ -712,10 +715,11 @@ ticketlens mcp # Start the MCP stdio server (reca
712
715
  ticketlens mcp install # Register it into the current project's .mcp.json
713
716
  ticketlens mcp install --dry-run # Preview the registration without writing
714
717
 
715
- # ── Comment & Transition ─────────────────────────────────────────────────────
718
+ # ── Comment, Transition & Assign ─────────────────────────────────────────────
716
719
  ticketlens comment CNV1-2 --body="Looks good, merging." # Post a comment to the tracker [Pro]
717
720
  ticketlens transition CNV1-2 # List valid transitions (read-only) [Pro]
718
721
  ticketlens transition CNV1-2 --target="Done" --confirm # Execute the transition [Pro]
722
+ ticketlens assign CNV1-2 --to=me # Assign the ticket to yourself [Pro]
719
723
 
720
724
  # ── Stats ──────────────────────────────────────────────────────────────────────
721
725
  ticketlens stats # Response-time metrics from local history
@@ -800,6 +804,7 @@ ticketlens recall settings # Show effective retry-queue settings,
800
804
  ticketlens mcp # Start the MCP stdio server (recall/ticket write tools)
801
805
  ticketlens comment CNV1-2 --body="..." # Post a comment to the tracker
802
806
  ticketlens transition CNV1-2 --target="Done" --confirm # Transition ticket status
807
+ ticketlens assign CNV1-2 --to=me # Assign the ticket to yourself
803
808
  ticketlens activate YOUR-LICENSE-KEY # Activate Pro license
804
809
  ```
805
810
 
@@ -28,7 +28,7 @@ import {
28
28
  printCollisionsHelp, printStatsHelp,
29
29
  printCloudKeysHelp,
30
30
  printNoteHelp, printRecallHelp, printMcpHelp,
31
- printCommentHelp, printTransitionHelp,
31
+ printCommentHelp, printTransitionHelp, printAssignHelp,
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';
@@ -766,6 +766,18 @@ switch (command) {
766
766
  break;
767
767
  }
768
768
 
769
+ case 'assign': {
770
+ if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printAssignHelp(); break; }
771
+ const { runTicketAssign } = await import('../skills/jtb/scripts/lib/ticket-command.mjs');
772
+ runTicketAssign(cmdArgs).then(({ ok }) => {
773
+ if (!ok) process.exitCode = 1;
774
+ }).catch(err => {
775
+ process.stderr.write(`Error: ${err.message}\n`);
776
+ process.exitCode = 1;
777
+ });
778
+ break;
779
+ }
780
+
769
781
  case 'help':
770
782
  default: {
771
783
  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.24.0",
3
+ "version": "0.25.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.24.0 -->
1
+ <!-- jtb-skill-version: 0.25.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.
@@ -63,6 +63,7 @@ Fetches a Jira ticket and produces a structured brief with code references, then
63
63
  /jtb comment PROD-1234 --body="..." # post a comment to the tracker (Pro)
64
64
  /jtb transition PROD-1234 # list the tracker's current valid transitions (Pro)
65
65
  /jtb transition PROD-1234 --target="Done" --confirm # execute the transition (Pro)
66
+ /jtb assign PROD-1234 --to=me # assign the ticket to yourself (Pro)
66
67
  ```
67
68
 
68
69
  ## Prerequisites
@@ -263,23 +264,26 @@ Recall notes are stored locally at `~/.ticketlens/recall/`. On a Pro account wit
263
264
 
264
265
  ---
265
266
 
266
- ## Comment & Transition — write back to the tracker (Pro)
267
+ ## Comment, Transition & Assign — write back to the tracker (Pro)
267
268
 
268
- Unlike Recall (a local note about a ticket), these write directly to the ticket's real tracker — Jira, GitHub, or Linear. Only dispatch when the user has actually asked for the ticket to be commented on or moved — never as a routine end-of-session action the way Recall capture is.
269
+ Unlike Recall (a local note about a ticket), these write directly to the ticket's real tracker — Jira, GitHub, or Linear. Only dispatch when the user has actually asked for the ticket to be commented on, moved, or assigned — never as a routine end-of-session action the way Recall capture is.
269
270
 
270
271
  ```bash
271
272
  ticketlens comment PROD-1234 --body="Fixed in a2f9c1, deployed to staging."
272
273
  ticketlens transition PROD-1234 # list valid transitions — read-only
273
274
  ticketlens transition PROD-1234 --target="Done" --confirm # execute
275
+ ticketlens assign PROD-1234 --to=me # assign to yourself
274
276
  ```
275
277
 
276
278
  `transition` called with just a ticket key never mutates anything — it lists the tracker's current valid options (Jira: real workflow transitions for that issue; GitHub: open/closed; Linear: team-scoped workflow states). Only add `--target` **and** `--confirm` once the target has actually been confirmed with the user — `--confirm` is a deliberate two-step gate, not a formality to route around. Never guess a `--target` value; always list first, then use one of the names shown.
277
279
 
278
- Both actions 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.
280
+ `assign` is self-assign only — `--to` must be `me`. There is no way to assign to anyone else yet; don't attempt a workaround (e.g. via `comment`) if the user asks for that — tell them it isn't supported.
279
281
 
280
- **Pick exactly one path per action — never both.** If this harness has TicketLens's MCP server configured (`ticketlens mcp` — `ticket_comment`/`ticket_transition` 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.
282
+ All three actions 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.
281
283
 
282
- Requires a Pro license — on Free, both no-op with an upgrade hint on stderr.
284
+ **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` 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.
285
+
286
+ Requires a Pro license — on Free, all three no-op with an upgrade hint on stderr.
283
287
 
284
288
  ---
285
289
 
@@ -123,7 +123,7 @@ export function createGitHubAdapter(conn, { fetcher = globalThis.fetch } = {}) {
123
123
  });
124
124
  if (!res.ok) throw new Error(`GitHub API error ${res.status} fetching current user`);
125
125
  const raw = await res.json();
126
- return { displayName: raw.name || raw.login, email: raw.email ?? null };
126
+ return { displayName: raw.name || raw.login, email: raw.email ?? null, login: raw.login };
127
127
  },
128
128
 
129
129
  async searchTickets(_query, opts = {}) {
@@ -187,5 +187,26 @@ export function createGitHubAdapter(conn, { fetcher = globalThis.fetch } = {}) {
187
187
  if (!res.ok) await throwGitHubWriteError(res, 'transitioning', key);
188
188
  return { executed: true, to: match.to };
189
189
  },
190
+
191
+ /**
192
+ * Self-assign only — GitHub's assignees field is an array, but this
193
+ * always replaces it with exactly the caller, mirroring the other
194
+ * adapters' self-assign scope (arbitrary-user assignment deferred).
195
+ */
196
+ async assignToSelf(key, opts = {}) {
197
+ const me = await this.fetchCurrentUser(opts);
198
+ if (!me.login) {
199
+ throw new Error(`Cannot determine current user's login — GitHub did not return it for this connection.`);
200
+ }
201
+ const number = parseInt(key.split('-').pop(), 10);
202
+ const res = await fetcher(`${GITHUB_API}/repos/${owner}/${repo}/issues/${number}`, {
203
+ method: 'PATCH',
204
+ headers: { ...headers, 'Content-Type': 'application/json' },
205
+ body: JSON.stringify({ assignees: [me.login] }),
206
+ signal: AbortSignal.timeout(opts.timeoutMs ?? 10_000),
207
+ });
208
+ if (!res.ok) await throwGitHubWriteError(res, 'assigning', key);
209
+ return { assignee: me.displayName ?? me.login };
210
+ },
190
211
  };
191
212
  }
@@ -1,4 +1,4 @@
1
- import { fetchTicket, fetchCurrentUser, searchTickets, fetchStatuses, postComment, getTransitions, postTransition } from '../jira-client.mjs';
1
+ import { fetchTicket, fetchCurrentUser, searchTickets, fetchStatuses, postComment, getTransitions, postTransition, assignIssue } from '../jira-client.mjs';
2
2
  import { buildJiraEnv } from '../config.mjs';
3
3
 
4
4
  /**
@@ -43,5 +43,26 @@ export function createJiraAdapter(conn, { fetcher = globalThis.fetch } = {}) {
43
43
  await postTransition(key, match.id, { ...base, ...opts });
44
44
  return { executed: true, to: match.to ?? match.name };
45
45
  },
46
+
47
+ /**
48
+ * Self-assign only — arbitrary-user assignment would need a
49
+ * user-search API this codebase doesn't have yet. Reuses
50
+ * fetchCurrentUser, which already returns both accountId (Cloud)
51
+ * and name (Server/DC).
52
+ */
53
+ async assignToSelf(key, opts = {}) {
54
+ const me = await fetchCurrentUser({ ...base, ...opts });
55
+ const field = apiVersion === 3 ? 'accountId' : 'name';
56
+ const value = me[field];
57
+ // Jira's PUT /issue/{key}/assignee treats a null identity field as
58
+ // "unassign", not an error — it returns 204 either way. Never send
59
+ // it: that would silently unassign the ticket while this command
60
+ // reports success.
61
+ if (!value) {
62
+ throw new Error(`Cannot determine current user's ${field} — Jira did not return it for this connection.`);
63
+ }
64
+ await assignIssue(key, { [field]: value }, { ...base, ...opts });
65
+ return { assignee: me.displayName ?? value };
66
+ },
46
67
  };
47
68
  }
@@ -127,12 +127,12 @@ export function createLinearAdapter(conn, { fetcher = globalThis.fetch } = {}) {
127
127
  async fetchCurrentUser(opts = {}) {
128
128
  const signal = AbortSignal.timeout(opts.timeoutMs ?? 10_000);
129
129
  const data = await gql(
130
- `{ viewer { name email } }`,
130
+ `{ viewer { id name email } }`,
131
131
  {},
132
132
  { token, fetcher, signal },
133
133
  );
134
134
  const v = data.viewer;
135
- return { displayName: v.name, email: v.email ?? null };
135
+ return { displayName: v.name, email: v.email ?? null, id: v.id };
136
136
  },
137
137
 
138
138
  async searchTickets(_query, opts = {}) {
@@ -231,5 +231,31 @@ export function createLinearAdapter(conn, { fetcher = globalThis.fetch } = {}) {
231
231
  }
232
232
  return { executed: true, to: match.to };
233
233
  },
234
+
235
+ /**
236
+ * Self-assign only — arbitrary-user assignment would need a
237
+ * user-search query this codebase doesn't have yet.
238
+ */
239
+ async assignToSelf(key, opts = {}) {
240
+ const signal = AbortSignal.timeout(opts.timeoutMs ?? 10_000);
241
+ const me = await this.fetchCurrentUser(opts);
242
+ if (!me.id) {
243
+ throw new Error(`Cannot determine current user's id — Linear did not return it for this connection.`);
244
+ }
245
+ const info = await fetchIssueStateInfo(key, { token, fetcher, signal });
246
+ const data = await gql(
247
+ `mutation ($id: String!, $assigneeId: String!) {
248
+ issueUpdate(id: $id, input: { assigneeId: $assigneeId }) {
249
+ success
250
+ }
251
+ }`,
252
+ { id: info.id, assigneeId: me.id },
253
+ { token, fetcher, signal },
254
+ );
255
+ if (!data.issueUpdate?.success) {
256
+ throw new Error(`Linear issueUpdate reported success:false assigning ${key}`);
257
+ }
258
+ return { assignee: me.displayName ?? me.id };
259
+ },
234
260
  };
235
261
  }
@@ -138,6 +138,10 @@ export function parseCommand(args) {
138
138
  return { command: 'transition', args: args.slice(1) };
139
139
  }
140
140
 
141
+ if (first === 'assign') {
142
+ return { command: 'assign', args: args.slice(1) };
143
+ }
144
+
141
145
  // Anything that looks like a ticket key or any non-flag arg → fetch
142
146
  return { command: 'fetch', args };
143
147
  }
@@ -56,6 +56,7 @@ export function printHelp({ stream = process.stdout } = {}) {
56
56
  ` ${s.brand('ticketlens')} mcp Start the MCP stdio server for Recall ${s.dim('[Pro]')}`,
57
57
  ` ${s.brand('ticketlens')} comment ${s.dim('<TICKET-KEY> --body=...')} Post a comment to the tracker ${s.dim('[Pro]')}`,
58
58
  ` ${s.brand('ticketlens')} transition ${s.dim('<TICKET-KEY> [--target=... --confirm]')} Move ticket status ${s.dim('[Pro]')}`,
59
+ ` ${s.brand('ticketlens')} assign ${s.dim('<TICKET-KEY> --to=me')} Assign a ticket to yourself ${s.dim('[Pro]')}`,
59
60
  '',
60
61
  ` ${s.brand('ticketlens')} delete ${s.dim('<PROFILE-NAME>')} Remove a profile`,
61
62
  ` ${s.brand('ticketlens')} activate ${s.dim('<KEY>')} Activate a license key`,
@@ -596,10 +597,11 @@ export function printMcpHelp({ stream = process.stdout } = {}) {
596
597
  '',
597
598
  ` Start an MCP (Model Context Protocol) stdio server exposing Recall and`,
598
599
  ` ticket writes as native tools — ${s.cyan('recall_add')}, ${s.cyan('recall_search')}, ${s.cyan('ticket_comment')},`,
599
- ` ${s.cyan('ticket_transition')} — for any MCP-compatible AI harness, not just Claude Code.`,
600
- ` Thin adapter over the same code as ${s.cyan('note add')}/${s.cyan('recall')}/${s.cyan('comment')}/${s.cyan('transition')}`,
601
- ` above: same Pro gate, same local vault/tracker writes, same team sync.`,
602
- ` ${s.cyan('ticket_transition')} is destructive when called with \`target\`+\`confirm: true\`.`,
600
+ ` ${s.cyan('ticket_transition')}, ${s.cyan('ticket_assign')} — for any MCP-compatible AI harness, not just`,
601
+ ` Claude Code. Thin adapter over the same code as ${s.cyan('note add')}/${s.cyan('recall')}/${s.cyan('comment')}/`,
602
+ ` ${s.cyan('transition')}/${s.cyan('assign')} above: same Pro gate, same local vault/tracker writes, same`,
603
+ ` team sync. ${s.cyan('ticket_transition')} is destructive when called with \`target\`+\`confirm: true\`;`,
604
+ ` ${s.cyan('ticket_assign')} is currently self-assign only.`,
603
605
  ` Long-running — exits when the client closes stdin.`,
604
606
  '',
605
607
  ` ${s.bold('OPTIONS')}`,
@@ -676,6 +678,30 @@ export function printTransitionHelp({ stream = process.stdout } = {}) {
676
678
  stream.write(lines.join('\n') + '\n');
677
679
  }
678
680
 
681
+ export function printAssignHelp({ stream = process.stdout } = {}) {
682
+ const s = createStyler({ isTTY: stream.isTTY });
683
+ const lines = [
684
+ '',
685
+ ` ${s.bold(s.brand('ticketlens'))} ${s.bold('assign')} ${s.dim('TICKET-KEY --to=me')} ${s.dim('[Pro]')}`,
686
+ '',
687
+ ` Assign a ticket to yourself directly in its tracker (Jira/GitHub/Linear). ${s.dim('[Pro]')}`,
688
+ ` Self-assign only for now — ${s.brand('--to')} must be ${s.brand('me')}. Assigning to someone else`,
689
+ ` isn't supported yet.`,
690
+ '',
691
+ ` ${s.bold('OPTIONS')}`,
692
+ '',
693
+ ` ${s.brand('--to')}=${s.dim('me')} Required — only "me" is currently supported`,
694
+ ` ${s.brand('--profile')}=${s.dim('NAME')} Connection profile to use ${s.dim('(optional)')}`,
695
+ ` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
696
+ '',
697
+ ` ${s.bold('EXAMPLES')}`,
698
+ '',
699
+ ` ${s.dim('$')} ticketlens assign PROD-123 --to=me`,
700
+ '',
701
+ ];
702
+ stream.write(lines.join('\n') + '\n');
703
+ }
704
+
679
705
  export function printSwitchHelp({ stream = process.stdout } = {}) {
680
706
  const s = createStyler({ isTTY: stream.isTTY });
681
707
  const lines = [
@@ -494,6 +494,36 @@ export async function postTransition(ticketKey, transitionId, opts = {}) {
494
494
  }
495
495
  }
496
496
 
497
+ /**
498
+ * Sets the issue's assignee. `assignee` is sent verbatim — the caller
499
+ * (jira-adapter.mjs) resolves the right shape for the API version:
500
+ * `{ accountId }` for Cloud (v3), `{ name }` for Server/DC (v2), since
501
+ * Cloud has no username concept and Server/DC has no accountId.
502
+ */
503
+ export async function assignIssue(ticketKey, assignee, opts = {}) {
504
+ const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000, allowPrivateIp = false } = opts;
505
+ validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
506
+ const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
507
+ const url = `${baseUrl}/rest/api/${apiVersion}/issue/${encodeURIComponent(ticketKey)}/assignee`;
508
+
509
+ const fetchOpts = {
510
+ method: 'PUT',
511
+ headers: { ...buildAuthHeader(env), 'Content-Type': 'application/json' },
512
+ body: JSON.stringify(assignee),
513
+ };
514
+ if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
515
+
516
+ const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
517
+ if (!response.ok) {
518
+ let details;
519
+ try { details = await response.json(); } catch { /* body not JSON — fall through with no details */ }
520
+ const err = new Error(`Jira API error ${response.status} assigning ${ticketKey}`);
521
+ err.status = response.status;
522
+ err.details = details;
523
+ throw err;
524
+ }
525
+ }
526
+
497
527
  export async function fetchTicket(ticketKey, opts = {}) {
498
528
  const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), depth = 1, apiVersion = 2, timeoutMs = 10_000, expandChangelog = false, allowPrivateIp = false, _visited = new Set(), _currentDepth = 0 } = opts;
499
529
  validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
@@ -23,7 +23,7 @@ import readline from 'node:readline';
23
23
  import { DEFAULT_CONFIG_DIR, getVersion } from './config.mjs';
24
24
  import { runNoteAdd } from './note-command.mjs';
25
25
  import { runRecall } from './recall-command.mjs';
26
- import { runTicketComment, runTicketTransitionList, runTicketTransition } from './ticket-command.mjs';
26
+ import { runTicketComment, runTicketTransitionList, runTicketTransition, runTicketAssign } from './ticket-command.mjs';
27
27
 
28
28
  const PROTOCOL_VERSION = '2025-11-25';
29
29
 
@@ -78,6 +78,18 @@ const TOOLS = [
78
78
  required: ['ticket'],
79
79
  },
80
80
  },
81
+ {
82
+ name: 'ticket_assign',
83
+ description: 'Assign a ticket to yourself in its tracker (Jira/GitHub/Linear). Self-assign only — assigning to someone else is not supported yet. Requires a TicketLens Pro license.',
84
+ inputSchema: {
85
+ type: 'object',
86
+ properties: {
87
+ ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
88
+ to: { type: 'string', description: 'Who to assign to — currently only "me" is accepted.' },
89
+ },
90
+ required: ['ticket', 'to'],
91
+ },
92
+ },
81
93
  ];
82
94
 
83
95
  function jsonRpcResult(id, result) {
@@ -184,16 +196,30 @@ async function callTicketTransition(args, { configDir, runTicketTransitionListFn
184
196
  return ok ? { content } : { isError: true, content };
185
197
  }
186
198
 
199
+ async function callTicketAssign(args, { configDir, runTicketAssignFn }) {
200
+ if (!args.ticket) {
201
+ return { isError: true, content: [{ type: 'text', text: 'Missing required argument: ticket' }] };
202
+ }
203
+ if (!args.to) {
204
+ return { isError: true, content: [{ type: 'text', text: 'Missing required argument: to' }] };
205
+ }
206
+ const capture = capturingStream();
207
+ const { ok } = await runTicketAssignFn([args.ticket, `--to=${args.to}`], { configDir, stream: capture });
208
+ const content = [{ type: 'text', text: capture.text }];
209
+ return ok ? { content } : { isError: true, content };
210
+ }
211
+
187
212
  async function handleToolsCall(params, deps) {
188
213
  const { name, arguments: args = {} } = params ?? {};
189
214
  if (name === 'recall_add') return callRecallAdd(args, deps);
190
215
  if (name === 'recall_search') return callRecallSearch(args, deps);
191
216
  if (name === 'ticket_comment') return callTicketComment(args, deps);
192
217
  if (name === 'ticket_transition') return callTicketTransition(args, deps);
218
+ if (name === 'ticket_assign') return callTicketAssign(args, deps);
193
219
  return { isError: true, content: [{ type: 'text', text: `Unknown tool: ${name}` }] };
194
220
  }
195
221
 
196
- async function handleMessage(raw, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn }) {
222
+ async function handleMessage(raw, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn }) {
197
223
  let msg;
198
224
  try {
199
225
  msg = JSON.parse(raw);
@@ -223,7 +249,7 @@ async function handleMessage(raw, { configDir, runNoteAddFn, runRecallFn, runTic
223
249
 
224
250
  if (method === 'tools/call') {
225
251
  try {
226
- const result = await handleToolsCall(params, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn });
252
+ const result = await handleToolsCall(params, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn });
227
253
  return jsonRpcResult(id, result);
228
254
  } catch (err) {
229
255
  return jsonRpcError(id ?? null, -32603, `Internal error: ${err.message}`);
@@ -249,6 +275,7 @@ export function runMcpServer({
249
275
  runTicketCommentFn = runTicketComment,
250
276
  runTicketTransitionListFn = runTicketTransitionList,
251
277
  runTicketTransitionFn = runTicketTransition,
278
+ runTicketAssignFn = runTicketAssign,
252
279
  } = {}) {
253
280
  // A client can disconnect mid-write (EPIPE) at any time on a long-lived
254
281
  // process — an unhandled 'error' event on either stream would otherwise
@@ -268,7 +295,7 @@ export function runMcpServer({
268
295
  // never resolving (a dropped rejection isn't a resolution) — the
269
296
  // server would hang on shutdown instead of exiting.
270
297
  queue = queue.then(async () => {
271
- const response = await handleMessage(line, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn });
298
+ const response = await handleMessage(line, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn });
272
299
  if (response) stdout.write(response);
273
300
  }).catch(() => {});
274
301
  });
@@ -19,7 +19,7 @@ export function detectTrackerType(baseUrl) {
19
19
  * Instantiates the correct tracker adapter for a resolved connection.
20
20
  * @param {{ baseUrl: string, auth?: string, email?: string, apiToken?: string, pat?: string }} conn
21
21
  * @param {{ fetcher?: Function }} [opts]
22
- * @returns {{ type: string, fetchTicket: Function, fetchCurrentUser: Function, searchTickets: Function, fetchStatuses: Function, addComment: Function, getTransitions: Function, transition: Function }}
22
+ * @returns {{ type: string, fetchTicket: Function, fetchCurrentUser: Function, searchTickets: Function, fetchStatuses: Function, addComment: Function, getTransitions: Function, transition: Function, assignToSelf: Function }}
23
23
  */
24
24
  export function resolveAdapter(conn, opts = {}) {
25
25
  const type = detectTrackerType(conn?.baseUrl);
@@ -234,3 +234,57 @@ export async function runTicketTransition(cmdArgs, {
234
234
  return { ok: false };
235
235
  }
236
236
  }
237
+
238
+ /**
239
+ * Self-assign only — `--to` currently only accepts the literal "me".
240
+ * Arbitrary-user assignment needs a per-tracker user-search step this
241
+ * codebase doesn't have yet; kept as an explicit, rejected value now so
242
+ * a future `--to=someone@else.com` doesn't silently redefine what a
243
+ * bare/missing --to means today.
244
+ *
245
+ * @param {string[]} cmdArgs - [ticketKey, '--to=me']
246
+ * @returns {Promise<{ ok: boolean }>}
247
+ */
248
+ export async function runTicketAssign(cmdArgs, {
249
+ configDir = DEFAULT_CONFIG_DIR,
250
+ stream = process.stderr,
251
+ isLicensedFn = isLicensed,
252
+ resolveConnectionFn = resolveConnection,
253
+ resolveAdapterFn = resolveAdapter,
254
+ checkCooldownFn = checkCooldown,
255
+ recordActionFn = recordAction,
256
+ logActionFn = logAction,
257
+ actor = os.userInfo().username,
258
+ } = {}) {
259
+ const usage = 'Usage: ticketlens assign TICKET-KEY --to=me\n';
260
+ if (!requireLicense(isLicensedFn, configDir, 'ticketlens assign', stream)) return { ok: false };
261
+
262
+ const ticketKey = requireTicketKey(cmdArgs, usage, stream);
263
+ if (!ticketKey) return { ok: false };
264
+
265
+ const to = parseFlag(cmdArgs, 'to');
266
+ if (to !== 'me') {
267
+ stream.write(to ? ` --to="${to}" is not yet supported — only --to=me (self-assign) is available.\n` : usage);
268
+ return { ok: false };
269
+ }
270
+
271
+ const cooldown = checkCooldownFn(ticketKey, 'assign', { configDir });
272
+ if (cooldown.active) {
273
+ stream.write(` Skipped — ${ticketKey} was already assigned ${Math.ceil(cooldown.remainingMs / 1000)}s ago. Wait a moment before retrying.\n`);
274
+ return { ok: false };
275
+ }
276
+
277
+ const adapter = resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
278
+ if (!adapter) return { ok: false };
279
+
280
+ try {
281
+ const result = await adapter.assignToSelf(ticketKey);
282
+ recordActionFn(ticketKey, 'assign', { configDir });
283
+ logActionFn({ ticketKey, action: 'assign', actor, tracker: adapter.type, detail: { assignee: result.assignee } }, { configDir });
284
+ stream.write(` ${ticketKey} assigned to ${result.assignee}.\n`);
285
+ return { ok: true };
286
+ } catch (err) {
287
+ stream.write(formatWriteFailure(ticketKey, err));
288
+ return { ok: false };
289
+ }
290
+ }