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.
- package/README.md +28 -5
- package/bin/ticketlens.mjs +43 -2
- package/package.json +1 -1
- package/skills/jtb/scripts/lib/adapters/github-adapter.mjs +130 -0
- package/skills/jtb/scripts/lib/adapters/jira-adapter.mjs +67 -1
- package/skills/jtb/scripts/lib/adapters/linear-adapter.mjs +192 -0
- package/skills/jtb/scripts/lib/cli.mjs +12 -0
- package/skills/jtb/scripts/lib/help.mjs +149 -7
- package/skills/jtb/scripts/lib/jira-client.mjs +183 -0
- package/skills/jtb/scripts/lib/mcp-server.mjs +141 -4
- package/skills/jtb/scripts/lib/ticket-command.mjs +400 -7
- package/skills/jtb/scripts/lib/ticket-create-enrichment.mjs +80 -0
- package/skills/jtb/scripts/lib/ticket-metadata-cache.mjs +84 -0
|
@@ -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
|
-
*
|
|
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
|
|
80
|
-
: ` Rate limited by the tracker — try
|
|
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
|
|
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})
|
|
118
|
+
return ` Tracker returned a server error (${classification.status}) creating the ticket. Try again later.\n`;
|
|
86
119
|
default:
|
|
87
|
-
return `
|
|
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(
|
|
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
|
+
}
|