ticketlens 0.40.0 → 0.41.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
@@ -499,7 +499,7 @@ Write directly to the ticket in its real tracker — Jira, GitHub, or Linear —
499
499
 
500
500
  `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.
501
501
 
502
- `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.
502
+ `ticketlens assign --to=me` self-assigns immediately, no `--confirm` needed. Any other `--to` (a name or email) assigns to another developer — Jira Cloud only for now, GitHub/Linear/Server-DC refuse cleanly. It searches Jira's real assignable-user list first: without `--confirm` it only lists the matching candidate(s), never assigns; with `--confirm` it executes only if exactly one candidate still matches (0 or 2+ always refuses). Matches are cached locally per project for 3 days.
503
503
 
504
504
  `ticketlens worklog` (or `tl worklog`) logs time on Jira tickets, always as you.
505
505
 
@@ -1121,6 +1121,7 @@ npm test
1121
1121
  See [ROADMAP.md](ROADMAP.md) for the full plan.
1122
1122
 
1123
1123
  Recently shipped:
1124
+ - **`ticketlens assign --to="name or email"`** — assign a ticket to another developer, not just yourself. Jira Cloud only for now; searches real assignable users first, lists matches, executes only on one confirmed match. Pro tier
1124
1125
  - **Console: Recall "select all N matching" bulk delete** — Gmail-style banner deletes every note matching your search/filters across all pages, not just the current one
1125
1126
  - **Recall Stop-hook fix** — the end-of-session Recall reminder no longer fires because of file writes; only real ticket writes (comment, transition, assign, update) arm it. Also hardened against malformed transcripts and a session-id path-collision bug found by adversarial testing
1126
1127
  - **Console: no repeat request on same-page menu clicks** — sidebar links, the header gear and Settings tabs skip the request when they point at the page you are on
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.40.0",
3
+ "version": "0.41.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.45.0 -->
1
+ <!-- jtb-skill-version: 0.46.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.
@@ -436,7 +436,9 @@ ticketlens comment PROD-1234 --body="Fixed in a2f9c1, deployed to staging."
436
436
  ticketlens comment PROD-1234 --body="See screenshot" --attach=./bug.png # attach local files
437
437
  ticketlens transition PROD-1234 # list valid transitions — read-only
438
438
  ticketlens transition PROD-1234 --target="Done" --confirm # execute
439
- ticketlens assign PROD-1234 --to=me # assign to yourself
439
+ ticketlens assign PROD-1234 --to=me # assign to yourself — immediate, no --confirm
440
+ ticketlens assign PROD-1234 --to="Jane Dev" # search assignable users — read-only, lists match(es)
441
+ ticketlens assign PROD-1234 --to="Jane Dev" --confirm # execute — only if exactly one match (Jira Cloud only)
440
442
  ticketlens duplicates PROD-1234 # find likely duplicates — read-only
441
443
  ticketlens link PROD-1234 PROD-5678 # list valid link types — read-only
442
444
  ticketlens link PROD-1234 PROD-5678 --type="Duplicate" --confirm # execute the link
@@ -451,13 +453,13 @@ ticketlens worklog PROD-1234=1h30m PROD-5678=45m --comment="Sprint work" --confi
451
453
 
452
454
  `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.
453
455
 
454
- `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.
456
+ `assign` — `--to=me` self-assigns immediately, no `--confirm` needed. Any other `--to` (a name or email) assigns to another developer, Jira Cloud only for now — GitHub, Linear, and Jira Server/DC refuse cleanly rather than guess at unverified API behavior. It searches Jira's real assignable-user list first: called without `--confirm` it never assigns, only lists the matching candidate(s) (name + accountId, cached locally per project for 3 days); called again with the same `--to` and `--confirm`, it executes only if exactly one candidate still matches — 0 or 2+ matches always refuses, never guesses which person was meant. Never attempt a workaround (e.g. via `comment`) for a tracker `assign` refuses (GitHub/Linear/Server-DC) — tell the user it isn't supported yet.
455
457
 
456
458
  `duplicates` lists likely-duplicate tickets in the same project. On Jira, any ticket already linked as a "Duplicate" is always listed first — that's a confirmed relationship a human already recorded, not a heuristic. Everything else is ranked by local title/description overlap — no tracker scores similarity server-side, so treat those as a nudge for the user to check manually, never as a confirmed duplicate to act on unprompted (e.g. don't auto-close or auto-comment based on a match). `--threshold=N` (0–1, default 0.35) tightens or loosens what counts as a text-match — it has no effect on Jira-linked duplicates, which are always shown. An empty result is the same approximation in the other direction — the local scorer can miss a real duplicate too, so don't treat "no likely duplicates found" as proof none exist.
457
459
 
458
460
  `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). Called with just the two keys, it lists the tracker's current valid link types without changing anything — never guess `--type`; always list first, then use one of the names shown. GitHub has no generic link relationship, so linking on a GitHub-tracked ticket *closes SOURCE as a duplicate of TARGET* — a real state change, not just a relationship add — and prints an explicit warning immediately before that happens, on top of the same `--confirm` gate.
459
461
 
460
- `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 — these are reversible metadata edits, same risk tier as `assign`.
462
+ `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 — these are reversible metadata edits, same risk tier as self-assign.
461
463
 
462
464
  `create` makes a brand-new ticket — there's no existing ticket to target, so `--project` (Jira project key / Linear team key) and `--type` (Jira issue type, ignored elsewhere) pick the destination instead of a ticket key. This is the highest-blast-radius command in the family: a bad `--project`/`--type` fabricates a real, hard-to-walk-back item in a live tracker. No `--confirm` gate — double-check the values with the user before calling it, since an invalid value surfaces the tracker's own error rather than a silent guess.
463
465
 
@@ -1,4 +1,4 @@
1
- import { fetchTicket, fetchCurrentUser, searchTickets, fetchStatuses, fetchProjects, fetchIssueTypes, postComment, getTransitions, postTransition, assignIssue, escapeJql, getIssueLinkTypes, postIssueLink, updateIssue, createIssue, DEFAULT_SEARCH_FIELDS } from '../jira-client.mjs';
1
+ import { fetchTicket, fetchCurrentUser, searchTickets, fetchStatuses, fetchProjects, fetchIssueTypes, postComment, getTransitions, postTransition, assignIssue, fetchAssignableUsers, escapeJql, getIssueLinkTypes, postIssueLink, updateIssue, createIssue, DEFAULT_SEARCH_FIELDS } from '../jira-client.mjs';
2
2
  import { postWorklog } from '../jira-worklog-client.mjs';
3
3
  import { uploadAttachment, resolveMediaId } from '../jira-attachment-client.mjs';
4
4
  import { readAttachments } from '../attachment-uploader.mjs';
@@ -83,6 +83,31 @@ export function createJiraAdapter(conn, { fetcher = globalThis.fetch } = {}) {
83
83
  return { assignee: me.displayName ?? value };
84
84
  },
85
85
 
86
+ /**
87
+ * Resolves a free-text name/email into candidate assignable users —
88
+ * read-only, never assigns. Cloud only (`apiVersion === 3`): Jira
89
+ * Server/DC's equivalent endpoint semantics are unverified, so this
90
+ * refuses rather than guess at request/response shape (ROADMAP 61).
91
+ * Caching (3-day TTL, per project+query) is the caller's job — same
92
+ * split as `listIssueTypes`, which is also cache-agnostic here.
93
+ */
94
+ async searchAssignableUsers(key, query, opts = {}) {
95
+ if (apiVersion !== 3) {
96
+ throw new Error('Assigning to another developer needs Jira Cloud — Server/DC is not supported yet.');
97
+ }
98
+ const users = await fetchAssignableUsers(key, query, { ...base, ...opts });
99
+ return users.map(u => ({ accountId: u.accountId, displayName: u.displayName ?? u.name ?? u.accountId }));
100
+ },
101
+
102
+ /**
103
+ * Executes an assignment to a resolved accountId — always Cloud
104
+ * (`searchAssignableUsers` is the only path that produces one), so no
105
+ * apiVersion branch is needed here the way `assignToSelf` has.
106
+ */
107
+ async assignToUser(key, accountId, opts = {}) {
108
+ await assignIssue(key, { accountId }, { ...base, ...opts });
109
+ },
110
+
86
111
  /**
87
112
  * Candidate search for duplicate-ticket detection. Scoped to the same
88
113
  * project as `sourceKey` (derived from its own prefix) and excludes it
@@ -670,7 +670,9 @@ export function printMcpHelp({ stream = process.stdout } = {}) {
670
670
  ` ${s.cyan('issue_types')} is Free and Jira-only — Linear/GitHub profiles get a clear`,
671
671
  ` "not available" instead of an empty result — every other tool needs Pro.`,
672
672
  ` ${s.cyan('ticket_transition')} is destructive when called with`,
673
- ` \`target\`+\`confirm: true\`; ${s.cyan('ticket_assign')} is currently self-assign only;`,
673
+ ` \`target\`+\`confirm: true\`; ${s.cyan('ticket_assign')}'s \`to: "me"\` self-assigns immediately,`,
674
+ ` no confirm needed — assigning to another developer (Jira Cloud only) resolves`,
675
+ ` \`to\` first and needs \`confirm: true\` once it matches exactly one candidate;`,
674
676
  ` ${s.cyan('ticket_worklog')} is Jira-only, logs as you only, and writes nothing without`,
675
677
  ` \`confirm: true\` (it previews instead) — a worklog cannot be deleted through TicketLens;`,
676
678
  ` ${s.cyan('ticket_duplicates')} is read-only; ${s.cyan('ticket_link')} on GitHub closes the source issue as a`,
@@ -778,19 +780,26 @@ export function printAssignHelp({ stream = process.stdout } = {}) {
778
780
  '',
779
781
  ` ${s.bold(s.brand('ticketlens'))} ${s.bold('assign')} ${s.dim('TICKET-KEY --to=me')} ${s.dim('[Pro]')}`,
780
782
  '',
781
- ` Assign a ticket to yourself directly in its tracker (Jira/GitHub/Linear). ${s.dim('[Pro]')}`,
782
- ` Self-assign only for now — ${s.brand('--to')} must be ${s.brand('me')}. Assigning to someone else`,
783
- ` isn't supported yet.`,
783
+ ` Assign a ticket directly in its tracker (Jira/GitHub/Linear). ${s.dim('[Pro]')}`,
784
+ ` ${s.brand('--to=me')} self-assigns immediately, no --confirm needed. Any other`,
785
+ ` ${s.brand('--to')} (a name or email) assigns to another developer — Jira Cloud`,
786
+ ` only for now. Resolves the name/email against real assignable users first:`,
787
+ ` without --confirm it lists the match(es) instead of assigning; with`,
788
+ ` --confirm it executes only if exactly one candidate matched. Results are`,
789
+ ` cached locally per project for 3 days.`,
784
790
  '',
785
791
  ` ${s.bold('OPTIONS')}`,
786
792
  '',
787
- ` ${s.brand('--to')}=${s.dim('me')} Required — only "me" is currently supported`,
788
- ` ${s.brand('--profile')}=${s.dim('NAME')} Connection profile to use ${s.dim('(optional)')}`,
789
- ` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
793
+ ` ${s.brand('--to')}=${s.dim('me|"name or email"')} Required`,
794
+ ` ${s.brand('--confirm')} Execute an assign-to-other match ${s.dim('(not needed for --to=me)')}`,
795
+ ` ${s.brand('--profile')}=${s.dim('NAME')} Connection profile to use ${s.dim('(optional)')}`,
796
+ ` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
790
797
  '',
791
798
  ` ${s.bold('EXAMPLES')}`,
792
799
  '',
793
800
  ` ${s.dim('$')} ticketlens assign PROD-123 --to=me`,
801
+ ` ${s.dim('$')} ticketlens assign PROD-123 --to="Jane Dev"`,
802
+ ` ${s.dim('$')} ticketlens assign PROD-123 --to="Jane Dev" --confirm`,
794
803
  '',
795
804
  ];
796
805
  stream.write(lines.join('\n') + '\n');
@@ -613,6 +613,39 @@ export async function assignIssue(ticketKey, assignee, opts = {}) {
613
613
  }
614
614
  }
615
615
 
616
+ /**
617
+ * Searches for users assignable to a ticket — resolves a caller-typed
618
+ * name/email into a real accountId before assignIssue can use it.
619
+ *
620
+ * `query` is required by Jira's own API: `GET .../user/assignable/search`
621
+ * 400s unless `query` or `accountId` is given (verified against Atlassian's
622
+ * published spec — there is no "list everyone assignable" mode), so this
623
+ * throws early on an empty query rather than sending a request guaranteed
624
+ * to fail.
625
+ */
626
+ export async function fetchAssignableUsers(ticketKey, query, opts = {}) {
627
+ const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000, allowPrivateIp = false, maxResults = 20 } = opts;
628
+ if (!query) {
629
+ throw new Error('fetchAssignableUsers requires a non-empty query — Jira has no "list all assignable users" mode.');
630
+ }
631
+ validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
632
+ const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
633
+ const params = new URLSearchParams({ issueKey: ticketKey, query, maxResults: String(maxResults) });
634
+ const url = `${baseUrl}/rest/api/${apiVersion}/user/assignable/search?${params}`;
635
+
636
+ const fetchOpts = { headers: { ...buildAuthHeader(env), 'Content-Type': 'application/json' } };
637
+ if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
638
+
639
+ const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
640
+ if (!response.ok) {
641
+ const err = new Error(`Jira API error ${response.status} searching assignable users for ${ticketKey}`);
642
+ err.status = response.status;
643
+ throw err;
644
+ }
645
+ const raw = await response.json();
646
+ return raw.map(u => ({ accountId: u.accountId ?? null, name: u.name ?? null, displayName: u.displayName ?? null }));
647
+ }
648
+
616
649
  /**
617
650
  * Updates a narrow, named field set on an issue. `fields` (summary,
618
651
  * description, priority) uses plain SET semantics — the same shape Jira's
@@ -522,7 +522,9 @@ async function callTicketAssign(args, { configDir, runTicketAssignFn }) {
522
522
  return { isError: true, content: [{ type: 'text', text: 'Missing required argument: to' }] };
523
523
  }
524
524
  const capture = capturingStream();
525
- const { ok } = await runTicketAssignFn([args.ticket, `--to=${args.to}`], { configDir, stream: capture });
525
+ const cmdArgs = [args.ticket, `--to=${args.to}`];
526
+ if (args.confirm === true) cmdArgs.push('--confirm');
527
+ const { ok } = await runTicketAssignFn(cmdArgs, { configDir, stream: capture, cliHints: false });
526
528
  const content = [{ type: 'text', text: capture.text }];
527
529
  return ok ? { content } : { isError: true, content };
528
530
  }
@@ -239,12 +239,13 @@ export const TOOLS = [
239
239
  },
240
240
  {
241
241
  name: 'ticket_assign',
242
- 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.',
242
+ description: 'Assign a ticket in its tracker (Jira/GitHub/Linear). `to: "me"` self-assigns immediately, no confirm needed. Any other `to` (a name or email) assigns to another developer — Jira Cloud only for now; GitHub, Linear, and Jira Server/DC are refused. Resolves `to` against real assignable users before writing: called without `confirm: true`, it never assigns — it returns the matching candidate(s) (name + accountId) instead, so the exact person can be confirmed first. Called again with the same `to` and `confirm: true`, it executes only if `to` still resolves to exactly one candidate; 0 or 2+ matches always refuses, never guesses. Matches are cached locally per ticket\'s project for 3 days. Requires a TicketLens Pro license.',
243
243
  inputSchema: {
244
244
  type: 'object',
245
245
  properties: {
246
246
  ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
247
- to: { type: 'string', description: 'Who to assign to — currently only "me" is accepted.' },
247
+ to: { type: 'string', description: '"me" to self-assign, or a name/email to search for and assign to another developer (Jira Cloud only).' },
248
+ confirm: { type: 'boolean', description: 'Must be true, alongside a `to` that resolves to exactly one match, to actually execute an assign-to-other. Not needed for `to: "me"`.' },
248
249
  },
249
250
  required: ['ticket', 'to'],
250
251
  },
@@ -105,7 +105,17 @@ const HARD_REJECT_PATTERNS = [
105
105
  { name: 'GitHub token', re: /\bgh[pousr]_[A-Za-z0-9]{20,}\b/ },
106
106
  ];
107
107
 
108
- const EMAIL_RE = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/;
108
+ // Bounded quantifiers (RFC 5321-realistic: 64-char local part, 253-char
109
+ // domain, 24-char TLD — no real email exceeds these) are load-bearing, not
110
+ // cosmetic: an unbounded `+` here is a textbook O(n^2) ReDoS on any long
111
+ // token with no '@' — the unanchored regex tries every starting position,
112
+ // and at each one backtracks the whole remaining length one char at a time
113
+ // before giving up. Found 2026-09-23 via adversarial testing of 49e: a
114
+ // 500KB error message with no '@' hung the process for minutes. Affects
115
+ // every caller of scanForSecrets (note add/patch, Recall push, and now
116
+ // error-reporter.mjs) — fixed once here rather than length-capping each
117
+ // call site, since the regex itself is the actual bug.
118
+ const EMAIL_RE = /[a-zA-Z0-9._%+-]{1,64}@[a-zA-Z0-9.-]{1,253}\.[a-zA-Z]{2,24}/;
109
119
 
110
120
  // Shared between CODE_FILENAME_RE below and FILENAME_REFERENCE_RE further
111
121
  // down, the same way WHITESPACE_CLASS is shared across the whitespace
@@ -16,7 +16,7 @@ import { resolveConnection, findProfilesByPrefix } from './profile-resolver.mjs'
16
16
  import { resolveAdapter } from './resolve-adapter.mjs';
17
17
  import { checkCooldown, recordAction } from './ticket-action-cooldown.mjs';
18
18
  import { logAction } from './ticket-action-log.mjs';
19
- import { readMetadataCache, writeMetadataCache } from './ticket-metadata-cache.mjs';
19
+ import { readMetadataCache, writeMetadataCache, isFresh, SINGLE_PROJECT_TTL_MS, mergeAssignableUsers, normalizeAssigneeQuery } from './ticket-metadata-cache.mjs';
20
20
  import { detectProjectOrTypeError, enrichCreateFailure } from './ticket-create-enrichment.mjs';
21
21
  import { TICKET_KEY_PATTERN, normalizeTicketKey } from './cli.mjs';
22
22
  import { scoreCandidates } from './duplicate-scorer.mjs';
@@ -436,13 +436,69 @@ export async function runTicketTransition(cmdArgs, {
436
436
  }
437
437
 
438
438
  /**
439
- * Self-assign only — `--to` currently only accepts the literal "me".
440
- * Arbitrary-user assignment needs a per-tracker user-search step this
441
- * codebase doesn't have yet; kept as an explicit, rejected value now so
442
- * a future `--to=someone@else.com` doesn't silently redefine what a
443
- * bare/missing --to means today.
439
+ * Derives the project-key portion of a ticket key — used only to key the
440
+ * local assignable-users cache, never for validation. Jira's own API stays
441
+ * the source of truth for whether the ticket key is real; a ticket key
442
+ * this can't parse just means caching is skipped, not a hard failure.
443
+ */
444
+ function projectKeyFromTicket(ticketKey) {
445
+ const hyphenIndex = ticketKey.lastIndexOf('-');
446
+ return hyphenIndex > 0 ? ticketKey.slice(0, hyphenIndex) : null;
447
+ }
448
+
449
+ /**
450
+ * Resolves a free-text `--to` query into assignable-user candidates,
451
+ * checking the local cache (3-day TTL, per project+query — ROADMAP 61)
452
+ * before calling the adapter. Read-only: never assigns, never touches
453
+ * cooldown. Exported for direct unit testing, same convention as
454
+ * `resolveTicketAdapter`.
455
+ */
456
+ export async function resolveAssigneeCandidates(adapter, ticketKey, query, {
457
+ profileName,
458
+ configDir = DEFAULT_CONFIG_DIR,
459
+ readMetadataCacheFn = readMetadataCache,
460
+ writeMetadataCacheFn = writeMetadataCache,
461
+ } = {}) {
462
+ const projectKey = projectKeyFromTicket(ticketKey);
463
+ const cached = readMetadataCacheFn(profileName, configDir);
464
+
465
+ if (projectKey) {
466
+ const normalizedQuery = normalizeAssigneeQuery(query);
467
+ const cachedCandidates = cached?.assignableUsersByProject?.[projectKey]?.[normalizedQuery];
468
+ const cachedAt = cached?.assignableUsersFetchedAt?.[projectKey]?.[normalizedQuery];
469
+ if (cachedCandidates && isFresh(cachedAt, SINGLE_PROJECT_TTL_MS)) {
470
+ return cachedCandidates;
471
+ }
472
+ }
473
+
474
+ const candidates = await adapter.searchAssignableUsers(ticketKey, query);
475
+
476
+ if (projectKey) {
477
+ const { assignableUsersByProject, assignableUsersFetchedAt } = mergeAssignableUsers(cached, projectKey, query, candidates);
478
+ writeMetadataCacheFn(profileName, {
479
+ projects: cached?.projects ?? [],
480
+ issueTypesByProject: cached?.issueTypesByProject ?? {},
481
+ issueTypesFetchedAt: cached?.issueTypesFetchedAt ?? {},
482
+ projectsFetchedAt: cached?.projectsFetchedAt ?? null,
483
+ assignableUsersByProject,
484
+ assignableUsersFetchedAt,
485
+ }, configDir);
486
+ }
487
+
488
+ return candidates;
489
+ }
490
+
491
+ /**
492
+ * `--to=me` self-assigns immediately — unchanged fast path, no discovery,
493
+ * no confirm. Any other `--to` resolves a real person first (ROADMAP 61):
494
+ * Jira Cloud only (Server/DC's assignable-user search is unverified —
495
+ * refuses cleanly rather than guessing), and only executes when exactly
496
+ * one candidate matches AND --confirm is given, mirroring `transition`'s
497
+ * list-then-confirm shape — notifying a colleague deserves the same
498
+ * reviewed-before-write gate as a workflow-state change.
444
499
  *
445
- * @param {string[]} cmdArgs - [ticketKey, '--to=me']
500
+ * @param {string[]} cmdArgs - [ticketKey, '--to=me'] or [ticketKey, '--to=...', '--confirm']
501
+ * @param {boolean} [cliHints] - see runTicketTransitionList's cliHints doc
446
502
  * @returns {Promise<{ ok: boolean }>}
447
503
  */
448
504
  export async function runTicketAssign(cmdArgs, {
@@ -454,17 +510,89 @@ export async function runTicketAssign(cmdArgs, {
454
510
  checkCooldownFn = checkCooldown,
455
511
  recordActionFn = recordAction,
456
512
  logActionFn = logAction,
513
+ readMetadataCacheFn = readMetadataCache,
514
+ writeMetadataCacheFn = writeMetadataCache,
457
515
  actor = os.userInfo().username,
516
+ cliHints = true,
458
517
  } = {}) {
459
- const usage = 'Usage: ticketlens assign TICKET-KEY --to=me\n';
518
+ const usage = cliHints
519
+ ? 'Usage: ticketlens assign TICKET-KEY --to=me | --to="name or email" --confirm\n'
520
+ : 'Usage: assign requires ticket and to; a to other than "me" also needs confirm: true to execute.\n';
460
521
  if (!requireLicense(isLicensedFn, configDir, 'ticketlens assign', stream)) return { ok: false };
461
522
 
462
523
  const ticketKey = requireTicketKey(cmdArgs, usage, stream);
463
524
  if (!ticketKey) return { ok: false };
464
525
 
465
526
  const to = parseFlag(cmdArgs, 'to');
466
- if (to !== 'me') {
467
- stream.write(to ? ` --to="${to}" is not yet supported — only --to=me (self-assign) is available.\n` : usage);
527
+ if (!to) {
528
+ stream.write(usage);
529
+ return { ok: false };
530
+ }
531
+
532
+ if (to === 'me') {
533
+ const cooldown = checkCooldownFn(ticketKey, 'assign', { configDir });
534
+ if (cooldown.active) {
535
+ stream.write(` Skipped — ${ticketKey} was already assigned ${Math.ceil(cooldown.remainingMs / 1000)}s ago. Wait a moment before retrying.\n`);
536
+ return { ok: false };
537
+ }
538
+
539
+ const resolved = resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
540
+ if (!resolved) return { ok: false };
541
+ const { adapter } = resolved;
542
+
543
+ try {
544
+ const result = await adapter.assignToSelf(ticketKey);
545
+ recordActionFn(ticketKey, 'assign', { configDir });
546
+ logActionFn({ ticketKey, action: 'assign', actor, tracker: adapter.type, detail: { assignee: result.assignee } }, { configDir });
547
+ const s = createStyler({ isTTY: stream.isTTY });
548
+ stream.write(` ${s.green('✔')} ${s.brand(s.bold(ticketKey))} assigned to ${s.bold(result.assignee)}.\n`);
549
+ return { ok: true };
550
+ } catch (err) {
551
+ stream.write(formatWriteFailure(ticketKey, err));
552
+ return { ok: false };
553
+ }
554
+ }
555
+
556
+ const resolved = resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
557
+ if (!resolved) return { ok: false };
558
+ const { adapter, conn } = resolved;
559
+
560
+ if (adapter.type !== 'jira') {
561
+ stream.write(` Assigning to another developer is Jira-only right now — ${adapter.type} is not supported.\n`);
562
+ return { ok: false };
563
+ }
564
+ if (conn.auth !== 'cloud') {
565
+ stream.write(' Assigning to another developer needs Jira Cloud — Server/DC is not supported yet.\n');
566
+ return { ok: false };
567
+ }
568
+
569
+ const s = createStyler({ isTTY: stream.isTTY });
570
+ let candidates;
571
+ try {
572
+ candidates = await resolveAssigneeCandidates(adapter, ticketKey, to, { profileName: conn.profileName, configDir, readMetadataCacheFn, writeMetadataCacheFn });
573
+ } catch (err) {
574
+ stream.write(formatWriteFailure(ticketKey, err));
575
+ return { ok: false };
576
+ }
577
+
578
+ if (candidates.length === 0) {
579
+ stream.write(` No assignable user found matching "${to}" for ${s.brand(s.bold(ticketKey))}.\n`);
580
+ return { ok: false };
581
+ }
582
+
583
+ if (candidates.length > 1) {
584
+ stream.write(` ${candidates.length} users match "${to}" — narrow the query:\n\n`);
585
+ for (const c of candidates) stream.write(` ${s.brand('●')} ${c.displayName} ${s.dim(c.accountId)}\n`);
586
+ return { ok: false };
587
+ }
588
+
589
+ const [candidate] = candidates;
590
+
591
+ if (!cmdArgs.includes('--confirm')) {
592
+ stream.write(` Match: ${s.bold(candidate.displayName)} ${s.dim(candidate.accountId)}\n`);
593
+ stream.write(cliHints
594
+ ? `\n Run again with --to="${to}" --confirm to execute.\n`
595
+ : `\n Call again with to="${to}" and confirm: true to execute.\n`);
468
596
  return { ok: false };
469
597
  }
470
598
 
@@ -474,16 +602,11 @@ export async function runTicketAssign(cmdArgs, {
474
602
  return { ok: false };
475
603
  }
476
604
 
477
- const resolved = resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
478
- if (!resolved) return { ok: false };
479
- const { adapter } = resolved;
480
-
481
605
  try {
482
- const result = await adapter.assignToSelf(ticketKey);
606
+ await adapter.assignToUser(ticketKey, candidate.accountId);
483
607
  recordActionFn(ticketKey, 'assign', { configDir });
484
- logActionFn({ ticketKey, action: 'assign', actor, tracker: adapter.type, detail: { assignee: result.assignee } }, { configDir });
485
- const s = createStyler({ isTTY: stream.isTTY });
486
- stream.write(` ${s.green('✔')} ${s.brand(s.bold(ticketKey))} assigned to ${s.bold(result.assignee)}.\n`);
608
+ logActionFn({ ticketKey, action: 'assign', actor, tracker: adapter.type, detail: { assignee: candidate.displayName, accountId: candidate.accountId } }, { configDir });
609
+ stream.write(` ${s.green('✔')} ${s.brand(s.bold(ticketKey))} assigned to ${s.bold(candidate.displayName)}.\n`);
487
610
  return { ok: true };
488
611
  } catch (err) {
489
612
  stream.write(formatWriteFailure(ticketKey, err));
@@ -21,7 +21,17 @@
21
21
  * projects: [{key, name}],
22
22
  * issueTypesByProject: {KEY: [{id, name}]},
23
23
  * issueTypesFetchedAt: {KEY: iso timestamp} // per-project, either access pattern
24
+ * assignableUsersByProject: {KEY: {normalizedQuery: [{accountId, displayName}]}},
25
+ * assignableUsersFetchedAt: {KEY: {normalizedQuery: iso timestamp}}
24
26
  * }
27
+ *
28
+ * assignableUsersByProject/assignableUsersFetchedAt (ROADMAP 61) share this
29
+ * file rather than a new one — same profile-scoped Jira-lookup cache, same
30
+ * 3-day freshness bar as a single-project issue-types lookup. Nested one
31
+ * level deeper than issueTypesByProject because Jira's own
32
+ * `user/assignable/search` has no "list everyone" mode (query is required
33
+ * unless accountId is given) — there is no per-project roster to cache,
34
+ * only per-(project, query) result pages, each with its own clock.
25
35
  */
26
36
 
27
37
  import fs from 'node:fs';
@@ -62,6 +72,39 @@ export function mergeProjectIssueTypes(cached, projectKey, types, fetchedAt = ne
62
72
  return { issueTypesByProject, issueTypesFetchedAt };
63
73
  }
64
74
 
75
+ /**
76
+ * Trim + lowercase — the one normalization every assignable-users cache
77
+ * read and write must agree on, so "Jane", " jane ", and "JANE" share one
78
+ * cache entry instead of three.
79
+ */
80
+ export function normalizeAssigneeQuery(query) {
81
+ return String(query).trim().toLowerCase();
82
+ }
83
+
84
+ /**
85
+ * Merges one (project, query) assignable-users search result into an
86
+ * existing (possibly null) cached map — same shape and same
87
+ * Object.create(null) defense as mergeProjectIssueTypes, one level deeper
88
+ * since a project can have many independently-fresh cached queries. Both
89
+ * projectKey and the normalized query are unvalidated values reaching a
90
+ * key position.
91
+ */
92
+ export function mergeAssignableUsers(cached, projectKey, query, candidates, fetchedAt = new Date().toISOString()) {
93
+ const normalizedQuery = normalizeAssigneeQuery(query);
94
+
95
+ const assignableUsersByProject = Object.assign(Object.create(null), cached?.assignableUsersByProject ?? {});
96
+ const projectQueries = Object.assign(Object.create(null), assignableUsersByProject[projectKey] ?? {});
97
+ projectQueries[normalizedQuery] = candidates;
98
+ assignableUsersByProject[projectKey] = projectQueries;
99
+
100
+ const assignableUsersFetchedAt = Object.assign(Object.create(null), cached?.assignableUsersFetchedAt ?? {});
101
+ const projectQueryTimestamps = Object.assign(Object.create(null), assignableUsersFetchedAt[projectKey] ?? {});
102
+ projectQueryTimestamps[normalizedQuery] = fetchedAt;
103
+ assignableUsersFetchedAt[projectKey] = projectQueryTimestamps;
104
+
105
+ return { assignableUsersByProject, assignableUsersFetchedAt };
106
+ }
107
+
65
108
  /**
66
109
  * Returns the absolute path to the ticket-metadata cache file for a profile.
67
110
  */
@@ -84,7 +127,7 @@ export function metadataCachePath(profileName, configDir = DEFAULT_CONFIG_DIR) {
84
127
  * @param {string|null} profileName
85
128
  * @param {string} [configDir]
86
129
  * @param {number} [ttlMs] - override TTL in ms for this file's own GC deletion; defaults to METADATA_TTL_MS (7d)
87
- * @returns {{ projects: {key:string,name:string}[], issueTypesByProject: object, issueTypesFetchedAt: object, projectsFetchedAt: string|null, fetchedAt: string } | null}
130
+ * @returns {{ projects: {key:string,name:string}[], issueTypesByProject: object, issueTypesFetchedAt: object, assignableUsersByProject: object, assignableUsersFetchedAt: object, projectsFetchedAt: string|null, fetchedAt: string } | null}
88
131
  */
89
132
  export function readMetadataCache(profileName, configDir = DEFAULT_CONFIG_DIR, ttlMs = METADATA_TTL_MS) {
90
133
  const filePath = metadataCachePath(profileName, configDir);
@@ -107,6 +150,8 @@ export function readMetadataCache(profileName, configDir = DEFAULT_CONFIG_DIR, t
107
150
  projects: data.projects ?? [],
108
151
  issueTypesByProject: data.issueTypesByProject ?? {},
109
152
  issueTypesFetchedAt: data.issueTypesFetchedAt ?? {},
153
+ assignableUsersByProject: data.assignableUsersByProject ?? {},
154
+ assignableUsersFetchedAt: data.assignableUsersFetchedAt ?? {},
110
155
  projectsFetchedAt: data.projectsFetchedAt ?? null,
111
156
  fetchedAt: data.fetchedAt,
112
157
  };
@@ -119,7 +164,7 @@ export function readMetadataCache(profileName, configDir = DEFAULT_CONFIG_DIR, t
119
164
  * updates (e.g. a single-project fetch merging into an existing
120
165
  * multi-project cache) — this function persists exactly what it's given.
121
166
  */
122
- export function writeMetadataCache(profileName, { projects = [], issueTypesByProject = {}, issueTypesFetchedAt = {}, projectsFetchedAt = null } = {}, configDir = DEFAULT_CONFIG_DIR) {
167
+ export function writeMetadataCache(profileName, { projects = [], issueTypesByProject = {}, issueTypesFetchedAt = {}, assignableUsersByProject = {}, assignableUsersFetchedAt = {}, projectsFetchedAt = null } = {}, configDir = DEFAULT_CONFIG_DIR) {
123
168
  const filePath = metadataCachePath(profileName, configDir);
124
169
  try {
125
170
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
@@ -129,6 +174,8 @@ export function writeMetadataCache(profileName, { projects = [], issueTypesByPro
129
174
  projects,
130
175
  issueTypesByProject,
131
176
  issueTypesFetchedAt,
177
+ assignableUsersByProject,
178
+ assignableUsersFetchedAt,
132
179
  }));
133
180
  } catch {
134
181
  // Non-fatal