ticketlens 0.40.1 → 0.42.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.1",
3
+ "version": "0.42.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, fetchProjectPriorities, 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
@@ -186,6 +211,34 @@ export function createJiraAdapter(conn, { fetcher = globalThis.fetch } = {}) {
186
211
  /** Real, currently-configured issue types for one project. */
187
212
  listIssueTypes: (projectKey, opts = {}) => fetchIssueTypes(projectKey, { ...base, ...opts }),
188
213
 
214
+ /**
215
+ * Real, currently-configured priority options for one project — used
216
+ * only to enrich an `update` failure message, never to pre-validate
217
+ * (see fetchProjectPriorities' own doc for why). Priority has no
218
+ * dedicated list-per-project endpoint, so this walks the project's
219
+ * issue types until one returns a non-empty priority list. NOT every
220
+ * issue type has one: confirmed live against corenexus that a
221
+ * team-managed project's Epic type has no `priority` field in its
222
+ * field metadata at all (real fields returned: assignee, description,
223
+ * labels, ... — priority absent), while its Task type does. An
224
+ * earlier version picked types[0] unconditionally and silently
225
+ * returned [] whenever that happened to be an Epic — caught by a live
226
+ * CLI run against CNV1-34, not by any unit test (every unit test used
227
+ * a hand-picked type). Stops at the first success rather than trying
228
+ * every type, since priority is otherwise scheme-shared across a
229
+ * project's types by Jira's default. Returns [] only once every type
230
+ * has been tried — this can only ever make an error message less
231
+ * informative, never a new way for `update` to fail.
232
+ */
233
+ async listPriorities(projectKey, opts = {}) {
234
+ const types = await fetchIssueTypes(projectKey, { ...base, ...opts });
235
+ for (const type of types) {
236
+ const priorities = await fetchProjectPriorities(projectKey, type.id, { ...base, ...opts });
237
+ if (priorities.length) return priorities;
238
+ }
239
+ return [];
240
+ },
241
+
189
242
  /**
190
243
  * Best-effort, per-file: one bad path or one failed upload never blocks
191
244
  * the rest (same `{applied/uploaded, errors}` shape convention as
@@ -1,6 +1,7 @@
1
1
  import { tokenize } from '../duplicate-scorer.mjs';
2
2
  import { readAttachments } from '../attachment-uploader.mjs';
3
3
  import { isSafeRedirectUrl, validateResolvedHost, defaultLookupFor } from '../jira-client.mjs';
4
+ import { SINGLE_PROJECT_TTL_MS, isFresh, mergeTeamLabels } from '../ticket-metadata-cache.mjs';
4
5
 
5
6
  const LINEAR_API = 'https://api.linear.app/graphql';
6
7
 
@@ -103,6 +104,22 @@ function resolveLabelNames(names, byName) {
103
104
  return { resolved, missing };
104
105
  }
105
106
 
107
+ /**
108
+ * Real, currently-configured labels for one team — extracted from
109
+ * updateFields' own inline query so the cache read-through wrapper around
110
+ * it (see updateFields below) and this raw fetch stay independently
111
+ * testable. Returns {id, name} to match ticket-metadata-cache.mjs's
112
+ * documented labelsByTeam shape exactly.
113
+ */
114
+ async function fetchTeamLabels(teamId, { token, fetcher, signal }) {
115
+ const data = await gql(
116
+ `query ($teamId: ID!) { issueLabels(filter: { team: { id: { eq: $teamId } } }, first: 250) { nodes { id name } } }`,
117
+ { teamId },
118
+ { token, fetcher, signal },
119
+ );
120
+ return (data.issueLabels?.nodes ?? []).map(l => ({ id: l.id, name: l.name }));
121
+ }
122
+
106
123
  async function fetchTeamWorkflowStates(teamId, { token, fetcher, signal }) {
107
124
  const data = await gql(
108
125
  `query ($teamId: ID!) {
@@ -393,12 +410,28 @@ export function createLinearAdapter(conn, { fetcher = globalThis.fetch } = {}) {
393
410
 
394
411
  let addLabelsResolved, addLabelsMissing, removeLabelsResolved, removeLabelsMissing;
395
412
  if (addLabels?.length || removeLabels?.length) {
396
- const labelData = await gql(
397
- `query ($teamId: ID!) { issueLabels(filter: { team: { id: { eq: $teamId } } }, first: 250) { nodes { id name } } }`,
398
- { teamId: info.team.id },
399
- { token, fetcher, signal },
400
- );
401
- const byName = new Map((labelData.issueLabels?.nodes ?? []).map(l => [l.name.toLowerCase(), l.id]));
413
+ const teamId = info.team.id;
414
+ const { readMetadataCacheFn, writeMetadataCacheFn, profileName, configDir } = opts;
415
+ const cached = readMetadataCacheFn ? readMetadataCacheFn(profileName, configDir) : null;
416
+ // Keyed on the timestamp existing, not on the cached array having
417
+ // entries — a team with genuinely zero labels configured caches
418
+ // `[]`, and gating on `.length` would re-fetch on every single
419
+ // subsequent call, never respecting SINGLE_PROJECT_TTL_MS. Same
420
+ // fix as ticket-update-enrichment.mjs's prioritiesByProject check.
421
+ const hasFreshLabels = cached?.labelsByTeam?.[teamId] !== undefined
422
+ && isFresh(cached?.labelsFetchedAt?.[teamId], SINGLE_PROJECT_TTL_MS);
423
+
424
+ let labels;
425
+ if (hasFreshLabels) {
426
+ labels = cached.labelsByTeam[teamId];
427
+ } else {
428
+ labels = await fetchTeamLabels(teamId, { token, fetcher, signal });
429
+ if (writeMetadataCacheFn) {
430
+ const { labelsByTeam, labelsFetchedAt } = mergeTeamLabels(cached, teamId, labels);
431
+ writeMetadataCacheFn(profileName, { ...cached, labelsByTeam, labelsFetchedAt }, configDir);
432
+ }
433
+ }
434
+ const byName = new Map(labels.map(l => [l.name.toLowerCase(), l.id]));
402
435
 
403
436
  if (addLabels?.length) {
404
437
  ({ resolved: addLabelsResolved, missing: addLabelsMissing } = resolveLabelNames(addLabels, byName));
@@ -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
@@ -705,6 +738,46 @@ export async function fetchIssueTypes(projectKey, opts = {}) {
705
738
  return values.map(t => ({ id: t.id, name: t.name }));
706
739
  }
707
740
 
741
+ /**
742
+ * Discovers the priority scheme actually configured for a project — used
743
+ * only to enrich an `update` failure message with real, current options,
744
+ * never as a client-side pre-validation step (same design choice as
745
+ * fetchIssueTypes' own doc comment). Confirmed live against a Cloud
746
+ * instance (corenexus) and a Server/DC instance (advent) that a project's
747
+ * priority list lives on this per-issuetype field-metadata sub-resource —
748
+ * there is no separate global-priority-list call this codebase already
749
+ * makes. Same v2/v3 envelope split as fetchIssueTypes' own top-level list
750
+ * call: v3 nests the field array under `fields`, v2 under `values` (a
751
+ * paginated-list envelope, maxResults/startAt/total/isLast). An earlier
752
+ * version of this function assumed the two were identical here — an
753
+ * artifact of an exploratory probe script that used a `??` fallback
754
+ * across both keys, which happened to mask the real difference. Caught
755
+ * live against advent (v2): a real Bug-type priority list came back
756
+ * empty under `fields`, present under `values`.
757
+ */
758
+ export async function fetchProjectPriorities(projectKey, issueTypeId, opts = {}) {
759
+ const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000, allowPrivateIp = false } = opts;
760
+ validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
761
+ const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
762
+ const headers = { ...buildAuthHeader(env), 'Content-Type': 'application/json' };
763
+
764
+ const url = `${baseUrl}/rest/api/${apiVersion}/issue/createmeta/${encodeURIComponent(projectKey)}/issuetypes/${encodeURIComponent(issueTypeId)}`;
765
+ const fetchOpts = { headers };
766
+ if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
767
+ const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
768
+
769
+ if (!response.ok) {
770
+ const err = new Error(`Jira API error ${response.status} fetching priorities for ${projectKey}`);
771
+ err.status = response.status;
772
+ throw err;
773
+ }
774
+
775
+ const raw = await response.json();
776
+ const fieldList = apiVersion >= 3 ? (raw.fields ?? []) : (raw.values ?? []);
777
+ const priorityField = fieldList.find(f => f.fieldId === 'priority');
778
+ return (priorityField?.allowedValues ?? []).map(p => ({ id: p.id, name: p.name }));
779
+ }
780
+
708
781
  /**
709
782
  * Creates a new issue. `project`/`type` are passed straight through as
710
783
  * Jira's own {key}/{name} references — issue types are project-configurable,
@@ -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
  },
@@ -14,10 +14,11 @@ import { DEFAULT_CONFIG_DIR } from './config.mjs';
14
14
  import { isLicensed, showUpgradePrompt } from './license.mjs';
15
15
  import { resolveConnection, findProfilesByPrefix } from './profile-resolver.mjs';
16
16
  import { resolveAdapter } from './resolve-adapter.mjs';
17
- import { checkCooldown, recordAction } from './ticket-action-cooldown.mjs';
17
+ import { claimAction, releaseAction } 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
+ import { enrichUpdateFailure } from './ticket-update-enrichment.mjs';
21
22
  import { TICKET_KEY_PATTERN, normalizeTicketKey } from './cli.mjs';
22
23
  import { scoreCandidates } from './duplicate-scorer.mjs';
23
24
  import { MAX_ATTACHMENTS } from './attachment-uploader.mjs';
@@ -32,6 +33,45 @@ function parseAttachPaths(cmdArgs) {
32
33
  return raw ? raw.split(',').map(p => p.trim()).filter(Boolean) : [];
33
34
  }
34
35
 
36
+ /**
37
+ * Best-effort release of a claimAction cooldown claim (Backlog #34/ROADMAP
38
+ * 57) — releaseAction can itself throw (lock contention, same as any
39
+ * withLock caller in ticket-action-cooldown.mjs). A release is always
40
+ * called while already reporting some other outcome (a write failure, a
41
+ * refusal, an executed:false result); letting a lock-contention error
42
+ * replace that outcome would be strictly worse than just leaving the claim
43
+ * in place a few seconds longer — it expires on its own. Same "best
44
+ * effort — the claim expires by itself" reasoning already applied to
45
+ * ticket-worklog.mjs's own releaseClaim helper, DRY'd here across every
46
+ * write in this family instead of duplicated per function.
47
+ */
48
+ export function safeRelease(releaseActionFn, ticketKey, action, configDir) {
49
+ try { releaseActionFn(ticketKey, action, { configDir }); } catch { /* best effort — the claim expires by itself */ }
50
+ }
51
+
52
+ /**
53
+ * claimAction can itself throw — lock contention on the same 2s deadline
54
+ * as any other withLock caller in ticket-action-cooldown.mjs. Left
55
+ * unguarded, that throw would fall through to the outer CLI/MCP error
56
+ * handler and be misreported as a real failure (and could trip the
57
+ * opt-in error-reporting pipeline) for what is actually correct, benign
58
+ * contention — the write is still safely refused either way, but the
59
+ * graceful "Skipped" UX this whole family is built around would be lost.
60
+ * Same guard ticket-worklog.mjs's own claimOrSkip already has; returns a
61
+ * `lockError` string instead of a boolean flag so each call site can
62
+ * fold it into its existing skip-message wording without a second
63
+ * lookup. Caught in code review before shipping — not exercised by the
64
+ * original 4-way live concurrency trial, which stayed under the 2s
65
+ * deadline.
66
+ */
67
+ export function safeClaim(claimActionFn, ticketKey, action, configDir) {
68
+ try {
69
+ return claimActionFn(ticketKey, action, { configDir });
70
+ } catch (err) {
71
+ return { claimed: false, remainingMs: 0, lockError: err.message };
72
+ }
73
+ }
74
+
35
75
  /**
36
76
  * GitHub has no PAT-compatible public API for uploading issue/comment
37
77
  * assets (confirmed via research — the only upload endpoint requires a
@@ -263,8 +303,8 @@ export async function runTicketComment(cmdArgs, {
263
303
  isLicensedFn = isLicensed,
264
304
  resolveConnectionFn = resolveConnection,
265
305
  resolveAdapterFn = resolveAdapter,
266
- checkCooldownFn = checkCooldown,
267
- recordActionFn = recordAction,
306
+ claimActionFn = claimAction,
307
+ releaseActionFn = releaseAction,
268
308
  logActionFn = logAction,
269
309
  actor = os.userInfo().username,
270
310
  } = {}) {
@@ -281,32 +321,36 @@ export async function runTicketComment(cmdArgs, {
281
321
  }
282
322
  const attachPaths = parseAttachPaths(cmdArgs);
283
323
 
284
- const cooldown = checkCooldownFn(ticketKey, 'comment', { configDir });
285
- if (cooldown.active) {
286
- stream.write(` Skipped — a comment was already posted to ${ticketKey} ${Math.ceil(cooldown.remainingMs / 1000)}s ago. Wait a moment before retrying.\n`);
324
+ const claim = safeClaim(claimActionFn, ticketKey, 'comment', configDir);
325
+ if (!claim.claimed) {
326
+ stream.write(claim.lockError
327
+ ? ` ${ticketKey} not commented — could not take the cooldown lock (${claim.lockError}). Nothing was sent; safe to retry.\n`
328
+ : ` Skipped — a comment was already posted to ${ticketKey} ${Math.ceil(claim.remainingMs / 1000)}s ago. Wait a moment before retrying.\n`);
287
329
  return { ok: false };
288
330
  }
289
331
 
290
332
  const resolved = resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
291
- if (!resolved) return { ok: false };
333
+ if (!resolved) { safeRelease(releaseActionFn, ticketKey, 'comment', configDir); return { ok: false }; }
292
334
  const { adapter } = resolved;
293
335
  const s = createStyler({ isTTY: stream.isTTY });
294
336
 
295
337
  // Uploaded BEFORE the comment write so a tracker capable of inline
296
338
  // rendering (Jira Server/DC via wiki markup, Jira Cloud via a real ADF
297
339
  // media node, Linear via Markdown) can fold it into the same atomic
298
- // comment post rather than needing a second edit call.
340
+ // comment post rather than needing a second edit call. Inside the same
341
+ // try/catch as addComment — every adapter's attachFiles is documented to
342
+ // never throw (per-file errors are caught internally), but that is an
343
+ // implicit contract, not something to leave an un-released claim on if
344
+ // it were ever violated (caught in code review before shipping).
299
345
  let attachResult = null;
300
- if (attachPaths.length && !refuseGithubAttachments(adapter, attachPaths, stream)) {
301
- attachResult = await adapter.attachFiles(ticketKey, attachPaths);
302
- }
303
- const inlineSnippets = (attachResult?.uploaded ?? []).filter(a => a.inlineMarkup).map(a => a.inlineMarkup).join('\n\n');
304
- const finalBody = inlineSnippets ? `${body}\n\n${inlineSnippets}` : body;
305
- const extraAdfNodes = (attachResult?.uploaded ?? []).filter(a => a.adfMediaNode).map(a => a.adfMediaNode);
306
-
307
346
  try {
347
+ if (attachPaths.length && !refuseGithubAttachments(adapter, attachPaths, stream)) {
348
+ attachResult = await adapter.attachFiles(ticketKey, attachPaths);
349
+ }
350
+ const inlineSnippets = (attachResult?.uploaded ?? []).filter(a => a.inlineMarkup).map(a => a.inlineMarkup).join('\n\n');
351
+ const finalBody = inlineSnippets ? `${body}\n\n${inlineSnippets}` : body;
352
+ const extraAdfNodes = (attachResult?.uploaded ?? []).filter(a => a.adfMediaNode).map(a => a.adfMediaNode);
308
353
  const result = await adapter.addComment(ticketKey, finalBody, extraAdfNodes.length ? { extraAdfNodes } : {});
309
- recordActionFn(ticketKey, 'comment', { configDir });
310
354
  // attachPaths (every path attempted, raw) plus attachedFilenames (what
311
355
  // actually landed) — a partial attach failure is reconstructable from
312
356
  // the difference between the two, not just silently absent from audit.
@@ -314,6 +358,7 @@ export async function runTicketComment(cmdArgs, {
314
358
  stream.write(` ${s.green('✔')} Comment posted to ${s.brand(s.bold(ticketKey))}${result.url ? ` (${result.url})` : ''}\n` + formatAttachSummary(attachResult, s));
315
359
  return { ok: true };
316
360
  } catch (err) {
361
+ safeRelease(releaseActionFn, ticketKey, 'comment', configDir);
317
362
  // Attachments (if any) genuinely landed on the tracker before this
318
363
  // write was attempted — formatAttachSummary is still shown here so a
319
364
  // caller retrying the whole command doesn't blindly re-upload them.
@@ -383,8 +428,8 @@ export async function runTicketTransition(cmdArgs, {
383
428
  isLicensedFn = isLicensed,
384
429
  resolveConnectionFn = resolveConnection,
385
430
  resolveAdapterFn = resolveAdapter,
386
- checkCooldownFn = checkCooldown,
387
- recordActionFn = recordAction,
431
+ claimActionFn = claimAction,
432
+ releaseActionFn = releaseAction,
388
433
  logActionFn = logAction,
389
434
  actor = os.userInfo().username,
390
435
  cliHints = true,
@@ -407,42 +452,101 @@ export async function runTicketTransition(cmdArgs, {
407
452
  return { ok: false };
408
453
  }
409
454
 
410
- const cooldown = checkCooldownFn(ticketKey, 'transition', { configDir });
411
- if (cooldown.active) {
412
- stream.write(` Skipped — ${ticketKey} was already transitioned ${Math.ceil(cooldown.remainingMs / 1000)}s ago. Wait a moment before retrying.\n`);
455
+ const claim = safeClaim(claimActionFn, ticketKey, 'transition', configDir);
456
+ if (!claim.claimed) {
457
+ stream.write(claim.lockError
458
+ ? ` ${ticketKey} not transitioned — could not take the cooldown lock (${claim.lockError}). Nothing was sent; safe to retry.\n`
459
+ : ` Skipped — ${ticketKey} was already transitioned ${Math.ceil(claim.remainingMs / 1000)}s ago. Wait a moment before retrying.\n`);
413
460
  return { ok: false };
414
461
  }
415
462
 
416
463
  const resolved = resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
417
- if (!resolved) return { ok: false };
464
+ if (!resolved) { safeRelease(releaseActionFn, ticketKey, 'transition', configDir); return { ok: false }; }
418
465
  const { adapter } = resolved;
419
466
 
420
467
  try {
421
468
  const result = await adapter.transition(ticketKey, target);
422
469
  if (!result.executed) {
470
+ safeRelease(releaseActionFn, ticketKey, 'transition', configDir);
423
471
  const optionsHint = result.options?.length ? ` Valid options: ${result.options.map(o => o.name).join(', ')}.` : '';
424
472
  stream.write(` Not transitioned — ${result.reason}.${optionsHint}\n`);
425
473
  return { ok: false, reason: result.reason };
426
474
  }
427
- recordActionFn(ticketKey, 'transition', { configDir });
428
475
  logActionFn({ ticketKey, action: 'transition', actor, tracker: adapter.type, detail: { to: result.to } }, { configDir });
429
476
  const s = createStyler({ isTTY: stream.isTTY });
430
477
  stream.write(` ${s.green('✔')} ${s.brand(s.bold(ticketKey))} transitioned to ${s.bold(`"${result.to}"`)}.\n`);
431
478
  return { ok: true };
432
479
  } catch (err) {
480
+ safeRelease(releaseActionFn, ticketKey, 'transition', configDir);
433
481
  stream.write(formatWriteFailure(ticketKey, err));
434
482
  return { ok: false };
435
483
  }
436
484
  }
437
485
 
438
486
  /**
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.
487
+ * Derives the project-key portion of a ticket key — used only to key the
488
+ * local assignable-users cache, never for validation. Jira's own API stays
489
+ * the source of truth for whether the ticket key is real; a ticket key
490
+ * this can't parse just means caching is skipped, not a hard failure.
491
+ */
492
+ function projectKeyFromTicket(ticketKey) {
493
+ const hyphenIndex = ticketKey.lastIndexOf('-');
494
+ return hyphenIndex > 0 ? ticketKey.slice(0, hyphenIndex) : null;
495
+ }
496
+
497
+ /**
498
+ * Resolves a free-text `--to` query into assignable-user candidates,
499
+ * checking the local cache (3-day TTL, per project+query — ROADMAP 61)
500
+ * before calling the adapter. Read-only: never assigns, never touches
501
+ * cooldown. Exported for direct unit testing, same convention as
502
+ * `resolveTicketAdapter`.
503
+ */
504
+ export async function resolveAssigneeCandidates(adapter, ticketKey, query, {
505
+ profileName,
506
+ configDir = DEFAULT_CONFIG_DIR,
507
+ readMetadataCacheFn = readMetadataCache,
508
+ writeMetadataCacheFn = writeMetadataCache,
509
+ } = {}) {
510
+ const projectKey = projectKeyFromTicket(ticketKey);
511
+ const cached = readMetadataCacheFn(profileName, configDir);
512
+
513
+ if (projectKey) {
514
+ const normalizedQuery = normalizeAssigneeQuery(query);
515
+ const cachedCandidates = cached?.assignableUsersByProject?.[projectKey]?.[normalizedQuery];
516
+ const cachedAt = cached?.assignableUsersFetchedAt?.[projectKey]?.[normalizedQuery];
517
+ if (cachedCandidates && isFresh(cachedAt, SINGLE_PROJECT_TTL_MS)) {
518
+ return cachedCandidates;
519
+ }
520
+ }
521
+
522
+ const candidates = await adapter.searchAssignableUsers(ticketKey, query);
523
+
524
+ if (projectKey) {
525
+ const { assignableUsersByProject, assignableUsersFetchedAt } = mergeAssignableUsers(cached, projectKey, query, candidates);
526
+ writeMetadataCacheFn(profileName, {
527
+ projects: cached?.projects ?? [],
528
+ issueTypesByProject: cached?.issueTypesByProject ?? {},
529
+ issueTypesFetchedAt: cached?.issueTypesFetchedAt ?? {},
530
+ projectsFetchedAt: cached?.projectsFetchedAt ?? null,
531
+ assignableUsersByProject,
532
+ assignableUsersFetchedAt,
533
+ }, configDir);
534
+ }
535
+
536
+ return candidates;
537
+ }
538
+
539
+ /**
540
+ * `--to=me` self-assigns immediately — unchanged fast path, no discovery,
541
+ * no confirm. Any other `--to` resolves a real person first (ROADMAP 61):
542
+ * Jira Cloud only (Server/DC's assignable-user search is unverified —
543
+ * refuses cleanly rather than guessing), and only executes when exactly
544
+ * one candidate matches AND --confirm is given, mirroring `transition`'s
545
+ * list-then-confirm shape — notifying a colleague deserves the same
546
+ * reviewed-before-write gate as a workflow-state change.
444
547
  *
445
- * @param {string[]} cmdArgs - [ticketKey, '--to=me']
548
+ * @param {string[]} cmdArgs - [ticketKey, '--to=me'] or [ticketKey, '--to=...', '--confirm']
549
+ * @param {boolean} [cliHints] - see runTicketTransitionList's cliHints doc
446
550
  * @returns {Promise<{ ok: boolean }>}
447
551
  */
448
552
  export async function runTicketAssign(cmdArgs, {
@@ -451,41 +555,112 @@ export async function runTicketAssign(cmdArgs, {
451
555
  isLicensedFn = isLicensed,
452
556
  resolveConnectionFn = resolveConnection,
453
557
  resolveAdapterFn = resolveAdapter,
454
- checkCooldownFn = checkCooldown,
455
- recordActionFn = recordAction,
558
+ claimActionFn = claimAction,
559
+ releaseActionFn = releaseAction,
456
560
  logActionFn = logAction,
561
+ readMetadataCacheFn = readMetadataCache,
562
+ writeMetadataCacheFn = writeMetadataCache,
457
563
  actor = os.userInfo().username,
564
+ cliHints = true,
458
565
  } = {}) {
459
- const usage = 'Usage: ticketlens assign TICKET-KEY --to=me\n';
566
+ const usage = cliHints
567
+ ? 'Usage: ticketlens assign TICKET-KEY --to=me | --to="name or email" --confirm\n'
568
+ : 'Usage: assign requires ticket and to; a to other than "me" also needs confirm: true to execute.\n';
460
569
  if (!requireLicense(isLicensedFn, configDir, 'ticketlens assign', stream)) return { ok: false };
461
570
 
462
571
  const ticketKey = requireTicketKey(cmdArgs, usage, stream);
463
572
  if (!ticketKey) return { ok: false };
464
573
 
465
574
  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);
575
+ if (!to) {
576
+ stream.write(usage);
468
577
  return { ok: false };
469
578
  }
470
579
 
471
- const cooldown = checkCooldownFn(ticketKey, 'assign', { configDir });
472
- if (cooldown.active) {
473
- stream.write(` Skipped — ${ticketKey} was already assigned ${Math.ceil(cooldown.remainingMs / 1000)}s ago. Wait a moment before retrying.\n`);
474
- return { ok: false };
580
+ if (to === 'me') {
581
+ const claim = safeClaim(claimActionFn, ticketKey, 'assign', configDir);
582
+ if (!claim.claimed) {
583
+ stream.write(claim.lockError
584
+ ? ` ${ticketKey} not assigned — could not take the cooldown lock (${claim.lockError}). Nothing was sent; safe to retry.\n`
585
+ : ` Skipped — ${ticketKey} was already assigned ${Math.ceil(claim.remainingMs / 1000)}s ago. Wait a moment before retrying.\n`);
586
+ return { ok: false };
587
+ }
588
+
589
+ const resolved = resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
590
+ if (!resolved) { safeRelease(releaseActionFn, ticketKey, 'assign', configDir); return { ok: false }; }
591
+ const { adapter } = resolved;
592
+
593
+ try {
594
+ const result = await adapter.assignToSelf(ticketKey);
595
+ logActionFn({ ticketKey, action: 'assign', actor, tracker: adapter.type, detail: { assignee: result.assignee } }, { configDir });
596
+ const s = createStyler({ isTTY: stream.isTTY });
597
+ stream.write(` ${s.green('✔')} ${s.brand(s.bold(ticketKey))} assigned to ${s.bold(result.assignee)}.\n`);
598
+ return { ok: true };
599
+ } catch (err) {
600
+ safeRelease(releaseActionFn, ticketKey, 'assign', configDir);
601
+ stream.write(formatWriteFailure(ticketKey, err));
602
+ return { ok: false };
603
+ }
475
604
  }
476
605
 
477
606
  const resolved = resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
478
607
  if (!resolved) return { ok: false };
479
- const { adapter } = resolved;
608
+ const { adapter, conn } = resolved;
609
+
610
+ if (adapter.type !== 'jira') {
611
+ stream.write(` Assigning to another developer is Jira-only right now — ${adapter.type} is not supported.\n`);
612
+ return { ok: false };
613
+ }
614
+ if (conn.auth !== 'cloud') {
615
+ stream.write(' Assigning to another developer needs Jira Cloud — Server/DC is not supported yet.\n');
616
+ return { ok: false };
617
+ }
480
618
 
619
+ const s = createStyler({ isTTY: stream.isTTY });
620
+ let candidates;
481
621
  try {
482
- const result = await adapter.assignToSelf(ticketKey);
483
- 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`);
622
+ candidates = await resolveAssigneeCandidates(adapter, ticketKey, to, { profileName: conn.profileName, configDir, readMetadataCacheFn, writeMetadataCacheFn });
623
+ } catch (err) {
624
+ stream.write(formatWriteFailure(ticketKey, err));
625
+ return { ok: false };
626
+ }
627
+
628
+ if (candidates.length === 0) {
629
+ stream.write(` No assignable user found matching "${to}" for ${s.brand(s.bold(ticketKey))}.\n`);
630
+ return { ok: false };
631
+ }
632
+
633
+ if (candidates.length > 1) {
634
+ stream.write(` ${candidates.length} users match "${to}" — narrow the query:\n\n`);
635
+ for (const c of candidates) stream.write(` ${s.brand('●')} ${c.displayName} ${s.dim(c.accountId)}\n`);
636
+ return { ok: false };
637
+ }
638
+
639
+ const [candidate] = candidates;
640
+
641
+ if (!cmdArgs.includes('--confirm')) {
642
+ stream.write(` Match: ${s.bold(candidate.displayName)} ${s.dim(candidate.accountId)}\n`);
643
+ stream.write(cliHints
644
+ ? `\n Run again with --to="${to}" --confirm to execute.\n`
645
+ : `\n Call again with to="${to}" and confirm: true to execute.\n`);
646
+ return { ok: false };
647
+ }
648
+
649
+ const claim = safeClaim(claimActionFn, ticketKey, 'assign', configDir);
650
+ if (!claim.claimed) {
651
+ stream.write(claim.lockError
652
+ ? ` ${ticketKey} not assigned — could not take the cooldown lock (${claim.lockError}). Nothing was sent; safe to retry.\n`
653
+ : ` Skipped — ${ticketKey} was already assigned ${Math.ceil(claim.remainingMs / 1000)}s ago. Wait a moment before retrying.\n`);
654
+ return { ok: false };
655
+ }
656
+
657
+ try {
658
+ await adapter.assignToUser(ticketKey, candidate.accountId);
659
+ logActionFn({ ticketKey, action: 'assign', actor, tracker: adapter.type, detail: { assignee: candidate.displayName, accountId: candidate.accountId } }, { configDir });
660
+ stream.write(` ${s.green('✔')} ${s.brand(s.bold(ticketKey))} assigned to ${s.bold(candidate.displayName)}.\n`);
487
661
  return { ok: true };
488
662
  } catch (err) {
663
+ safeRelease(releaseActionFn, ticketKey, 'assign', configDir);
489
664
  stream.write(formatWriteFailure(ticketKey, err));
490
665
  return { ok: false };
491
666
  }
@@ -653,8 +828,8 @@ export async function runTicketLink(cmdArgs, {
653
828
  isLicensedFn = isLicensed,
654
829
  resolveConnectionFn = resolveConnection,
655
830
  resolveAdapterFn = resolveAdapter,
656
- checkCooldownFn = checkCooldown,
657
- recordActionFn = recordAction,
831
+ claimActionFn = claimAction,
832
+ releaseActionFn = releaseAction,
658
833
  logActionFn = logAction,
659
834
  actor = os.userInfo().username,
660
835
  cliHints = true,
@@ -680,17 +855,20 @@ export async function runTicketLink(cmdArgs, {
680
855
  }
681
856
 
682
857
  const cooldownKey = `${sourceKey}:${targetKey}`;
683
- const cooldown = checkCooldownFn(cooldownKey, 'link', { configDir });
684
- if (cooldown.active) {
685
- stream.write(` Skipped — ${sourceKey} was already linked to ${targetKey} ${Math.ceil(cooldown.remainingMs / 1000)}s ago. Wait a moment before retrying.\n`);
858
+ const claim = safeClaim(claimActionFn, cooldownKey, 'link', configDir);
859
+ if (!claim.claimed) {
860
+ stream.write(claim.lockError
861
+ ? ` ${sourceKey} not linked — could not take the cooldown lock (${claim.lockError}). Nothing was sent; safe to retry.\n`
862
+ : ` Skipped — ${sourceKey} was already linked to ${targetKey} ${Math.ceil(claim.remainingMs / 1000)}s ago. Wait a moment before retrying.\n`);
686
863
  return { ok: false };
687
864
  }
688
865
 
689
866
  const resolved = resolveTicketAdapter(sourceKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
690
- if (!resolved) return { ok: false };
867
+ if (!resolved) { safeRelease(releaseActionFn, cooldownKey, 'link', configDir); return { ok: false }; }
691
868
  const { adapter } = resolved;
692
869
 
693
870
  if (adapter.type === 'github' && type.toLowerCase() !== 'duplicate') {
871
+ safeRelease(releaseActionFn, cooldownKey, 'link', configDir);
694
872
  stream.write(` GitHub only supports linking as a duplicate — no generic link types. Got type "${type}".\n`);
695
873
  return { ok: false };
696
874
  }
@@ -701,11 +879,11 @@ export async function runTicketLink(cmdArgs, {
701
879
  try {
702
880
  const result = await adapter.linkTo(sourceKey, targetKey, type);
703
881
  if (!result.executed) {
882
+ safeRelease(releaseActionFn, cooldownKey, 'link', configDir);
704
883
  const optionsHint = result.options?.length ? ` Valid options: ${result.options.join(', ')}.` : '';
705
884
  stream.write(` Not linked — ${result.reason}.${optionsHint}\n`);
706
885
  return { ok: false, reason: result.reason };
707
886
  }
708
- recordActionFn(cooldownKey, 'link', { configDir });
709
887
  logActionFn({ ticketKey: sourceKey, action: 'link', actor, tracker: adapter.type, detail: { targetKey, type } }, { configDir });
710
888
  const s = createStyler({ isTTY: stream.isTTY });
711
889
  stream.write(
@@ -715,6 +893,7 @@ export async function runTicketLink(cmdArgs, {
715
893
  );
716
894
  return { ok: true };
717
895
  } catch (err) {
896
+ safeRelease(releaseActionFn, cooldownKey, 'link', configDir);
718
897
  stream.write(formatWriteFailure(sourceKey, err));
719
898
  return { ok: false };
720
899
  }
@@ -747,9 +926,11 @@ export async function runTicketUpdate(cmdArgs, {
747
926
  isLicensedFn = isLicensed,
748
927
  resolveConnectionFn = resolveConnection,
749
928
  resolveAdapterFn = resolveAdapter,
750
- checkCooldownFn = checkCooldown,
751
- recordActionFn = recordAction,
929
+ claimActionFn = claimAction,
930
+ releaseActionFn = releaseAction,
752
931
  logActionFn = logAction,
932
+ readMetadataCacheFn = readMetadataCache,
933
+ writeMetadataCacheFn = writeMetadataCache,
753
934
  actor = os.userInfo().username,
754
935
  } = {}) {
755
936
  const usage = 'Usage: ticketlens update TICKET-KEY [--title="..."] [--description="..."] [--add-labels=a,b] [--remove-labels=c] [--priority="High"]\n';
@@ -771,34 +952,45 @@ export async function runTicketUpdate(cmdArgs, {
771
952
  return { ok: false };
772
953
  }
773
954
 
774
- const cooldown = checkCooldownFn(ticketKey, 'update', { configDir });
775
- if (cooldown.active) {
776
- stream.write(` Skipped — ${ticketKey} was already updated ${Math.ceil(cooldown.remainingMs / 1000)}s ago. Wait a moment before retrying.\n`);
955
+ const claim = safeClaim(claimActionFn, ticketKey, 'update', configDir);
956
+ if (!claim.claimed) {
957
+ stream.write(claim.lockError
958
+ ? ` ${ticketKey} not updated — could not take the cooldown lock (${claim.lockError}). Nothing was sent; safe to retry.\n`
959
+ : ` Skipped — ${ticketKey} was already updated ${Math.ceil(claim.remainingMs / 1000)}s ago. Wait a moment before retrying.\n`);
777
960
  return { ok: false };
778
961
  }
779
962
 
780
963
  const resolved = resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
781
- if (!resolved) return { ok: false };
782
- const { adapter } = resolved;
964
+ if (!resolved) { safeRelease(releaseActionFn, ticketKey, 'update', configDir); return { ok: false }; }
965
+ const { adapter, conn } = resolved;
783
966
 
784
967
  if (adapter.type === 'github' && priority !== undefined) {
968
+ safeRelease(releaseActionFn, ticketKey, 'update', configDir);
785
969
  stream.write(` GitHub Issues have no native priority field — cannot update priority on ${ticketKey}. Remove --priority and retry.\n`);
786
970
  return { ok: false };
787
971
  }
788
972
 
789
973
  try {
790
- const result = await adapter.updateFields(ticketKey, { title, description, priority, addLabels, removeLabels });
974
+ const result = await adapter.updateFields(ticketKey, { title, description, priority, addLabels, removeLabels }, { readMetadataCacheFn, writeMetadataCacheFn, profileName: conn.profileName, configDir });
791
975
  const hasApplied = Object.keys(result.applied).length > 0;
792
976
  const hasErrors = Object.keys(result.errors).length > 0;
793
977
 
794
978
  if (hasApplied) {
795
- recordActionFn(ticketKey, 'update', { configDir });
796
979
  logActionFn({ ticketKey, action: 'update', actor, tracker: adapter.type, detail: { ...result.applied, failed: Object.keys(result.errors) } }, { configDir });
980
+ } else {
981
+ // Nothing landed — release so a caller fixing the failed field(s) can
982
+ // retry immediately, same "release on definite failure" rule as every
983
+ // other write in this family. A partial success (hasApplied &&
984
+ // hasErrors) deliberately keeps the claim armed, unchanged from the
985
+ // old recordActionFn-only-when-hasApplied behavior.
986
+ safeRelease(releaseActionFn, ticketKey, 'update', configDir);
797
987
  }
798
988
  stream.write(formatUpdateResult(ticketKey, result, createStyler({ isTTY: stream.isTTY })));
799
989
  return hasErrors ? { ok: false, applied: result.applied, errors: result.errors } : { ok: true, applied: result.applied };
800
990
  } catch (err) {
801
- stream.write(formatWriteFailure(ticketKey, err));
991
+ safeRelease(releaseActionFn, ticketKey, 'update', configDir);
992
+ const enrichment = await enrichUpdateFailure(err, { adapter, projectKey: projectKeyFromTicket(ticketKey), profileName: conn.profileName, configDir, readMetadataCacheFn, writeMetadataCacheFn });
993
+ stream.write(formatWriteFailure(ticketKey, err) + enrichment);
802
994
  return { ok: false };
803
995
  }
804
996
  }
@@ -833,8 +1025,8 @@ export async function runTicketCreate(cmdArgs, {
833
1025
  isLicensedFn = isLicensed,
834
1026
  resolveConnectionFn = resolveConnection,
835
1027
  resolveAdapterFn = resolveAdapter,
836
- checkCooldownFn = checkCooldown,
837
- recordActionFn = recordAction,
1028
+ claimActionFn = claimAction,
1029
+ releaseActionFn = releaseAction,
838
1030
  logActionFn = logAction,
839
1031
  readMetadataCacheFn = readMetadataCache,
840
1032
  writeMetadataCacheFn = writeMetadataCache,
@@ -899,9 +1091,11 @@ export async function runTicketCreate(cmdArgs, {
899
1091
  // text that can themselves contain ":", which would let two genuinely
900
1092
  // different tuples collide onto the same cooldown key.
901
1093
  const cooldownKey = `create:${JSON.stringify([project ?? '', type ?? '', summary])}`;
902
- const cooldown = checkCooldownFn(cooldownKey, 'create', { configDir });
903
- if (cooldown.active) {
904
- stream.write(` Skipped — a ticket with this summary was already created ${Math.ceil(cooldown.remainingMs / 1000)}s ago. Wait a moment before retrying.\n`);
1094
+ const claim = safeClaim(claimActionFn, cooldownKey, 'create', configDir);
1095
+ if (!claim.claimed) {
1096
+ stream.write(claim.lockError
1097
+ ? ` Nothing was created — could not take the cooldown lock (${claim.lockError}). Safe to retry.\n`
1098
+ : ` Skipped — a ticket with this summary was already created ${Math.ceil(claim.remainingMs / 1000)}s ago. Wait a moment before retrying.\n`);
905
1099
  return { ok: false };
906
1100
  }
907
1101
 
@@ -909,6 +1103,9 @@ export async function runTicketCreate(cmdArgs, {
909
1103
  try {
910
1104
  result = await adapter.createTicket({ project, type, summary, description });
911
1105
  } catch (err) {
1106
+ // Definite failure — no ticket was created, so the claim is released to
1107
+ // let a corrected retry through immediately (Backlog #34/ROADMAP 57).
1108
+ safeRelease(releaseActionFn, cooldownKey, 'create', configDir);
912
1109
  // profileName is only resolved when this failure is actually
913
1110
  // project/issuetype-shaped — not on every failure, and never on the
914
1111
  // success path — since it exists solely to scope the enrichment cache.
@@ -922,12 +1119,13 @@ export async function runTicketCreate(cmdArgs, {
922
1119
  }
923
1120
 
924
1121
  // The write already landed — a real, external, hard-to-walk-back ticket
925
- // now exists. From here on, nothing may report this as a failed write:
926
- // cooldown/audit bookkeeping is best-effort, never the reason a real
927
- // success gets mistaken for one (which risks a caller retrying and
928
- // fabricating a genuine duplicate).
1122
+ // now exists. From here on, nothing may report this as a failed write.
1123
+ // The cooldown claim was already recorded atomically at claim time above
1124
+ // (no separate recordActionFn call needed here); only the audit log is
1125
+ // best-effort — a logging failure must never make a real success look
1126
+ // like a failure (which risks a caller retrying and fabricating a
1127
+ // genuine duplicate).
929
1128
  try {
930
- recordActionFn(cooldownKey, 'create', { configDir });
931
1129
  logActionFn({ ticketKey: result.key, action: 'create', actor, tracker: adapter.type, detail: { project, type } }, { configDir });
932
1130
  } catch (bookkeepingErr) {
933
1131
  stream.write(` Warning: ${result.key} was created but could not be logged: ${bookkeepingErr.message}\n`);
@@ -21,7 +21,35 @@
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}},
26
+ * prioritiesByProject: {KEY: [{id, name}]},
27
+ * prioritiesFetchedAt: {KEY: iso timestamp},
28
+ * labelsByTeam: {TEAM_ID: [{id, name}]},
29
+ * labelsFetchedAt: {TEAM_ID: iso timestamp}
24
30
  * }
31
+ *
32
+ * assignableUsersByProject/assignableUsersFetchedAt (ROADMAP 61) share this
33
+ * file rather than a new one — same profile-scoped Jira-lookup cache, same
34
+ * 3-day freshness bar as a single-project issue-types lookup. Nested one
35
+ * level deeper than issueTypesByProject because Jira's own
36
+ * `user/assignable/search` has no "list everyone" mode (query is required
37
+ * unless accountId is given) — there is no per-project roster to cache,
38
+ * only per-(project, query) result pages, each with its own clock.
39
+ *
40
+ * prioritiesByProject/prioritiesFetchedAt (Backlog #34/ROADMAP 57 follow-up)
41
+ * are per-project like issueTypesByProject, not global — confirmed live
42
+ * against both a Cloud instance (corenexus) and a Server/DC instance
43
+ * (advent) that priority schemes are project-scoped, not instance-wide.
44
+ * Reactive-enrichment-only, same 3-day SINGLE_PROJECT_TTL_MS bar, same
45
+ * design as issue-types: never blocks a write, only enriches the error
46
+ * message after Jira's own 400.
47
+ *
48
+ * labelsByTeam/labelsFetchedAt is Linear-only — Linear requires labels to
49
+ * pre-exist as real objects (unlike Jira's freeform labels), and its own
50
+ * `updateFields` re-fetches the full team label list via GraphQL on every
51
+ * single call with no caching. Keyed by team id (Linear's label scope),
52
+ * same 3-day freshness bar.
25
53
  */
26
54
 
27
55
  import fs from 'node:fs';
@@ -62,6 +90,67 @@ export function mergeProjectIssueTypes(cached, projectKey, types, fetchedAt = ne
62
90
  return { issueTypesByProject, issueTypesFetchedAt };
63
91
  }
64
92
 
93
+ /**
94
+ * Trim + lowercase — the one normalization every assignable-users cache
95
+ * read and write must agree on, so "Jane", " jane ", and "JANE" share one
96
+ * cache entry instead of three.
97
+ */
98
+ export function normalizeAssigneeQuery(query) {
99
+ return String(query).trim().toLowerCase();
100
+ }
101
+
102
+ /**
103
+ * Merges one (project, query) assignable-users search result into an
104
+ * existing (possibly null) cached map — same shape and same
105
+ * Object.create(null) defense as mergeProjectIssueTypes, one level deeper
106
+ * since a project can have many independently-fresh cached queries. Both
107
+ * projectKey and the normalized query are unvalidated values reaching a
108
+ * key position.
109
+ */
110
+ export function mergeAssignableUsers(cached, projectKey, query, candidates, fetchedAt = new Date().toISOString()) {
111
+ const normalizedQuery = normalizeAssigneeQuery(query);
112
+
113
+ const assignableUsersByProject = Object.assign(Object.create(null), cached?.assignableUsersByProject ?? {});
114
+ const projectQueries = Object.assign(Object.create(null), assignableUsersByProject[projectKey] ?? {});
115
+ projectQueries[normalizedQuery] = candidates;
116
+ assignableUsersByProject[projectKey] = projectQueries;
117
+
118
+ const assignableUsersFetchedAt = Object.assign(Object.create(null), cached?.assignableUsersFetchedAt ?? {});
119
+ const projectQueryTimestamps = Object.assign(Object.create(null), assignableUsersFetchedAt[projectKey] ?? {});
120
+ projectQueryTimestamps[normalizedQuery] = fetchedAt;
121
+ assignableUsersFetchedAt[projectKey] = projectQueryTimestamps;
122
+
123
+ return { assignableUsersByProject, assignableUsersFetchedAt };
124
+ }
125
+
126
+ /**
127
+ * Merges one project's priority list into an existing (possibly null)
128
+ * cached map — same shape and Object.create(null) defense as
129
+ * mergeProjectIssueTypes, for the same reason (projectKey is an
130
+ * unvalidated CLI/tracker value reaching a key position).
131
+ */
132
+ export function mergeProjectPriorities(cached, projectKey, priorities, fetchedAt = new Date().toISOString()) {
133
+ const prioritiesByProject = Object.assign(Object.create(null), cached?.prioritiesByProject ?? {});
134
+ prioritiesByProject[projectKey] = priorities;
135
+ const prioritiesFetchedAt = Object.assign(Object.create(null), cached?.prioritiesFetchedAt ?? {});
136
+ prioritiesFetchedAt[projectKey] = fetchedAt;
137
+ return { prioritiesByProject, prioritiesFetchedAt };
138
+ }
139
+
140
+ /**
141
+ * Merges one team's label list into an existing (possibly null) cached
142
+ * map — same shape and Object.create(null) defense as
143
+ * mergeProjectIssueTypes; teamId is Linear's own UUID, not user-supplied,
144
+ * but the same defensive pattern is kept for consistency with its siblings.
145
+ */
146
+ export function mergeTeamLabels(cached, teamId, labels, fetchedAt = new Date().toISOString()) {
147
+ const labelsByTeam = Object.assign(Object.create(null), cached?.labelsByTeam ?? {});
148
+ labelsByTeam[teamId] = labels;
149
+ const labelsFetchedAt = Object.assign(Object.create(null), cached?.labelsFetchedAt ?? {});
150
+ labelsFetchedAt[teamId] = fetchedAt;
151
+ return { labelsByTeam, labelsFetchedAt };
152
+ }
153
+
65
154
  /**
66
155
  * Returns the absolute path to the ticket-metadata cache file for a profile.
67
156
  */
@@ -84,7 +173,7 @@ export function metadataCachePath(profileName, configDir = DEFAULT_CONFIG_DIR) {
84
173
  * @param {string|null} profileName
85
174
  * @param {string} [configDir]
86
175
  * @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}
176
+ * @returns {{ projects: {key:string,name:string}[], issueTypesByProject: object, issueTypesFetchedAt: object, assignableUsersByProject: object, assignableUsersFetchedAt: object, prioritiesByProject: object, prioritiesFetchedAt: object, labelsByTeam: object, labelsFetchedAt: object, projectsFetchedAt: string|null, fetchedAt: string } | null}
88
177
  */
89
178
  export function readMetadataCache(profileName, configDir = DEFAULT_CONFIG_DIR, ttlMs = METADATA_TTL_MS) {
90
179
  const filePath = metadataCachePath(profileName, configDir);
@@ -107,6 +196,12 @@ export function readMetadataCache(profileName, configDir = DEFAULT_CONFIG_DIR, t
107
196
  projects: data.projects ?? [],
108
197
  issueTypesByProject: data.issueTypesByProject ?? {},
109
198
  issueTypesFetchedAt: data.issueTypesFetchedAt ?? {},
199
+ assignableUsersByProject: data.assignableUsersByProject ?? {},
200
+ assignableUsersFetchedAt: data.assignableUsersFetchedAt ?? {},
201
+ prioritiesByProject: data.prioritiesByProject ?? {},
202
+ prioritiesFetchedAt: data.prioritiesFetchedAt ?? {},
203
+ labelsByTeam: data.labelsByTeam ?? {},
204
+ labelsFetchedAt: data.labelsFetchedAt ?? {},
110
205
  projectsFetchedAt: data.projectsFetchedAt ?? null,
111
206
  fetchedAt: data.fetchedAt,
112
207
  };
@@ -119,7 +214,7 @@ export function readMetadataCache(profileName, configDir = DEFAULT_CONFIG_DIR, t
119
214
  * updates (e.g. a single-project fetch merging into an existing
120
215
  * multi-project cache) — this function persists exactly what it's given.
121
216
  */
122
- export function writeMetadataCache(profileName, { projects = [], issueTypesByProject = {}, issueTypesFetchedAt = {}, projectsFetchedAt = null } = {}, configDir = DEFAULT_CONFIG_DIR) {
217
+ export function writeMetadataCache(profileName, { projects = [], issueTypesByProject = {}, issueTypesFetchedAt = {}, assignableUsersByProject = {}, assignableUsersFetchedAt = {}, prioritiesByProject = {}, prioritiesFetchedAt = {}, labelsByTeam = {}, labelsFetchedAt = {}, projectsFetchedAt = null } = {}, configDir = DEFAULT_CONFIG_DIR) {
123
218
  const filePath = metadataCachePath(profileName, configDir);
124
219
  try {
125
220
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
@@ -129,6 +224,12 @@ export function writeMetadataCache(profileName, { projects = [], issueTypesByPro
129
224
  projects,
130
225
  issueTypesByProject,
131
226
  issueTypesFetchedAt,
227
+ assignableUsersByProject,
228
+ assignableUsersFetchedAt,
229
+ prioritiesByProject,
230
+ prioritiesFetchedAt,
231
+ labelsByTeam,
232
+ labelsFetchedAt,
132
233
  }));
133
234
  } catch {
134
235
  // Non-fatal
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Failure-message enrichment for `ticket_update`'s priority field —
3
+ * reactive only, never runs on the success path. Same structure as
4
+ * ticket-create-enrichment.mjs's project/issuetype enrichment; kept as a
5
+ * separate file (not merged into that one) since the two enrich different
6
+ * fields for a different write and share no state beyond the cache module.
7
+ */
8
+
9
+ import { SINGLE_PROJECT_TTL_MS, isFresh, mergeProjectPriorities } from './ticket-metadata-cache.mjs';
10
+
11
+ /**
12
+ * Detects whether an update failure is shaped like a bad priority name —
13
+ * the only case this enrichment applies to. Jira surfaces this via its
14
+ * own real `err.details.errors.priority` key, confirmed by direct
15
+ * observation against a live Cloud instance (corenexus) and a live
16
+ * Server/DC instance (advent) on 2026-09-24. Anything else (rate limits,
17
+ * network errors, generic 4xx/5xx, a title/description/label failure)
18
+ * returns false — enrichment never applies there.
19
+ */
20
+ export function detectPriorityError(err) {
21
+ return Boolean(err?.details?.errors && 'priority' in err.details.errors);
22
+ }
23
+
24
+ /**
25
+ * Best-effort failure-message enrichment for ticket_update's priority
26
+ * field — reactive only, never runs on the success path or for a
27
+ * non-priority failure. Reuses a cached per-project priority listing when
28
+ * fresh (no extra network call); refreshes it when missing/stale. A
29
+ * refresh failure is swallowed entirely and nothing is written to the
30
+ * cache: this can only ever make an error message MORE informative, never
31
+ * introduce a new way for ticketlens update to fail or a new way to
32
+ * poison the cache. Jira-only — GitHub has no priority field (refused
33
+ * upstream in ticket-command.mjs) and Linear already returns a structured,
34
+ * local `{reason:'not-found', options}` error with no network round trip.
35
+ *
36
+ * Freshness is keyed on the timestamp existing, not on the cached array
37
+ * having entries — a project with no priority-capable issue type at all
38
+ * genuinely caches `[]`, and `[].length` is falsy, so gating on length
39
+ * would re-walk every issue type (adapter.listPriorities' own sequential
40
+ * per-type loop) on every single subsequent failed update, forever,
41
+ * never respecting SINGLE_PROJECT_TTL_MS. Caught in code review before
42
+ * shipping — the sibling ticket-create-enrichment.mjs has this same
43
+ * length-gated pattern for issueTypesByProject, not fixed here since its
44
+ * refresh is a single call, not a sequential loop.
45
+ */
46
+ export async function enrichUpdateFailure(err, { adapter, projectKey, profileName, configDir, readMetadataCacheFn, writeMetadataCacheFn }) {
47
+ if (!detectPriorityError(err) || adapter.type !== 'jira' || !projectKey) return '';
48
+
49
+ let cached = readMetadataCacheFn(profileName, configDir);
50
+ const hasFreshPriorities = cached?.prioritiesByProject?.[projectKey] !== undefined
51
+ && isFresh(cached?.prioritiesFetchedAt?.[projectKey], SINGLE_PROJECT_TTL_MS);
52
+
53
+ if (!hasFreshPriorities) {
54
+ try {
55
+ const { prioritiesByProject, prioritiesFetchedAt } = mergeProjectPriorities(cached, projectKey, await adapter.listPriorities(projectKey));
56
+ cached = { ...cached, prioritiesByProject, prioritiesFetchedAt };
57
+ writeMetadataCacheFn(profileName, cached, configDir);
58
+ } catch {
59
+ return '';
60
+ }
61
+ }
62
+
63
+ const priorities = cached?.prioritiesByProject?.[projectKey];
64
+ if (!priorities?.length) return '';
65
+ return ` Known priorities for ${projectKey}: ${priorities.map(p => p.name).join(', ')}.\n`;
66
+ }