ticketlens 0.41.0 → 0.42.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.41.0",
3
+ "version": "0.42.1",
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
- import { fetchTicket, fetchCurrentUser, searchTickets, fetchStatuses, fetchProjects, fetchIssueTypes, postComment, getTransitions, postTransition, assignIssue, fetchAssignableUsers, 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';
@@ -211,6 +211,34 @@ export function createJiraAdapter(conn, { fetcher = globalThis.fetch } = {}) {
211
211
  /** Real, currently-configured issue types for one project. */
212
212
  listIssueTypes: (projectKey, opts = {}) => fetchIssueTypes(projectKey, { ...base, ...opts }),
213
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
+
214
242
  /**
215
243
  * Best-effort, per-file: one bad path or one failed upload never blocks
216
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));
@@ -3,7 +3,7 @@
3
3
  * Centralised here to avoid triplicating the regex and warning logic.
4
4
  */
5
5
 
6
- export const DEFAULT_API_BASE = 'http://api.ticketlens.test';
6
+ export const DEFAULT_API_BASE = 'https://api.ticketlens.app';
7
7
  export const DEFAULT_SITE_BASE = 'https://ticketlens.app';
8
8
 
9
9
  // Matches localhost, 127.0.0.1, and any hostname ending in .test or .local,
@@ -738,6 +738,46 @@ export async function fetchIssueTypes(projectKey, opts = {}) {
738
738
  return values.map(t => ({ id: t.id, name: t.name }));
739
739
  }
740
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
+
741
781
  /**
742
782
  * Creates a new issue. `project`/`type` are passed straight through as
743
783
  * Jira's own {key}/{name} references — issue types are project-configurable,
@@ -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
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,29 +452,32 @@ 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
  }
@@ -507,8 +555,8 @@ export async function runTicketAssign(cmdArgs, {
507
555
  isLicensedFn = isLicensed,
508
556
  resolveConnectionFn = resolveConnection,
509
557
  resolveAdapterFn = resolveAdapter,
510
- checkCooldownFn = checkCooldown,
511
- recordActionFn = recordAction,
558
+ claimActionFn = claimAction,
559
+ releaseActionFn = releaseAction,
512
560
  logActionFn = logAction,
513
561
  readMetadataCacheFn = readMetadataCache,
514
562
  writeMetadataCacheFn = writeMetadataCache,
@@ -530,24 +578,26 @@ export async function runTicketAssign(cmdArgs, {
530
578
  }
531
579
 
532
580
  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`);
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`);
536
586
  return { ok: false };
537
587
  }
538
588
 
539
589
  const resolved = resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
540
- if (!resolved) return { ok: false };
590
+ if (!resolved) { safeRelease(releaseActionFn, ticketKey, 'assign', configDir); return { ok: false }; }
541
591
  const { adapter } = resolved;
542
592
 
543
593
  try {
544
594
  const result = await adapter.assignToSelf(ticketKey);
545
- recordActionFn(ticketKey, 'assign', { configDir });
546
595
  logActionFn({ ticketKey, action: 'assign', actor, tracker: adapter.type, detail: { assignee: result.assignee } }, { configDir });
547
596
  const s = createStyler({ isTTY: stream.isTTY });
548
597
  stream.write(` ${s.green('✔')} ${s.brand(s.bold(ticketKey))} assigned to ${s.bold(result.assignee)}.\n`);
549
598
  return { ok: true };
550
599
  } catch (err) {
600
+ safeRelease(releaseActionFn, ticketKey, 'assign', configDir);
551
601
  stream.write(formatWriteFailure(ticketKey, err));
552
602
  return { ok: false };
553
603
  }
@@ -596,19 +646,21 @@ export async function runTicketAssign(cmdArgs, {
596
646
  return { ok: false };
597
647
  }
598
648
 
599
- const cooldown = checkCooldownFn(ticketKey, 'assign', { configDir });
600
- if (cooldown.active) {
601
- stream.write(` Skipped — ${ticketKey} was already assigned ${Math.ceil(cooldown.remainingMs / 1000)}s ago. Wait a moment before retrying.\n`);
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`);
602
654
  return { ok: false };
603
655
  }
604
656
 
605
657
  try {
606
658
  await adapter.assignToUser(ticketKey, candidate.accountId);
607
- recordActionFn(ticketKey, 'assign', { configDir });
608
659
  logActionFn({ ticketKey, action: 'assign', actor, tracker: adapter.type, detail: { assignee: candidate.displayName, accountId: candidate.accountId } }, { configDir });
609
660
  stream.write(` ${s.green('✔')} ${s.brand(s.bold(ticketKey))} assigned to ${s.bold(candidate.displayName)}.\n`);
610
661
  return { ok: true };
611
662
  } catch (err) {
663
+ safeRelease(releaseActionFn, ticketKey, 'assign', configDir);
612
664
  stream.write(formatWriteFailure(ticketKey, err));
613
665
  return { ok: false };
614
666
  }
@@ -776,8 +828,8 @@ export async function runTicketLink(cmdArgs, {
776
828
  isLicensedFn = isLicensed,
777
829
  resolveConnectionFn = resolveConnection,
778
830
  resolveAdapterFn = resolveAdapter,
779
- checkCooldownFn = checkCooldown,
780
- recordActionFn = recordAction,
831
+ claimActionFn = claimAction,
832
+ releaseActionFn = releaseAction,
781
833
  logActionFn = logAction,
782
834
  actor = os.userInfo().username,
783
835
  cliHints = true,
@@ -803,17 +855,20 @@ export async function runTicketLink(cmdArgs, {
803
855
  }
804
856
 
805
857
  const cooldownKey = `${sourceKey}:${targetKey}`;
806
- const cooldown = checkCooldownFn(cooldownKey, 'link', { configDir });
807
- if (cooldown.active) {
808
- 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`);
809
863
  return { ok: false };
810
864
  }
811
865
 
812
866
  const resolved = resolveTicketAdapter(sourceKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
813
- if (!resolved) return { ok: false };
867
+ if (!resolved) { safeRelease(releaseActionFn, cooldownKey, 'link', configDir); return { ok: false }; }
814
868
  const { adapter } = resolved;
815
869
 
816
870
  if (adapter.type === 'github' && type.toLowerCase() !== 'duplicate') {
871
+ safeRelease(releaseActionFn, cooldownKey, 'link', configDir);
817
872
  stream.write(` GitHub only supports linking as a duplicate — no generic link types. Got type "${type}".\n`);
818
873
  return { ok: false };
819
874
  }
@@ -824,11 +879,11 @@ export async function runTicketLink(cmdArgs, {
824
879
  try {
825
880
  const result = await adapter.linkTo(sourceKey, targetKey, type);
826
881
  if (!result.executed) {
882
+ safeRelease(releaseActionFn, cooldownKey, 'link', configDir);
827
883
  const optionsHint = result.options?.length ? ` Valid options: ${result.options.join(', ')}.` : '';
828
884
  stream.write(` Not linked — ${result.reason}.${optionsHint}\n`);
829
885
  return { ok: false, reason: result.reason };
830
886
  }
831
- recordActionFn(cooldownKey, 'link', { configDir });
832
887
  logActionFn({ ticketKey: sourceKey, action: 'link', actor, tracker: adapter.type, detail: { targetKey, type } }, { configDir });
833
888
  const s = createStyler({ isTTY: stream.isTTY });
834
889
  stream.write(
@@ -838,6 +893,7 @@ export async function runTicketLink(cmdArgs, {
838
893
  );
839
894
  return { ok: true };
840
895
  } catch (err) {
896
+ safeRelease(releaseActionFn, cooldownKey, 'link', configDir);
841
897
  stream.write(formatWriteFailure(sourceKey, err));
842
898
  return { ok: false };
843
899
  }
@@ -870,9 +926,11 @@ export async function runTicketUpdate(cmdArgs, {
870
926
  isLicensedFn = isLicensed,
871
927
  resolveConnectionFn = resolveConnection,
872
928
  resolveAdapterFn = resolveAdapter,
873
- checkCooldownFn = checkCooldown,
874
- recordActionFn = recordAction,
929
+ claimActionFn = claimAction,
930
+ releaseActionFn = releaseAction,
875
931
  logActionFn = logAction,
932
+ readMetadataCacheFn = readMetadataCache,
933
+ writeMetadataCacheFn = writeMetadataCache,
876
934
  actor = os.userInfo().username,
877
935
  } = {}) {
878
936
  const usage = 'Usage: ticketlens update TICKET-KEY [--title="..."] [--description="..."] [--add-labels=a,b] [--remove-labels=c] [--priority="High"]\n';
@@ -894,34 +952,45 @@ export async function runTicketUpdate(cmdArgs, {
894
952
  return { ok: false };
895
953
  }
896
954
 
897
- const cooldown = checkCooldownFn(ticketKey, 'update', { configDir });
898
- if (cooldown.active) {
899
- 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`);
900
960
  return { ok: false };
901
961
  }
902
962
 
903
963
  const resolved = resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
904
- if (!resolved) return { ok: false };
905
- const { adapter } = resolved;
964
+ if (!resolved) { safeRelease(releaseActionFn, ticketKey, 'update', configDir); return { ok: false }; }
965
+ const { adapter, conn } = resolved;
906
966
 
907
967
  if (adapter.type === 'github' && priority !== undefined) {
968
+ safeRelease(releaseActionFn, ticketKey, 'update', configDir);
908
969
  stream.write(` GitHub Issues have no native priority field — cannot update priority on ${ticketKey}. Remove --priority and retry.\n`);
909
970
  return { ok: false };
910
971
  }
911
972
 
912
973
  try {
913
- 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 });
914
975
  const hasApplied = Object.keys(result.applied).length > 0;
915
976
  const hasErrors = Object.keys(result.errors).length > 0;
916
977
 
917
978
  if (hasApplied) {
918
- recordActionFn(ticketKey, 'update', { configDir });
919
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);
920
987
  }
921
988
  stream.write(formatUpdateResult(ticketKey, result, createStyler({ isTTY: stream.isTTY })));
922
989
  return hasErrors ? { ok: false, applied: result.applied, errors: result.errors } : { ok: true, applied: result.applied };
923
990
  } catch (err) {
924
- 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);
925
994
  return { ok: false };
926
995
  }
927
996
  }
@@ -956,8 +1025,8 @@ export async function runTicketCreate(cmdArgs, {
956
1025
  isLicensedFn = isLicensed,
957
1026
  resolveConnectionFn = resolveConnection,
958
1027
  resolveAdapterFn = resolveAdapter,
959
- checkCooldownFn = checkCooldown,
960
- recordActionFn = recordAction,
1028
+ claimActionFn = claimAction,
1029
+ releaseActionFn = releaseAction,
961
1030
  logActionFn = logAction,
962
1031
  readMetadataCacheFn = readMetadataCache,
963
1032
  writeMetadataCacheFn = writeMetadataCache,
@@ -1022,9 +1091,11 @@ export async function runTicketCreate(cmdArgs, {
1022
1091
  // text that can themselves contain ":", which would let two genuinely
1023
1092
  // different tuples collide onto the same cooldown key.
1024
1093
  const cooldownKey = `create:${JSON.stringify([project ?? '', type ?? '', summary])}`;
1025
- const cooldown = checkCooldownFn(cooldownKey, 'create', { configDir });
1026
- if (cooldown.active) {
1027
- 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`);
1028
1099
  return { ok: false };
1029
1100
  }
1030
1101
 
@@ -1032,6 +1103,9 @@ export async function runTicketCreate(cmdArgs, {
1032
1103
  try {
1033
1104
  result = await adapter.createTicket({ project, type, summary, description });
1034
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);
1035
1109
  // profileName is only resolved when this failure is actually
1036
1110
  // project/issuetype-shaped — not on every failure, and never on the
1037
1111
  // success path — since it exists solely to scope the enrichment cache.
@@ -1045,12 +1119,13 @@ export async function runTicketCreate(cmdArgs, {
1045
1119
  }
1046
1120
 
1047
1121
  // The write already landed — a real, external, hard-to-walk-back ticket
1048
- // now exists. From here on, nothing may report this as a failed write:
1049
- // cooldown/audit bookkeeping is best-effort, never the reason a real
1050
- // success gets mistaken for one (which risks a caller retrying and
1051
- // 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).
1052
1128
  try {
1053
- recordActionFn(cooldownKey, 'create', { configDir });
1054
1129
  logActionFn({ ticketKey: result.key, action: 'create', actor, tracker: adapter.type, detail: { project, type } }, { configDir });
1055
1130
  } catch (bookkeepingErr) {
1056
1131
  stream.write(` Warning: ${result.key} was created but could not be logged: ${bookkeepingErr.message}\n`);
@@ -22,7 +22,11 @@
22
22
  * issueTypesByProject: {KEY: [{id, name}]},
23
23
  * issueTypesFetchedAt: {KEY: iso timestamp} // per-project, either access pattern
24
24
  * assignableUsersByProject: {KEY: {normalizedQuery: [{accountId, displayName}]}},
25
- * assignableUsersFetchedAt: {KEY: {normalizedQuery: iso timestamp}}
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}
26
30
  * }
27
31
  *
28
32
  * assignableUsersByProject/assignableUsersFetchedAt (ROADMAP 61) share this
@@ -32,6 +36,20 @@
32
36
  * `user/assignable/search` has no "list everyone" mode (query is required
33
37
  * unless accountId is given) — there is no per-project roster to cache,
34
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.
35
53
  */
36
54
 
37
55
  import fs from 'node:fs';
@@ -105,6 +123,34 @@ export function mergeAssignableUsers(cached, projectKey, query, candidates, fetc
105
123
  return { assignableUsersByProject, assignableUsersFetchedAt };
106
124
  }
107
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
+
108
154
  /**
109
155
  * Returns the absolute path to the ticket-metadata cache file for a profile.
110
156
  */
@@ -127,7 +173,7 @@ export function metadataCachePath(profileName, configDir = DEFAULT_CONFIG_DIR) {
127
173
  * @param {string|null} profileName
128
174
  * @param {string} [configDir]
129
175
  * @param {number} [ttlMs] - override TTL in ms for this file's own GC deletion; defaults to METADATA_TTL_MS (7d)
130
- * @returns {{ projects: {key:string,name:string}[], issueTypesByProject: object, issueTypesFetchedAt: object, assignableUsersByProject: object, assignableUsersFetchedAt: 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}
131
177
  */
132
178
  export function readMetadataCache(profileName, configDir = DEFAULT_CONFIG_DIR, ttlMs = METADATA_TTL_MS) {
133
179
  const filePath = metadataCachePath(profileName, configDir);
@@ -152,6 +198,10 @@ export function readMetadataCache(profileName, configDir = DEFAULT_CONFIG_DIR, t
152
198
  issueTypesFetchedAt: data.issueTypesFetchedAt ?? {},
153
199
  assignableUsersByProject: data.assignableUsersByProject ?? {},
154
200
  assignableUsersFetchedAt: data.assignableUsersFetchedAt ?? {},
201
+ prioritiesByProject: data.prioritiesByProject ?? {},
202
+ prioritiesFetchedAt: data.prioritiesFetchedAt ?? {},
203
+ labelsByTeam: data.labelsByTeam ?? {},
204
+ labelsFetchedAt: data.labelsFetchedAt ?? {},
155
205
  projectsFetchedAt: data.projectsFetchedAt ?? null,
156
206
  fetchedAt: data.fetchedAt,
157
207
  };
@@ -164,7 +214,7 @@ export function readMetadataCache(profileName, configDir = DEFAULT_CONFIG_DIR, t
164
214
  * updates (e.g. a single-project fetch merging into an existing
165
215
  * multi-project cache) — this function persists exactly what it's given.
166
216
  */
167
- export function writeMetadataCache(profileName, { projects = [], issueTypesByProject = {}, issueTypesFetchedAt = {}, assignableUsersByProject = {}, assignableUsersFetchedAt = {}, 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) {
168
218
  const filePath = metadataCachePath(profileName, configDir);
169
219
  try {
170
220
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
@@ -176,6 +226,10 @@ export function writeMetadataCache(profileName, { projects = [], issueTypesByPro
176
226
  issueTypesFetchedAt,
177
227
  assignableUsersByProject,
178
228
  assignableUsersFetchedAt,
229
+ prioritiesByProject,
230
+ prioritiesFetchedAt,
231
+ labelsByTeam,
232
+ labelsFetchedAt,
179
233
  }));
180
234
  } catch {
181
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
+ }