ticketlens 0.26.0 → 0.30.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.
@@ -16,6 +16,8 @@ import { resolveConnection } from './profile-resolver.mjs';
16
16
  import { resolveAdapter } from './resolve-adapter.mjs';
17
17
  import { checkCooldown, recordAction } from './ticket-action-cooldown.mjs';
18
18
  import { logAction } from './ticket-action-log.mjs';
19
+ import { readMetadataCache, writeMetadataCache } from './ticket-metadata-cache.mjs';
20
+ import { detectProjectOrTypeError, enrichCreateFailure } from './ticket-create-enrichment.mjs';
19
21
  import { TICKET_KEY_PATTERN } from './cli.mjs';
20
22
  import { scoreCandidates } from './duplicate-scorer.mjs';
21
23
 
@@ -68,24 +70,91 @@ function formatWriteFailure(ticketKey, err) {
68
70
  * Read-path counterpart to formatWriteFailure — reuses the same
69
71
  * classification (rate-limit/timeout/server-error metadata is real and
70
72
  * worth keeping, not specific to writes) but with read-appropriate wording,
71
- * since "duplicates" never writes anything.
73
+ * parameterized by what's being checked (e.g. "for duplicates", "for link
74
+ * options") since neither duplicates nor link-list ever writes anything.
72
75
  */
76
+ function formatReadFailure(ticketKey, err, actionPhrase) {
77
+ const classification = classifyWriteFailure(err);
78
+ switch (classification.kind) {
79
+ case 'rate-limited': {
80
+ const wait = classification.detail.retryAfterSeconds ?? null;
81
+ return wait
82
+ ? ` Rate limited by the tracker — retry checking ${ticketKey} ${actionPhrase} after ~${wait}s.\n`
83
+ : ` Rate limited by the tracker — try checking ${ticketKey} ${actionPhrase} again later.\n`;
84
+ }
85
+ case 'network-or-timeout':
86
+ return ` Network error or timeout checking ${ticketKey} ${actionPhrase}. Try again.\n`;
87
+ case 'server-error':
88
+ return ` Tracker returned a server error (${classification.status}) checking ${ticketKey} ${actionPhrase}. Try again later.\n`;
89
+ default:
90
+ return ` Error checking ${ticketKey} ${actionPhrase}: ${err.message}\n`;
91
+ }
92
+ }
93
+
73
94
  function formatDuplicatesFailure(ticketKey, err) {
95
+ return formatReadFailure(ticketKey, err, 'for duplicates');
96
+ }
97
+
98
+ function formatLinkListFailure(ticketKey, err) {
99
+ return formatReadFailure(ticketKey, err, 'for link options');
100
+ }
101
+
102
+ /**
103
+ * Create-path counterpart to formatWriteFailure — same classification, but
104
+ * there is no ticket key to interpolate (creation never happened).
105
+ */
106
+ function formatCreateFailure(err) {
74
107
  const classification = classifyWriteFailure(err);
75
108
  switch (classification.kind) {
76
109
  case 'rate-limited': {
77
110
  const wait = classification.detail.retryAfterSeconds ?? null;
78
111
  return wait
79
- ? ` Rate limited by the tracker — retry checking ${ticketKey} after ~${wait}s.\n`
80
- : ` Rate limited by the tracker — try checking ${ticketKey} again later.\n`;
112
+ ? ` Rate limited by the tracker — retry creating the ticket after ~${wait}s.\n`
113
+ : ` Rate limited by the tracker — try creating the ticket again later.\n`;
81
114
  }
82
115
  case 'network-or-timeout':
83
- return ` Network error or timeout checking ${ticketKey} for duplicates. Try again.\n`;
116
+ return ` Network error or timeout creating the ticket — not retried automatically (a timed-out write may have already landed; check the tracker before retrying).\n`;
84
117
  case 'server-error':
85
- return ` Tracker returned a server error (${classification.status}) checking ${ticketKey} for duplicates. Try again later.\n`;
118
+ return ` Tracker returned a server error (${classification.status}) creating the ticket. Try again later.\n`;
86
119
  default:
87
- return ` Error checking ${ticketKey} for duplicates: ${err.message}\n`;
120
+ return ` Failed to create the ticket: ${err.message}\n`;
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Adapter error shapes for updateFields genuinely differ per tracker: a
126
+ * thrown Error (Jira/atomic-call failures, GitHub's shared title/description
127
+ * PATCH, GitHub's addLabels call), a { reason: 'not-found', missing/options }
128
+ * descriptor (Linear's pre-flight label/priority resolution), or a
129
+ * label -> Error map (GitHub's per-label DELETE loop, since each removal is
130
+ * independent and can fail differently). Never assume a single shape.
131
+ */
132
+ function formatFieldError(field, info) {
133
+ if (info instanceof Error) return `${field} (${info.message})`;
134
+ if (info?.reason === 'not-found') {
135
+ const list = info.missing ?? info.options ?? [];
136
+ return `${field} (not found${list.length ? `: ${list.join(', ')}` : ''})`;
88
137
  }
138
+ const perLabel = Object.entries(info ?? {}).map(([label, err]) => `${label}: ${err.message}`).join(', ');
139
+ return `${field} (${perLabel})`;
140
+ }
141
+
142
+ function describeAppliedFields(applied) {
143
+ const parts = [];
144
+ if (applied.title) parts.push('title');
145
+ if (applied.description) parts.push('description');
146
+ if (applied.priority) parts.push(`priority=${applied.priority}`);
147
+ if (applied.addLabels?.length) parts.push(`+labels(${applied.addLabels.join(', ')})`);
148
+ if (applied.removeLabels?.length) parts.push(`-labels(${applied.removeLabels.join(', ')})`);
149
+ return parts.join(', ');
150
+ }
151
+
152
+ function formatUpdateResult(ticketKey, { applied, errors }) {
153
+ const appliedText = describeAppliedFields(applied);
154
+ const errorText = Object.entries(errors).map(([field, info]) => formatFieldError(field, info)).join('; ');
155
+ if (appliedText && !errorText) return ` ${ticketKey} updated: ${appliedText}.\n`;
156
+ if (appliedText && errorText) return ` ${ticketKey} partially updated: ${appliedText}. Failed: ${errorText}.\n`;
157
+ return ` Nothing updated on ${ticketKey}. Failed: ${errorText}.\n`;
89
158
  }
90
159
 
91
160
  function requireLicense(isLicensedFn, configDir, commandName, stream) {
@@ -103,11 +172,19 @@ function requireTicketKey(cmdArgs, usage, stream) {
103
172
  return ticketKey;
104
173
  }
105
174
 
175
+ /**
176
+ * `ticketKey` is undefined for ticket_create — there is no existing ticket to
177
+ * prefix-match a connection from, so resolution falls through to --profile
178
+ * or the default profile (resolveConnectionFn already handles a falsy
179
+ * ticketKey by skipping prefix matching, see profile-resolver.mjs).
180
+ */
106
181
  function resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream }) {
107
182
  const profileName = parseFlag(cmdArgs, 'profile');
108
183
  const conn = resolveConnectionFn(ticketKey, { configDir, profileName });
109
184
  if (!conn.baseUrl) {
110
- stream.write(` No connection configured for ${ticketKey}. Run \`ticketlens init\`.\n`);
185
+ stream.write(ticketKey
186
+ ? ` No connection configured for ${ticketKey}. Run \`ticketlens init\`.\n`
187
+ : ` No connection configured. Run \`ticketlens init\` or pass --profile=NAME.\n`);
111
188
  return null;
112
189
  }
113
190
  return resolveAdapterFn(conn);
@@ -368,3 +445,319 @@ export async function runTicketDuplicates(cmdArgs, {
368
445
  return { ok: false };
369
446
  }
370
447
  }
448
+
449
+ /**
450
+ * Discovery only — never mutates. Lists the tracker's current available
451
+ * link types for sourceKey→targetKey. Jira's list is always fetched live
452
+ * (per-instance customizable — never cached, same principle as
453
+ * getTransitions). GitHub's "list" is really a single-item warning: its
454
+ * only link action closes sourceKey as a duplicate of targetKey, a
455
+ * materially louder operation than Jira/Linear's pure relationship-add,
456
+ * so that asymmetry is surfaced here before a caller ever reaches --confirm.
457
+ *
458
+ * @param {string[]} cmdArgs - [sourceKey, targetKey]
459
+ * @returns {Promise<{ ok: boolean, types?: string[] }>}
460
+ */
461
+ export async function runTicketLinkList(cmdArgs, {
462
+ configDir = DEFAULT_CONFIG_DIR,
463
+ stream = process.stderr,
464
+ isLicensedFn = isLicensed,
465
+ resolveConnectionFn = resolveConnection,
466
+ resolveAdapterFn = resolveAdapter,
467
+ } = {}) {
468
+ const usage = 'Usage: ticketlens link SOURCE-KEY TARGET-KEY [--type="..." --confirm]\n';
469
+ if (!requireLicense(isLicensedFn, configDir, 'ticketlens link', stream)) return { ok: false };
470
+
471
+ const sourceKey = requireTicketKey(cmdArgs, usage, stream);
472
+ if (!sourceKey) return { ok: false };
473
+ const targetKey = requireTicketKey(cmdArgs.slice(1), usage, stream);
474
+ if (!targetKey) return { ok: false };
475
+
476
+ const adapter = resolveTicketAdapter(sourceKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
477
+ if (!adapter) return { ok: false };
478
+
479
+ try {
480
+ const types = await adapter.getLinkTypes();
481
+ if (types.length === 0) {
482
+ stream.write(` No link types available for ${sourceKey} → ${targetKey} on ${adapter.type}.\n`);
483
+ return { ok: true, types: [] };
484
+ }
485
+ stream.write(` Available link types for ${sourceKey} → ${targetKey} (${adapter.type}):\n`);
486
+ for (const t of types) stream.write(` - ${t}\n`);
487
+ if (adapter.type === 'github') {
488
+ stream.write(` Note: GitHub has no generic link relationship — linking will CLOSE ${sourceKey} as a duplicate of ${targetKey}.\n`);
489
+ }
490
+ stream.write(` Run again with --type="<name>" --confirm to execute — ${sourceKey} will be recorded as the one that "types" ${targetKey}.\n`);
491
+ return { ok: true, types };
492
+ } catch (err) {
493
+ stream.write(formatLinkListFailure(sourceKey, err));
494
+ return { ok: false };
495
+ }
496
+ }
497
+
498
+ /**
499
+ * Executes a link. Requires both --type and --confirm — a type without
500
+ * confirm is incomplete input, never silently executed. Cooldown is keyed
501
+ * on the source:target pair (not sourceKey alone) so a second link to a
502
+ * different target isn't blocked by the debounce window; the audit log
503
+ * keeps ticketKey as the single valid sourceKey (logAction throws on
504
+ * anything else) with targetKey/type carried in detail instead.
505
+ *
506
+ * @param {string[]} cmdArgs - [sourceKey, targetKey, '--type=...', '--confirm']
507
+ * @returns {Promise<{ ok: boolean, reason?: string }>}
508
+ */
509
+ export async function runTicketLink(cmdArgs, {
510
+ configDir = DEFAULT_CONFIG_DIR,
511
+ stream = process.stderr,
512
+ isLicensedFn = isLicensed,
513
+ resolveConnectionFn = resolveConnection,
514
+ resolveAdapterFn = resolveAdapter,
515
+ checkCooldownFn = checkCooldown,
516
+ recordActionFn = recordAction,
517
+ logActionFn = logAction,
518
+ actor = os.userInfo().username,
519
+ } = {}) {
520
+ const usage = 'Usage: ticketlens link SOURCE-KEY TARGET-KEY --type="..." --confirm\n';
521
+ if (!requireLicense(isLicensedFn, configDir, 'ticketlens link', stream)) return { ok: false };
522
+
523
+ const sourceKey = requireTicketKey(cmdArgs, usage, stream);
524
+ if (!sourceKey) return { ok: false };
525
+ const targetKey = requireTicketKey(cmdArgs.slice(1), usage, stream);
526
+ if (!targetKey) return { ok: false };
527
+
528
+ const type = parseFlag(cmdArgs, 'type');
529
+ if (!type) {
530
+ stream.write(usage);
531
+ return { ok: false };
532
+ }
533
+ if (!cmdArgs.includes('--confirm')) {
534
+ stream.write(` Refusing to link ${sourceKey} to ${targetKey} as "${type}" without --confirm. Re-run with --confirm once you've reviewed the target.\n`);
535
+ return { ok: false };
536
+ }
537
+
538
+ const cooldownKey = `${sourceKey}:${targetKey}`;
539
+ const cooldown = checkCooldownFn(cooldownKey, 'link', { configDir });
540
+ if (cooldown.active) {
541
+ stream.write(` Skipped — ${sourceKey} was already linked to ${targetKey} ${Math.ceil(cooldown.remainingMs / 1000)}s ago. Wait a moment before retrying.\n`);
542
+ return { ok: false };
543
+ }
544
+
545
+ const adapter = resolveTicketAdapter(sourceKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
546
+ if (!adapter) return { ok: false };
547
+
548
+ if (adapter.type === 'github' && type.toLowerCase() !== 'duplicate') {
549
+ stream.write(` GitHub only supports linking as a duplicate — no generic link types. Got type "${type}".\n`);
550
+ return { ok: false };
551
+ }
552
+ if (adapter.type === 'github') {
553
+ stream.write(` Note: this will CLOSE ${sourceKey} as a duplicate of ${targetKey} on GitHub.\n`);
554
+ }
555
+
556
+ try {
557
+ const result = await adapter.linkTo(sourceKey, targetKey, type);
558
+ if (!result.executed) {
559
+ const optionsHint = result.options?.length ? ` Valid options: ${result.options.join(', ')}.` : '';
560
+ stream.write(` Not linked — ${result.reason}.${optionsHint}\n`);
561
+ return { ok: false, reason: result.reason };
562
+ }
563
+ recordActionFn(cooldownKey, 'link', { configDir });
564
+ logActionFn({ ticketKey: sourceKey, action: 'link', actor, tracker: adapter.type, detail: { targetKey, type } }, { configDir });
565
+ stream.write(
566
+ adapter.type === 'github'
567
+ ? ` ${sourceKey} closed as a duplicate of ${targetKey}.\n`
568
+ : ` ${sourceKey} linked to ${targetKey} as "${type}".\n`,
569
+ );
570
+ return { ok: true };
571
+ } catch (err) {
572
+ stream.write(formatWriteFailure(sourceKey, err));
573
+ return { ok: false };
574
+ }
575
+ }
576
+
577
+ /**
578
+ * Updates a narrow, named field set (title, description, labels, priority).
579
+ * No --confirm gate, unlike transition/link: those two have a list-then-act
580
+ * discovery step that --confirm gates the boundary of; update has none
581
+ * (priority validity surfaces the tracker's own error, same choice already
582
+ * made for ticket_create's issuetype) and its edits are reversible metadata
583
+ * changes with no workflow-state side effects — same risk tier as assign,
584
+ * which also ships with no --confirm.
585
+ *
586
+ * updateFields' result shape genuinely differs by how atomic each tracker's
587
+ * write is: Jira/Linear do it in one call and either fully succeed or throw
588
+ * (caught below, same as every other write); GitHub's title/description and
589
+ * label operations are independent HTTP calls, so it always returns
590
+ * { applied, errors } even on total failure. Whatever DID apply is still
591
+ * recorded/logged — a caller needs the cooldown to reflect a real partial
592
+ * write, and the audit trail should show what actually changed even if not
593
+ * everything did.
594
+ *
595
+ * @param {string[]} cmdArgs - [ticketKey, '--title=...', '--description=...', '--add-labels=a,b', '--remove-labels=c', '--priority=...']
596
+ * @returns {Promise<{ ok: boolean, applied?: object, errors?: object }>}
597
+ */
598
+ export async function runTicketUpdate(cmdArgs, {
599
+ configDir = DEFAULT_CONFIG_DIR,
600
+ stream = process.stderr,
601
+ isLicensedFn = isLicensed,
602
+ resolveConnectionFn = resolveConnection,
603
+ resolveAdapterFn = resolveAdapter,
604
+ checkCooldownFn = checkCooldown,
605
+ recordActionFn = recordAction,
606
+ logActionFn = logAction,
607
+ actor = os.userInfo().username,
608
+ } = {}) {
609
+ const usage = 'Usage: ticketlens update TICKET-KEY [--title="..."] [--description="..."] [--add-labels=a,b] [--remove-labels=c] [--priority="High"]\n';
610
+ if (!requireLicense(isLicensedFn, configDir, 'ticketlens update', stream)) return { ok: false };
611
+
612
+ const ticketKey = requireTicketKey(cmdArgs, usage, stream);
613
+ if (!ticketKey) return { ok: false };
614
+
615
+ const title = parseFlag(cmdArgs, 'title');
616
+ const description = parseFlag(cmdArgs, 'description');
617
+ const priority = parseFlag(cmdArgs, 'priority');
618
+ const addLabelsArg = parseFlag(cmdArgs, 'add-labels');
619
+ const removeLabelsArg = parseFlag(cmdArgs, 'remove-labels');
620
+ const addLabels = addLabelsArg ? addLabelsArg.split(',').map(l => l.trim()).filter(Boolean) : undefined;
621
+ const removeLabels = removeLabelsArg ? removeLabelsArg.split(',').map(l => l.trim()).filter(Boolean) : undefined;
622
+
623
+ if (title === undefined && description === undefined && priority === undefined && !addLabels?.length && !removeLabels?.length) {
624
+ stream.write(usage);
625
+ return { ok: false };
626
+ }
627
+
628
+ const cooldown = checkCooldownFn(ticketKey, 'update', { configDir });
629
+ if (cooldown.active) {
630
+ stream.write(` Skipped — ${ticketKey} was already updated ${Math.ceil(cooldown.remainingMs / 1000)}s ago. Wait a moment before retrying.\n`);
631
+ return { ok: false };
632
+ }
633
+
634
+ const adapter = resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
635
+ if (!adapter) return { ok: false };
636
+
637
+ if (adapter.type === 'github' && priority !== undefined) {
638
+ stream.write(` GitHub Issues have no native priority field — cannot update priority on ${ticketKey}. Remove --priority and retry.\n`);
639
+ return { ok: false };
640
+ }
641
+
642
+ try {
643
+ const result = await adapter.updateFields(ticketKey, { title, description, priority, addLabels, removeLabels });
644
+ const hasApplied = Object.keys(result.applied).length > 0;
645
+ const hasErrors = Object.keys(result.errors).length > 0;
646
+
647
+ if (hasApplied) {
648
+ recordActionFn(ticketKey, 'update', { configDir });
649
+ logActionFn({ ticketKey, action: 'update', actor, tracker: adapter.type, detail: { ...result.applied, failed: Object.keys(result.errors) } }, { configDir });
650
+ }
651
+ stream.write(formatUpdateResult(ticketKey, result));
652
+ return hasErrors ? { ok: false, applied: result.applied, errors: result.errors } : { ok: true, applied: result.applied };
653
+ } catch (err) {
654
+ stream.write(formatWriteFailure(ticketKey, err));
655
+ return { ok: false };
656
+ }
657
+ }
658
+
659
+ /**
660
+ * Creates a new ticket in the tracker (Jira/GitHub/Linear) — architecturally
661
+ * unlike every other write in this family: there is no existing ticket key
662
+ * to resolve a connection from, so --profile (or the default profile) picks
663
+ * the target tracker instead of ticket-prefix matching. --project is the
664
+ * project key (Jira) or team key (Linear) to create in — GitHub ignores it,
665
+ * its target repo is fixed by the profile. --type (Jira issuetype) is
666
+ * Jira-only; GitHub/Linear have no equivalent concept and ignore it with a
667
+ * warning — unlike an extra --project on GitHub, which is dropped silently,
668
+ * since a stray --type usually means the caller thought they were talking to
669
+ * Jira and should hear otherwise. Highest blast radius of the whole write
670
+ * family — a bad project/issuetype fabricates a real,
671
+ * hard-to-walk-back item in a live tracker — so, unlike update/assign, the
672
+ * cooldown key is derived from (project, type, summary) rather than a
673
+ * ticket key, guarding against exactly the flaky-retry double-creation
674
+ * scenario this whole mechanism exists to catch. No --confirm gate: same
675
+ * "no discovery step, reversible-enough risk tier" reasoning already
676
+ * applied to update/assign — the terminal errors below (missing --project/
677
+ * --type, an unresolvable Linear team, a bad Jira issuetype) are the
678
+ * safeguard, not a confirmation prompt.
679
+ *
680
+ * @param {string[]} cmdArgs - ['--project=...', '--type=...', '--summary=...', '--description=...', '--profile=...']
681
+ * @returns {Promise<{ ok: boolean, key?: string }>}
682
+ */
683
+ export async function runTicketCreate(cmdArgs, {
684
+ configDir = DEFAULT_CONFIG_DIR,
685
+ stream = process.stderr,
686
+ isLicensedFn = isLicensed,
687
+ resolveConnectionFn = resolveConnection,
688
+ resolveAdapterFn = resolveAdapter,
689
+ checkCooldownFn = checkCooldown,
690
+ recordActionFn = recordAction,
691
+ logActionFn = logAction,
692
+ readMetadataCacheFn = readMetadataCache,
693
+ writeMetadataCacheFn = writeMetadataCache,
694
+ actor = os.userInfo().username,
695
+ } = {}) {
696
+ const usage = 'Usage: ticketlens create --project=KEY --type="Task" --summary="..." [--description="..."] [--profile=NAME]\n';
697
+ if (!requireLicense(isLicensedFn, configDir, 'ticketlens create', stream)) return { ok: false };
698
+
699
+ const summary = parseFlag(cmdArgs, 'summary');
700
+ if (!summary) {
701
+ stream.write(usage);
702
+ return { ok: false };
703
+ }
704
+
705
+ const project = parseFlag(cmdArgs, 'project');
706
+ const type = parseFlag(cmdArgs, 'type');
707
+ const description = parseFlag(cmdArgs, 'description');
708
+
709
+ const adapter = resolveTicketAdapter(undefined, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
710
+ if (!adapter) return { ok: false };
711
+
712
+ if (adapter.type !== 'github' && !project) {
713
+ stream.write(` --project is required for ${adapter.type === 'jira' ? 'Jira (project key)' : 'Linear (team key)'}.\n`);
714
+ return { ok: false };
715
+ }
716
+ if (adapter.type === 'jira' && !type) {
717
+ stream.write(` --type is required for Jira (issue type, e.g. "Task" or "Bug").\n`);
718
+ return { ok: false };
719
+ }
720
+ if (adapter.type !== 'jira' && type !== undefined) {
721
+ stream.write(` Note: --type is ignored by ${adapter.type} — issue created without it.\n`);
722
+ }
723
+
724
+ // JSON-encoded, not naively colon-joined — project/type/summary are free
725
+ // text that can themselves contain ":", which would let two genuinely
726
+ // different tuples collide onto the same cooldown key.
727
+ const cooldownKey = `create:${JSON.stringify([project ?? '', type ?? '', summary])}`;
728
+ const cooldown = checkCooldownFn(cooldownKey, 'create', { configDir });
729
+ if (cooldown.active) {
730
+ stream.write(` Skipped — a ticket with this summary was already created ${Math.ceil(cooldown.remainingMs / 1000)}s ago. Wait a moment before retrying.\n`);
731
+ return { ok: false };
732
+ }
733
+
734
+ let result;
735
+ try {
736
+ result = await adapter.createTicket({ project, type, summary, description });
737
+ } catch (err) {
738
+ // profileName is only resolved when this failure is actually
739
+ // project/issuetype-shaped — not on every failure, and never on the
740
+ // success path — since it exists solely to scope the enrichment cache.
741
+ let enrichment = '';
742
+ if (detectProjectOrTypeError(err)) {
743
+ const profileName = resolveConnectionFn(undefined, { configDir, profileName: parseFlag(cmdArgs, 'profile') }).profileName;
744
+ enrichment = await enrichCreateFailure(err, { adapter, project, profileName, configDir, readMetadataCacheFn, writeMetadataCacheFn });
745
+ }
746
+ stream.write(formatCreateFailure(err) + enrichment);
747
+ return { ok: false };
748
+ }
749
+
750
+ // The write already landed — a real, external, hard-to-walk-back ticket
751
+ // now exists. From here on, nothing may report this as a failed write:
752
+ // cooldown/audit bookkeeping is best-effort, never the reason a real
753
+ // success gets mistaken for one (which risks a caller retrying and
754
+ // fabricating a genuine duplicate).
755
+ try {
756
+ recordActionFn(cooldownKey, 'create', { configDir });
757
+ logActionFn({ ticketKey: result.key, action: 'create', actor, tracker: adapter.type, detail: { project, type } }, { configDir });
758
+ } catch (bookkeepingErr) {
759
+ stream.write(` Warning: ${result.key} was created but could not be logged: ${bookkeepingErr.message}\n`);
760
+ }
761
+ stream.write(` Created ${result.key}${result.url ? ` (${result.url})` : ''}\n`);
762
+ return { ok: true, key: result.key };
763
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Failure-message enrichment for `ticket_create` — reactive only, never
3
+ * runs on the success path. Extracted from ticket-command.mjs to keep that
4
+ * file under the project's 800-line cap and to isolate a self-contained
5
+ * concern (project/issuetype cache read-through-and-refresh) from the rest
6
+ * of ticket-command.mjs's routing logic.
7
+ */
8
+
9
+ /**
10
+ * Detects whether a create failure is shaped like a project/issuetype
11
+ * mismatch — the only case cache-refresh enrichment applies to. Jira
12
+ * surfaces this via its own real `err.details.errors.{project,issuetype}`
13
+ * keys (confirmed by direct observation against a live instance during
14
+ * ticket_create's own launch verification); Linear's client-side
15
+ * team-resolution failure is marked with `err.code` instead of
16
+ * message-sniffed. Anything else (rate limits, network errors, generic
17
+ * 4xx/5xx) returns null — enrichment never applies there.
18
+ */
19
+ export function detectProjectOrTypeError(err) {
20
+ if (err?.code === 'PROJECT_NOT_FOUND') return { project: true, type: false };
21
+ const errors = err?.details?.errors;
22
+ if (!errors) return null;
23
+ const project = 'project' in errors;
24
+ const type = 'issuetype' in errors;
25
+ return (project || type) ? { project, type } : null;
26
+ }
27
+
28
+ /**
29
+ * Best-effort failure-message enrichment for ticket_create — reactive
30
+ * only, never runs on the success path or for a non-project/type failure.
31
+ * Reuses a cached project/issue-type listing when fresh (no extra network
32
+ * call); refreshes it when missing/stale. A refresh failure is swallowed
33
+ * entirely and nothing is written to the cache: this can only ever make
34
+ * an error message MORE informative, never introduce a new way for
35
+ * ticketlens create to fail or a new way to poison the cache.
36
+ */
37
+ export async function enrichCreateFailure(err, { adapter, project, profileName, configDir, readMetadataCacheFn, writeMetadataCacheFn }) {
38
+ const shape = detectProjectOrTypeError(err);
39
+ if (!shape || adapter.type === 'github') return '';
40
+
41
+ let cached = readMetadataCacheFn(profileName, configDir);
42
+ // Checked independently, not "cache present? skip entirely" — a cache
43
+ // populated by an earlier *project* error has projects but no issue
44
+ // types for this specific project, and vice versa. Treating any cache
45
+ // hit as fully sufficient silently drops the other half of a later,
46
+ // differently-shaped error's enrichment (caught via live-instance
47
+ // testing, not by unit tests alone).
48
+ const needsProjects = shape.project && !cached?.projects?.length;
49
+ const needsIssueTypes = shape.type && adapter.type === 'jira' && project && !cached?.issueTypesByProject?.[project]?.length;
50
+
51
+ if (needsProjects || needsIssueTypes) {
52
+ try {
53
+ const projects = needsProjects ? await adapter.listCreatableProjects() : (cached?.projects ?? []);
54
+ // Object.create(null), not {} — `project` is an unvalidated CLI value
55
+ // reaching this key position. On a plain {}, assigning to a key like
56
+ // "__proto__" redirects into the object's own prototype slot instead
57
+ // of creating a real entry, silently losing this project's cache
58
+ // write. A null-prototype target has no such accessor to intercept.
59
+ const issueTypesByProject = Object.assign(Object.create(null), cached?.issueTypesByProject ?? {});
60
+ if (needsIssueTypes) {
61
+ issueTypesByProject[project] = await adapter.listIssueTypes(project);
62
+ }
63
+ cached = { projects, issueTypesByProject };
64
+ writeMetadataCacheFn(profileName, cached, configDir);
65
+ } catch {
66
+ return '';
67
+ }
68
+ }
69
+
70
+ if (!cached) return '';
71
+
72
+ const parts = [];
73
+ if (shape.project && cached.projects?.length) {
74
+ parts.push(` Known creatable projects: ${cached.projects.map(p => p.key).join(', ')}.\n`);
75
+ }
76
+ if (shape.type && project && cached.issueTypesByProject?.[project]?.length) {
77
+ parts.push(` Known issue types for ${project}: ${cached.issueTypesByProject[project].map(t => t.name).join(', ')}.\n`);
78
+ }
79
+ return parts.join('');
80
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Project/issue-type metadata cache for `ticketlens create` — stores what
3
+ * this profile has actually confirmed it can create against (real project
4
+ * keys, real Jira issue types per project), refreshed only when a create
5
+ * attempt fails with a project/issuetype-shaped error. Never populated on
6
+ * the success path: that data only has value for enriching a failure
7
+ * message, so fetching it on every create call "just in case" would waste
8
+ * a network round-trip for the common case where the caller already got
9
+ * project/type right.
10
+ *
11
+ * Path: ~/.ticketlens/cache/PROFILE/ticket-metadata.json
12
+ * Format: { fetchedAt, projects: [{key, name}], issueTypesByProject: {KEY: [{id, name}]} }
13
+ * TTL: 24 hours (bypassed by an always-forced refresh from the caller
14
+ * after a project/issuetype error — see ticket-command.mjs)
15
+ */
16
+
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import { DEFAULT_CONFIG_DIR } from './config.mjs';
20
+
21
+ export const METADATA_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
22
+
23
+ /**
24
+ * Returns the absolute path to the ticket-metadata cache file for a profile.
25
+ */
26
+ export function metadataCachePath(profileName, configDir = DEFAULT_CONFIG_DIR) {
27
+ const safeProfile = (profileName || '_default').replace(/[^a-zA-Z0-9_\-]/g, '_');
28
+ const resolvedDir = path.resolve(configDir);
29
+ const result = path.join(resolvedDir, 'cache', safeProfile, 'ticket-metadata.json');
30
+ // Defense-in-depth: ensure the final path cannot escape the config directory,
31
+ // even if configDir itself is manipulated or the sanitization above is weakened.
32
+ if (!result.startsWith(resolvedDir + path.sep)) {
33
+ throw new Error(`Cache path escapes config directory: ${result}`);
34
+ }
35
+ return result;
36
+ }
37
+
38
+ /**
39
+ * Reads cached project/issue-type metadata for a profile.
40
+ * Returns null on cache miss, expired TTL, or corrupt JSON.
41
+ *
42
+ * @param {string|null} profileName
43
+ * @param {string} [configDir]
44
+ * @param {number} [ttlMs] - override TTL in ms; defaults to METADATA_TTL_MS (24h)
45
+ * @returns {{ projects: {key:string,name:string}[], issueTypesByProject: object, fetchedAt: string } | null}
46
+ */
47
+ export function readMetadataCache(profileName, configDir = DEFAULT_CONFIG_DIR, ttlMs = METADATA_TTL_MS) {
48
+ const filePath = metadataCachePath(profileName, configDir);
49
+ if (!fs.existsSync(filePath)) return null;
50
+
51
+ let data;
52
+ try {
53
+ data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
54
+ } catch {
55
+ return null;
56
+ }
57
+
58
+ const age = Date.now() - new Date(data.fetchedAt).getTime();
59
+ if (isNaN(age) || age > ttlMs) {
60
+ try { fs.unlinkSync(filePath); } catch { /* non-fatal */ }
61
+ return null;
62
+ }
63
+
64
+ return {
65
+ projects: data.projects ?? [],
66
+ issueTypesByProject: data.issueTypesByProject ?? {},
67
+ fetchedAt: data.fetchedAt,
68
+ };
69
+ }
70
+
71
+ /**
72
+ * Writes project/issue-type metadata to the cache. Non-fatal — a write
73
+ * failure must never break the caller (an enrichment attempt after an
74
+ * already-failed create).
75
+ */
76
+ export function writeMetadataCache(profileName, { projects = [], issueTypesByProject = {} } = {}, configDir = DEFAULT_CONFIG_DIR) {
77
+ const filePath = metadataCachePath(profileName, configDir);
78
+ try {
79
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
80
+ fs.writeFileSync(filePath, JSON.stringify({ fetchedAt: new Date().toISOString(), projects, issueTypesByProject }));
81
+ } catch {
82
+ // Non-fatal
83
+ }
84
+ }