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.
- package/LICENSE +21 -0
- package/README.md +457 -0
- package/bin/ticketlens.mjs +376 -0
- package/package.json +37 -0
- package/skills/jtb/scripts/fetch-my-tickets.mjs +377 -0
- package/skills/jtb/scripts/fetch-ticket.mjs +682 -0
- package/skills/jtb/scripts/lib/adapters/github-adapter.mjs +112 -0
- package/skills/jtb/scripts/lib/adapters/jira-adapter.mjs +19 -0
- package/skills/jtb/scripts/lib/adf-converter.mjs +67 -0
- package/skills/jtb/scripts/lib/ansi.mjs +87 -0
- package/skills/jtb/scripts/lib/arg-validator.mjs +178 -0
- package/skills/jtb/scripts/lib/attachment-downloader.mjs +123 -0
- package/skills/jtb/scripts/lib/attention-scorer.mjs +152 -0
- package/skills/jtb/scripts/lib/banner.mjs +201 -0
- package/skills/jtb/scripts/lib/brief-assembler.mjs +137 -0
- package/skills/jtb/scripts/lib/brief-cache.mjs +137 -0
- package/skills/jtb/scripts/lib/budget-pruner.mjs +242 -0
- package/skills/jtb/scripts/lib/cache-manager.mjs +499 -0
- package/skills/jtb/scripts/lib/cli-auth.mjs +40 -0
- package/skills/jtb/scripts/lib/cli.mjs +87 -0
- package/skills/jtb/scripts/lib/code-ref-parser.mjs +113 -0
- package/skills/jtb/scripts/lib/commit-linker.mjs +42 -0
- package/skills/jtb/scripts/lib/compliance-checker.mjs +92 -0
- package/skills/jtb/scripts/lib/config-wizard.mjs +392 -0
- package/skills/jtb/scripts/lib/config.mjs +63 -0
- package/skills/jtb/scripts/lib/diff-analyzer.mjs +66 -0
- package/skills/jtb/scripts/lib/drift-tracker.mjs +120 -0
- package/skills/jtb/scripts/lib/error-classifier.mjs +119 -0
- package/skills/jtb/scripts/lib/help.mjs +253 -0
- package/skills/jtb/scripts/lib/hook-installer.mjs +81 -0
- package/skills/jtb/scripts/lib/init-wizard.mjs +508 -0
- package/skills/jtb/scripts/lib/interactive-list.mjs +257 -0
- package/skills/jtb/scripts/lib/jira-client.mjs +169 -0
- package/skills/jtb/scripts/lib/ledger.mjs +96 -0
- package/skills/jtb/scripts/lib/license.mjs +195 -0
- package/skills/jtb/scripts/lib/pr-assembler.mjs +186 -0
- package/skills/jtb/scripts/lib/profile-picker.mjs +216 -0
- package/skills/jtb/scripts/lib/profile-resolver.mjs +236 -0
- package/skills/jtb/scripts/lib/profile-switcher.mjs +147 -0
- package/skills/jtb/scripts/lib/prompt-helpers.mjs +122 -0
- package/skills/jtb/scripts/lib/requirement-extractor.mjs +52 -0
- package/skills/jtb/scripts/lib/resolve-adapter.mjs +28 -0
- package/skills/jtb/scripts/lib/schedule-wizard.mjs +153 -0
- package/skills/jtb/scripts/lib/select-prompt.mjs +106 -0
- package/skills/jtb/scripts/lib/spinner.mjs +44 -0
- package/skills/jtb/scripts/lib/styled-assembler.mjs +183 -0
- package/skills/jtb/scripts/lib/summarizer.mjs +109 -0
- package/skills/jtb/scripts/lib/sync.mjs +119 -0
- package/skills/jtb/scripts/lib/table-formatter.mjs +48 -0
- package/skills/jtb/scripts/lib/triage-exporter.mjs +93 -0
- package/skills/jtb/scripts/lib/triage-history.mjs +166 -0
- package/skills/jtb/scripts/lib/triage-push.mjs +98 -0
- package/skills/jtb/scripts/lib/usage-tracker.mjs +54 -0
- 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
|
+
}
|