ticketlens 0.1.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.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +457 -0
  3. package/bin/ticketlens.mjs +376 -0
  4. package/package.json +37 -0
  5. package/skills/jtb/scripts/fetch-my-tickets.mjs +377 -0
  6. package/skills/jtb/scripts/fetch-ticket.mjs +682 -0
  7. package/skills/jtb/scripts/lib/adapters/github-adapter.mjs +112 -0
  8. package/skills/jtb/scripts/lib/adapters/jira-adapter.mjs +19 -0
  9. package/skills/jtb/scripts/lib/adf-converter.mjs +67 -0
  10. package/skills/jtb/scripts/lib/ansi.mjs +87 -0
  11. package/skills/jtb/scripts/lib/arg-validator.mjs +178 -0
  12. package/skills/jtb/scripts/lib/attachment-downloader.mjs +123 -0
  13. package/skills/jtb/scripts/lib/attention-scorer.mjs +152 -0
  14. package/skills/jtb/scripts/lib/banner.mjs +201 -0
  15. package/skills/jtb/scripts/lib/brief-assembler.mjs +137 -0
  16. package/skills/jtb/scripts/lib/brief-cache.mjs +137 -0
  17. package/skills/jtb/scripts/lib/budget-pruner.mjs +242 -0
  18. package/skills/jtb/scripts/lib/cache-manager.mjs +499 -0
  19. package/skills/jtb/scripts/lib/cli-auth.mjs +40 -0
  20. package/skills/jtb/scripts/lib/cli.mjs +87 -0
  21. package/skills/jtb/scripts/lib/code-ref-parser.mjs +113 -0
  22. package/skills/jtb/scripts/lib/commit-linker.mjs +42 -0
  23. package/skills/jtb/scripts/lib/compliance-checker.mjs +92 -0
  24. package/skills/jtb/scripts/lib/config-wizard.mjs +392 -0
  25. package/skills/jtb/scripts/lib/config.mjs +63 -0
  26. package/skills/jtb/scripts/lib/diff-analyzer.mjs +66 -0
  27. package/skills/jtb/scripts/lib/drift-tracker.mjs +120 -0
  28. package/skills/jtb/scripts/lib/error-classifier.mjs +119 -0
  29. package/skills/jtb/scripts/lib/help.mjs +253 -0
  30. package/skills/jtb/scripts/lib/hook-installer.mjs +81 -0
  31. package/skills/jtb/scripts/lib/init-wizard.mjs +508 -0
  32. package/skills/jtb/scripts/lib/interactive-list.mjs +257 -0
  33. package/skills/jtb/scripts/lib/jira-client.mjs +169 -0
  34. package/skills/jtb/scripts/lib/ledger.mjs +96 -0
  35. package/skills/jtb/scripts/lib/license.mjs +195 -0
  36. package/skills/jtb/scripts/lib/pr-assembler.mjs +186 -0
  37. package/skills/jtb/scripts/lib/profile-picker.mjs +216 -0
  38. package/skills/jtb/scripts/lib/profile-resolver.mjs +236 -0
  39. package/skills/jtb/scripts/lib/profile-switcher.mjs +147 -0
  40. package/skills/jtb/scripts/lib/prompt-helpers.mjs +122 -0
  41. package/skills/jtb/scripts/lib/requirement-extractor.mjs +52 -0
  42. package/skills/jtb/scripts/lib/resolve-adapter.mjs +28 -0
  43. package/skills/jtb/scripts/lib/schedule-wizard.mjs +153 -0
  44. package/skills/jtb/scripts/lib/select-prompt.mjs +106 -0
  45. package/skills/jtb/scripts/lib/spinner.mjs +44 -0
  46. package/skills/jtb/scripts/lib/styled-assembler.mjs +183 -0
  47. package/skills/jtb/scripts/lib/summarizer.mjs +109 -0
  48. package/skills/jtb/scripts/lib/sync.mjs +119 -0
  49. package/skills/jtb/scripts/lib/table-formatter.mjs +48 -0
  50. package/skills/jtb/scripts/lib/triage-exporter.mjs +93 -0
  51. package/skills/jtb/scripts/lib/triage-history.mjs +166 -0
  52. package/skills/jtb/scripts/lib/triage-push.mjs +98 -0
  53. package/skills/jtb/scripts/lib/usage-tracker.mjs +54 -0
  54. package/skills/jtb/scripts/lib/vcs-detector.mjs +12 -0
@@ -0,0 +1,153 @@
1
+ import { writeFileSync, mkdirSync, unlinkSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir, platform as osPlatform } from 'node:os';
4
+ import { spawnSync } from 'node:child_process';
5
+
6
+ const SCHEDULE_URL = 'https://api.ticketlens.dev/v1/schedule';
7
+
8
+ /**
9
+ * Build a macOS LaunchAgent plist string.
10
+ */
11
+ export function buildPlist({ hour, minute, ticketlensBin }) {
12
+ if (!Number.isInteger(hour) || !Number.isInteger(minute)) {
13
+ throw new Error('hour and minute must be integers');
14
+ }
15
+ return `<?xml version="1.0" encoding="UTF-8"?>
16
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
17
+ <plist version="1.0">
18
+ <dict>
19
+ <key>Label</key>
20
+ <string>io.ticketlens.digest</string>
21
+ <key>ProgramArguments</key>
22
+ <array>
23
+ <string>${ticketlensBin}</string>
24
+ <string>triage</string>
25
+ <string>--digest</string>
26
+ </array>
27
+ <key>StartCalendarInterval</key>
28
+ <dict>
29
+ <key>Hour</key>
30
+ <integer>${hour}</integer>
31
+ <key>Minute</key>
32
+ <integer>${minute}</integer>
33
+ </dict>
34
+ <key>StandardOutPath</key>
35
+ <string>/tmp/ticketlens-digest.log</string>
36
+ <key>StandardErrorPath</key>
37
+ <string>/tmp/ticketlens-digest.log</string>
38
+ </dict>
39
+ </plist>`;
40
+ }
41
+
42
+ /**
43
+ * Build a crontab line for Linux.
44
+ */
45
+ export function buildCronLine({ hour, minute, ticketlensBin }) {
46
+ return `${minute} ${hour} * * * ${ticketlensBin} triage --digest >> /tmp/ticketlens-digest.log 2>&1`;
47
+ }
48
+
49
+ /**
50
+ * Register a digest schedule with the backend and create a local cron/LaunchAgent job.
51
+ */
52
+ export async function runScheduleWizard({
53
+ answers,
54
+ fetcher = globalThis.fetch,
55
+ licenseKey,
56
+ configDir,
57
+ platform = osPlatform(),
58
+ writeLocalJob = defaultWriteLocalJob,
59
+ timeoutMs = 10_000,
60
+ }) {
61
+ const { time, email, timezone } = answers;
62
+ const [hourStr, minuteStr] = time.split(':');
63
+ const hour = parseInt(hourStr, 10);
64
+ const minute = parseInt(minuteStr, 10);
65
+
66
+ const res = await fetcher(SCHEDULE_URL, {
67
+ method: 'POST',
68
+ signal: AbortSignal.timeout(timeoutMs),
69
+ headers: {
70
+ 'Content-Type': 'application/json',
71
+ 'Authorization': `Bearer ${licenseKey}`,
72
+ },
73
+ body: JSON.stringify({ email, timezone, deliverAt: time }),
74
+ });
75
+
76
+ if (!res.ok) {
77
+ const err = new Error(`Schedule API error ${res.status}`);
78
+ err.status = res.status;
79
+ throw err;
80
+ }
81
+
82
+ const data = await res.json();
83
+
84
+ const ticketlensBin = resolveTicketlensBin();
85
+ const content = platform === 'darwin'
86
+ ? buildPlist({ hour, minute, ticketlensBin })
87
+ : buildCronLine({ hour, minute, ticketlensBin });
88
+
89
+ writeLocalJob(content, platform);
90
+
91
+ return data;
92
+ }
93
+
94
+ export async function runScheduleStop({ fetcher = globalThis.fetch, licenseKey, platform = osPlatform() }) {
95
+ const res = await fetcher(SCHEDULE_URL, {
96
+ method: 'DELETE',
97
+ signal: AbortSignal.timeout(10_000),
98
+ headers: { 'Authorization': `Bearer ${licenseKey}` },
99
+ });
100
+ if (!res.ok) throw new Error(`Schedule API error ${res.status}`);
101
+
102
+ if (platform === 'darwin') {
103
+ const plistPath = join(homedir(), 'Library', 'LaunchAgents', 'io.ticketlens.digest.plist');
104
+ spawnSync('launchctl', ['unload', plistPath], { encoding: 'utf8' });
105
+ try { unlinkSync(plistPath); } catch { /* already removed */ }
106
+ } else {
107
+ const existing = spawnSync('crontab', ['-l'], { encoding: 'utf8' }).stdout ?? '';
108
+ const updated = existing.replace(/.*ticketlens triage --digest.*\n?/g, '');
109
+ const tmp = `/tmp/ticketlens-crontab-${Date.now()}`;
110
+ writeFileSync(tmp, updated, 'utf8');
111
+ spawnSync('crontab', [tmp], { encoding: 'utf8' });
112
+ }
113
+
114
+ process.stdout.write('✔ Digest schedule removed.\n');
115
+ }
116
+
117
+ export async function runScheduleStatus({ fetcher = globalThis.fetch, licenseKey }) {
118
+ const res = await fetcher(SCHEDULE_URL, {
119
+ method: 'GET',
120
+ signal: AbortSignal.timeout(10_000),
121
+ headers: { 'Authorization': `Bearer ${licenseKey}` },
122
+ });
123
+ if (!res.ok) {
124
+ process.stdout.write('No active digest schedule found.\n');
125
+ return;
126
+ }
127
+ const data = await res.json();
128
+ process.stdout.write(`Digest schedule: ${data.deliverAt} ${data.timezone}\n`);
129
+ process.stdout.write(`Last delivered: ${data.lastDeliveredAt ?? 'never'}\n`);
130
+ process.stdout.write(`Next delivery: ${data.nextDelivery}\n`);
131
+ }
132
+
133
+ function resolveTicketlensBin() {
134
+ const which = spawnSync('which', ['ticketlens'], { encoding: 'utf8' });
135
+ if (which.status === 0 && which.stdout.trim()) return which.stdout.trim();
136
+ return `${homedir()}/.npm/bin/ticketlens`;
137
+ }
138
+
139
+ function defaultWriteLocalJob(content, platform) {
140
+ if (platform === 'darwin') {
141
+ const launchAgentsDir = join(homedir(), 'Library', 'LaunchAgents');
142
+ mkdirSync(launchAgentsDir, { recursive: true });
143
+ const plistPath = join(launchAgentsDir, 'io.ticketlens.digest.plist');
144
+ writeFileSync(plistPath, content, 'utf8');
145
+ spawnSync('launchctl', ['load', plistPath], { encoding: 'utf8' });
146
+ } else {
147
+ const existing = spawnSync('crontab', ['-l'], { encoding: 'utf8' }).stdout ?? '';
148
+ const updated = existing.replace(/.*ticketlens triage --digest.*/g, '').trimEnd() + '\n' + content + '\n';
149
+ const tmp = `/tmp/ticketlens-crontab-${Date.now()}`;
150
+ writeFileSync(tmp, updated, 'utf8');
151
+ spawnSync('crontab', [tmp], { encoding: 'utf8' });
152
+ }
153
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Reusable raw-mode selector primitive and simple promptSelect helper.
3
+ * Used by profile-picker.mjs, profile-switcher.mjs, and init-wizard.mjs.
4
+ */
5
+
6
+ import { createStyler } from './ansi.mjs';
7
+
8
+ /**
9
+ * Low-level raw-mode selector. Handles stdin lifecycle, arrow keys, Enter/Esc.
10
+ * Calls renderFn(selectedIndex) on each state change; renderFn must write its
11
+ * output to the stream and return the number of lines written.
12
+ *
13
+ * @param {object} opts
14
+ * @param {number} opts.count
15
+ * @param {number} [opts.initialIndex=0]
16
+ * @param {(index: number) => number} opts.renderFn
17
+ * @param {NodeJS.WriteStream} [opts.stream=process.stderr]
18
+ * @returns {Promise<number|null>} selected index, or null if cancelled
19
+ */
20
+ export function runRawSelect({ count, initialIndex = 0, renderFn, stream = process.stderr }) {
21
+ if (!stream.isTTY || !process.stdin.setRawMode) return Promise.resolve(null);
22
+
23
+ let selected = initialIndex;
24
+ let lineCount = 0;
25
+
26
+ function erase() {
27
+ for (let i = 0; i < lineCount; i++) stream.write('\x1b[A\r\x1b[2K');
28
+ }
29
+
30
+ function render() {
31
+ erase();
32
+ lineCount = renderFn(selected);
33
+ }
34
+
35
+ return new Promise((resolve) => {
36
+ const stdin = process.stdin;
37
+ const wasRaw = stdin.isRaw;
38
+
39
+ function cleanup() {
40
+ stream.write('\x1b[?25h');
41
+ stdin.setRawMode(wasRaw ?? false);
42
+ stdin.pause();
43
+ stdin.removeListener('data', onData);
44
+ }
45
+
46
+ function onData(buf) {
47
+ const key = buf.toString();
48
+ if (key === '\x03' || key === '\x1b' || key === 'q' || key === 'Q') {
49
+ cleanup(); erase(); resolve(null); return;
50
+ }
51
+ if (key === '\x1b[A' && selected > 0) { selected--; render(); return; }
52
+ if (key === '\x1b[B' && selected < count - 1) { selected++; render(); return; }
53
+ if (key === '\r' || key === '\n') { cleanup(); erase(); resolve(selected); return; }
54
+ }
55
+
56
+ stream.write('\x1b[?25l');
57
+ stdin.setRawMode(true);
58
+ stdin.resume();
59
+ stdin.setEncoding('utf8');
60
+ stdin.on('data', onData);
61
+ lineCount = renderFn(selected);
62
+ });
63
+ }
64
+
65
+ /**
66
+ * Simple arrow-key list selector with ❯ marker and optional sublabels.
67
+ *
68
+ * @param {Array<{label: string, sublabel?: string}>} items
69
+ * @param {object} [opts]
70
+ * @param {number} [opts.initialIndex=0]
71
+ * @param {string} [opts.hint]
72
+ * @param {NodeJS.WriteStream} [opts.stream]
73
+ * @returns {Promise<number|null>}
74
+ */
75
+ export function promptSelect(items, opts = {}) {
76
+ const {
77
+ stream = process.stderr,
78
+ hint = '↑/↓ select Enter confirm Esc cancel',
79
+ initialIndex = 0,
80
+ } = opts;
81
+
82
+ const s = createStyler({ isTTY: stream.isTTY });
83
+
84
+ if (!stream.isTTY || !process.stdin.setRawMode) {
85
+ for (const item of items) stream.write(` ${s.cyan('›')} ${item.label}\n`);
86
+ return Promise.resolve(null);
87
+ }
88
+
89
+ function renderFn(selected) {
90
+ const lines = [];
91
+ for (let i = 0; i < items.length; i++) {
92
+ const item = items[i];
93
+ const isSelected = i === selected;
94
+ const marker = isSelected ? s.blue('❯') : ' ';
95
+ const label = isSelected ? s.bold(s.blue(item.label)) : item.label;
96
+ lines.push(` ${marker} ${label}`);
97
+ if (item.sublabel) lines.push(` ${s.dim(item.sublabel)}`);
98
+ }
99
+ lines.push('');
100
+ lines.push(` ${s.dim(hint)}`);
101
+ stream.write(lines.join('\n') + '\n');
102
+ return lines.length;
103
+ }
104
+
105
+ return runRawSelect({ count: items.length, initialIndex, renderFn, stream });
106
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Zero-dependency terminal spinner for CLI progress feedback.
3
+ * Writes to stderr so stdout stays clean for piped output.
4
+ * Only animates when stderr is a TTY; silently no-ops otherwise.
5
+ */
6
+
7
+ const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
8
+ const INTERVAL = 80; // ms per frame
9
+
10
+ export function createSpinner(message, { stream = process.stderr } = {}) {
11
+ const isTTY = stream.isTTY;
12
+ let timer = null;
13
+ let frame = 0;
14
+
15
+ return {
16
+ start() {
17
+ if (!isTTY || timer) return this;
18
+ stream.write('\x1b[?25l'); // hide cursor
19
+ timer = setInterval(() => {
20
+ stream.write(`\r\x1b[K${FRAMES[frame]} ${message}`);
21
+ frame = (frame + 1) % FRAMES.length;
22
+ }, INTERVAL);
23
+ return this;
24
+ },
25
+
26
+ update(newMessage) {
27
+ message = newMessage;
28
+ return this;
29
+ },
30
+
31
+ stop(finalMessage) {
32
+ if (timer) {
33
+ clearInterval(timer);
34
+ timer = null;
35
+ stream.write('\r\x1b[K'); // clear line
36
+ stream.write('\x1b[?25h'); // show cursor
37
+ }
38
+ if (finalMessage && isTTY) {
39
+ stream.write(finalMessage + '\n');
40
+ }
41
+ return this;
42
+ },
43
+ };
44
+ }
@@ -0,0 +1,183 @@
1
+ import { createStyler } from './ansi.mjs';
2
+ import { formatTable } from './table-formatter.mjs';
3
+ import { formatSize } from './attachment-downloader.mjs';
4
+ import { timeAgo, truncate, stripCr } from './config.mjs';
5
+
6
+ function divWidth() {
7
+ return 30;
8
+ }
9
+ function halfDivWidth() {
10
+ return 15;
11
+ }
12
+ function statusColor(s, status) {
13
+ const st = (status || '').toLowerCase();
14
+ if (/done|closed|resolved|complete/.test(st)) return s.green(status);
15
+ if (/progress|review|testing|qa/.test(st)) return s.yellow(status);
16
+ return status;
17
+ }
18
+
19
+ export function styleTriageSummary(scoredTickets, opts = {}) {
20
+ const { staleDays = 5, baseUrl, styled = true } = opts;
21
+ const s = createStyler({ forceColor: styled, noColor: !styled });
22
+ const browseUrl = baseUrl ? baseUrl.replace(/\/$/, '') + '/browse/' : null;
23
+ const actionable = scoredTickets.filter(t => t.urgency !== 'clear');
24
+
25
+ if (actionable.length === 0) {
26
+ return s.green('All clear — no tickets need your attention right now.');
27
+ }
28
+
29
+ const needsResponse = actionable.filter(t => t.urgency === 'needs-response');
30
+ const aging = actionable.filter(t => t.urgency === 'aging');
31
+
32
+ const parts = [];
33
+ if (needsResponse.length > 0) parts.push(`${needsResponse.length} need response`);
34
+ if (aging.length > 0) parts.push(`${aging.length} aging`);
35
+
36
+ const sections = [];
37
+ sections.push(s.bold(`${actionable.length} tickets need attention`) + ' ' + s.dim(`(${parts.join(', ')})`) );
38
+
39
+ // Legend + base URL hint
40
+ const legendParts = [];
41
+ if (needsResponse.length > 0) legendParts.push(`${s.red('●')} needs response`);
42
+ if (aging.length > 0) legendParts.push(`${s.yellow('●')} aging`);
43
+ let legend = legendParts.join(' ');
44
+ if (browseUrl) legend += `\n${s.dim('Open:')} ${browseUrl}${s.dim('<key>')}`;
45
+ sections.push(legend);
46
+
47
+ const ticketCell = (key, colorFn) => {
48
+ return colorFn('●') + ' ' + key;
49
+ };
50
+
51
+ if (needsResponse.length > 0) {
52
+ const tableRows = needsResponse.map((t, i) => {
53
+ const ago = t.lastComment ? timeAgo(t.lastComment.created) : '';
54
+ const commenter = t.lastComment?.author ?? 'Unknown';
55
+ const snippet = t.lastComment?.body ? truncate(t.lastComment.body, 40) : '';
56
+ return [String(i + 1), ticketCell(t.ticketKey, s.red), truncate(t.summary, 45), t.status, commenter, ago, snippet];
57
+ });
58
+ const table = formatTable(
59
+ ['#', 'Ticket', 'Title', 'Status', 'From', 'When', 'Comment'],
60
+ tableRows,
61
+ { maxWidths: { 2: 45, 6: 40 } },
62
+ );
63
+ sections.push(table);
64
+ }
65
+
66
+ if (aging.length > 0) {
67
+ const agingOffset = needsResponse.length;
68
+ const tableRows = aging.map((t, i) => {
69
+ const days = t.daysSinceUpdate ?? '?';
70
+ return [String(agingOffset + i + 1), ticketCell(t.ticketKey, s.yellow), truncate(t.summary, 45), t.status, `${days}d`];
71
+ });
72
+ const table = formatTable(
73
+ ['#', 'Ticket', 'Title', 'Status', 'Stale'],
74
+ tableRows,
75
+ { maxWidths: { 2: 45 } },
76
+ );
77
+ sections.push(table);
78
+ }
79
+
80
+ return sections.join('\n\n');
81
+ }
82
+
83
+ export function styleBrief(ticket, codeRefs = null, opts = {}) {
84
+ const { styled = true } = opts;
85
+ const s = createStyler({ forceColor: styled, noColor: !styled });
86
+
87
+ const sections = [];
88
+
89
+ // Header: ticket key + summary
90
+ sections.push(s.bold(s.brand(`${ticket.key}: ${ticket.summary}`)));
91
+
92
+ // Metadata line
93
+ const meta = [
94
+ `${s.dim('Type:')} ${ticket.type}`,
95
+ `${s.dim('Status:')} ${statusColor(s, ticket.status)}`,
96
+ `${s.dim('Priority:')} ${ticket.priority}`,
97
+ `${s.dim('Assignee:')} ${ticket.assignee ?? 'Unassigned'}`,
98
+ ];
99
+ if (ticket.created) meta.push(`${s.dim('Created:')} ${ticket.created.split('T')[0]}`);
100
+ if (ticket.updated) meta.push(`${s.dim('Updated:')} ${ticket.updated.split('T')[0]}`);
101
+ sections.push(meta.join(s.dim(' · ')));
102
+
103
+ // Description
104
+ if (ticket.description) {
105
+ sections.push(`${s.bold(s.brand('Description'))}\n${s.dim('─'.repeat(divWidth()))}\n${stripCr(ticket.description)}`);
106
+ }
107
+
108
+ // Comments
109
+ if (ticket.comments?.length > 0) {
110
+ const commentLines = ticket.comments.map(c => {
111
+ const date = c.created ? c.created.split('T')[0] : 'unknown';
112
+ return `${s.brand(c.author)} ${s.dim(`(${date})`)}\n${stripCr(c.body)}`;
113
+ });
114
+ sections.push(`${s.bold(s.brand('Comments'))}\n${s.dim('─'.repeat(divWidth()))}\n${commentLines.join(`\n\n${s.dim('─'.repeat(halfDivWidth()))}\n`)}`);
115
+ }
116
+
117
+ // Linked tickets
118
+ if (ticket.linkedTicketDetails?.length > 0) {
119
+ const linkedSections = ticket.linkedTicketDetails.map(lt => {
120
+ const parts = [`${s.brand(lt.key)}: ${lt.summary}`, `${s.dim('Type:')} ${lt.type} | ${s.dim('Status:')} ${statusColor(s, lt.status)}`];
121
+ if (lt.description) parts.push(stripCr(lt.description));
122
+ if (lt.comments?.length > 0) {
123
+ const cmts = lt.comments.map(c => {
124
+ const date = c.created ? c.created.split('T')[0] : 'unknown';
125
+ return `${s.brand(c.author)} ${s.dim(`(${date})`)}: ${stripCr(c.body)}`;
126
+ });
127
+ parts.push(cmts.join('\n'));
128
+ }
129
+ return parts.join('\n');
130
+ });
131
+ sections.push(`${s.bold(s.brand('Linked Tickets'))}\n${s.dim('─'.repeat(divWidth()))}\n${linkedSections.join(`\n\n${s.dim('─'.repeat(halfDivWidth()))}\n`)}`);
132
+ }
133
+
134
+ // Code references
135
+ if (codeRefs) {
136
+ const categories = [
137
+ ['File Paths', codeRefs.filePaths],
138
+ ['Methods', codeRefs.methods],
139
+ ['Classes', codeRefs.classes],
140
+ ['Git SHAs', codeRefs.shas],
141
+ ['SVN Revisions', codeRefs.svnRevisions],
142
+ ['Branches', codeRefs.branches],
143
+ ['Namespaces', codeRefs.namespaces],
144
+ ];
145
+ const filled = categories
146
+ .filter(([, items]) => items?.length > 0)
147
+ .map(([label, items]) => `${s.dim(label + ':')} ${items.map(i => s.brand(i)).join(', ')}`);
148
+ if (filled.length > 0) {
149
+ sections.push(`${s.bold(s.brand('Code References'))}\n${s.dim('─'.repeat(divWidth()))}\n${filled.join('\n')}`);
150
+ }
151
+ }
152
+
153
+ if (ticket.attachments?.length > 0) {
154
+ const lines = ticket.attachments.map(a => {
155
+ const r = (ticket.localAttachments ?? []).find(x => x.filename === a.filename);
156
+ const sz = formatSize(a.size);
157
+ if (r?.localPath) {
158
+ const note = r.skipReason === 'cached' ? s.dim(', cached') : '';
159
+ return ` ${s.brand(r.localPath)}${note} ${s.dim(a.filename + ', ' + sz)}`;
160
+ }
161
+ if (r?.skipReason === 'too-large') return ` ${a.filename} ${s.dim(sz + ' — exceeds 10 MB limit')}`;
162
+ if (r?.skipReason === 'limit') return ` ${a.filename} ${s.dim(sz + ' — attachment limit reached')}`;
163
+ if (r?.skipReason === 'error') return ` ${a.filename} ${s.red('download failed: ' + r.error)}`;
164
+ return ` ${a.filename} ${s.dim(sz)}`;
165
+ });
166
+ sections.push(`${s.bold(s.brand('Attachments'))}\n${s.dim('─'.repeat(divWidth()))}\n${lines.join('\n')}`);
167
+ }
168
+
169
+ const out = sections.join('\n\n');
170
+
171
+ if (!styled) return out;
172
+
173
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
174
+ const plainTextLength = out.replace(ANSI_RE, '').length;
175
+ const briefTokens = Math.ceil(plainTextLength / 4);
176
+ const ticketCount = 1 + (ticket.linkedTicketDetails?.length ?? 1);
177
+ const rawTokenEstimate = Math.max(12000, ticketCount * 8000);
178
+ const savings = Math.round((1 - briefTokens / rawTokenEstimate) * 100);
179
+ const savingsStr = savings > 0 ? ` · ~${savings}% vs raw API` : '';
180
+ const footer = s.dim(` ○ ~${briefTokens} tokens loaded${savingsStr} · --plain for pipe-safe output`);
181
+
182
+ return out + '\n\n' + footer;
183
+ }
@@ -0,0 +1,109 @@
1
+ const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages';
2
+ const OPENAI_URL = 'https://api.openai.com/v1/chat/completions';
3
+ const CLOUD_URL = 'https://api.ticketlens.dev/v1/summarize';
4
+ const PROMPT = 'Summarize this Jira ticket in 3 sentences. Focus on what matters most for implementation. Be concrete.\n\n';
5
+
6
+ /**
7
+ * Summarize a ticket brief using BYOK or cloud mode.
8
+ * @param {object} opts
9
+ * @param {string} opts.brief - Markdown brief text
10
+ * @param {'byok'|'cloud'} opts.mode
11
+ * @param {object} [opts.credentials] - { anthropicApiKey?, openaiApiKey? }
12
+ * @param {string} [opts.licenseKey] - Required for cloud mode
13
+ * @param {Function} [opts.fetcher] - Injectable for tests (defaults to globalThis.fetch)
14
+ * @param {number} [opts.timeoutMs]
15
+ * @returns {Promise<string>} Summary text
16
+ */
17
+ export async function summarize({ brief, mode, credentials = null, licenseKey = null, fetcher = globalThis.fetch, timeoutMs = 30_000 }) {
18
+ if (mode === 'byok') {
19
+ return byok({ brief, credentials, fetcher, timeoutMs });
20
+ }
21
+ return cloud({ brief, licenseKey, fetcher, timeoutMs });
22
+ }
23
+
24
+ async function byok({ brief, credentials, fetcher, timeoutMs }) {
25
+ const anthropicKey = credentials?.anthropicApiKey;
26
+ const openaiKey = credentials?.openaiApiKey;
27
+
28
+ if (!anthropicKey && !openaiKey) {
29
+ throw new Error('No API key found. Add ANTHROPIC_API_KEY or OPENAI_API_KEY to ~/.ticketlens/credentials.json');
30
+ }
31
+
32
+ if (anthropicKey) {
33
+ return callAnthropic({ brief, apiKey: anthropicKey, fetcher, timeoutMs });
34
+ }
35
+
36
+ return callOpenAi({ brief, apiKey: openaiKey, fetcher, timeoutMs });
37
+ }
38
+
39
+ async function callAnthropic({ brief, apiKey, fetcher, timeoutMs }) {
40
+ const res = await fetcher(ANTHROPIC_URL, {
41
+ method: 'POST',
42
+ signal: AbortSignal.timeout(timeoutMs),
43
+ headers: {
44
+ 'Content-Type': 'application/json',
45
+ 'x-api-key': apiKey,
46
+ 'anthropic-version': '2023-06-01',
47
+ },
48
+ body: JSON.stringify({
49
+ model: 'claude-haiku-4-5-20251001',
50
+ max_tokens: 256,
51
+ messages: [{ role: 'user', content: PROMPT + brief }],
52
+ }),
53
+ });
54
+
55
+ if (!res.ok) {
56
+ const err = new Error(`Anthropic API error ${res.status}`);
57
+ err.status = res.status;
58
+ throw err;
59
+ }
60
+
61
+ const data = await res.json();
62
+ return data.content[0].text;
63
+ }
64
+
65
+ async function callOpenAi({ brief, apiKey, fetcher, timeoutMs }) {
66
+ const res = await fetcher(OPENAI_URL, {
67
+ method: 'POST',
68
+ signal: AbortSignal.timeout(timeoutMs),
69
+ headers: {
70
+ 'Content-Type': 'application/json',
71
+ 'Authorization': `Bearer ${apiKey}`,
72
+ },
73
+ body: JSON.stringify({
74
+ model: 'gpt-4o-mini',
75
+ max_tokens: 256,
76
+ messages: [{ role: 'user', content: PROMPT + brief }],
77
+ }),
78
+ });
79
+
80
+ if (!res.ok) {
81
+ const err = new Error(`OpenAI API error ${res.status}`);
82
+ err.status = res.status;
83
+ throw err;
84
+ }
85
+
86
+ const data = await res.json();
87
+ return data.choices[0].message.content;
88
+ }
89
+
90
+ async function cloud({ brief, licenseKey, fetcher, timeoutMs }) {
91
+ const res = await fetcher(CLOUD_URL, {
92
+ method: 'POST',
93
+ signal: AbortSignal.timeout(timeoutMs),
94
+ headers: {
95
+ 'Content-Type': 'application/json',
96
+ 'Authorization': `Bearer ${licenseKey}`,
97
+ },
98
+ body: JSON.stringify({ brief }),
99
+ });
100
+
101
+ if (!res.ok) {
102
+ const err = new Error(`TicketLens API error ${res.status}`);
103
+ err.status = res.status;
104
+ throw err;
105
+ }
106
+
107
+ const data = await res.json();
108
+ return data.summary;
109
+ }