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,257 @@
1
+ import { createStyler } from './ansi.mjs';
2
+ import { spawn } from 'node:child_process';
3
+ import { runSwitch } from './profile-switcher.mjs';
4
+ import { timeAgo, truncate } from './config.mjs';
5
+
6
+ const ESCAPE_RE = /\x1b\[[0-9;]*m|\x1b\]8;[^\x07]*\x07/g;
7
+ const visLen = (str) => str.replace(ESCAPE_RE, '').length;
8
+
9
+ function padRight(str, len) {
10
+ const pad = Math.max(0, len - visLen(str));
11
+ return str + ' '.repeat(pad);
12
+ }
13
+
14
+ /** Truncate a string to maxCols visible characters, preserving ANSI sequences. */
15
+ function clipToWidth(str, maxCols) {
16
+ if (!maxCols || visLen(str) <= maxCols) return str;
17
+ let visible = 0;
18
+ let i = 0;
19
+ while (i < str.length && visible < maxCols) {
20
+ if (str[i] === '\x1b' && str[i + 1] === '[') {
21
+ const end = str.indexOf('m', i);
22
+ if (end !== -1) { i = end + 1; continue; }
23
+ }
24
+ if (str[i] === '\x1b' && str[i + 1] === ']') {
25
+ const end = str.indexOf('\x07', i);
26
+ if (end !== -1) { i = end + 1; continue; }
27
+ }
28
+ visible++;
29
+ i++;
30
+ }
31
+ return str.slice(0, i) + '\x1b[0m';
32
+ }
33
+
34
+ export function runInteractiveList(tickets, opts = {}) {
35
+ const { baseUrl, staleDays = 5, styled = true } = opts;
36
+ const browseUrl = baseUrl ? baseUrl.replace(/\/$/, '') + '/browse/' : null;
37
+ const s = createStyler({ forceColor: styled, noColor: !styled });
38
+
39
+ const actionable = tickets.filter(t => t.urgency !== 'clear');
40
+ if (actionable.length === 0) {
41
+ process.stdout.write(s.green('All clear — no tickets need your attention right now.') + '\n');
42
+ return Promise.resolve();
43
+ }
44
+
45
+ const needsResponse = actionable.filter(t => t.urgency === 'needs-response');
46
+ const aging = actionable.filter(t => t.urgency === 'aging');
47
+ const items = [...needsResponse, ...aging];
48
+
49
+ let selectedIndex = 0;
50
+ let scrollTop = 0;
51
+ let dynamicLineCount = 0; // track how many lines the dynamic section used last render
52
+
53
+ // Responsive column layout — re-evaluated on every render so terminal resizes are handled.
54
+ // Wide (≥150): all columns. Medium (≥100): no detail. Narrow (<100): key+title+status only.
55
+ function getColLayout() {
56
+ const w = process.stdout.columns || 120;
57
+ if (w >= 150) return { key: 12, title: 45, status: 14, from: 14, when: 8, detail: 35 };
58
+ if (w >= 100) return { key: 12, title: 35, status: 14, from: 14, when: 8, detail: 0 };
59
+ return { key: 10, title: Math.max(20, w - 36), status: 12, from: 0, when: 0, detail: 0 };
60
+ }
61
+
62
+ function buildRow(ticket, index) {
63
+ const COL = getColLayout();
64
+ const isSelected = index === selectedIndex;
65
+ const isNR = ticket.urgency === 'needs-response';
66
+
67
+ const dot = isNR ? s.red('\u25cf') : s.yellow('\u25cf');
68
+ const key = padRight(ticket.ticketKey, COL.key);
69
+ const title = padRight(truncate(ticket.summary, COL.title), COL.title);
70
+ const status = padRight(ticket.status, COL.status);
71
+
72
+ const parts = [` ${dot}`, key, title, status];
73
+
74
+ if (COL.from > 0) {
75
+ const from = padRight(isNR ? (ticket.lastComment?.author ?? 'Unknown') : '', COL.from);
76
+ const when = padRight(isNR && ticket.lastComment ? timeAgo(ticket.lastComment.created) : '', COL.when);
77
+ parts.push(from, when);
78
+ }
79
+
80
+ if (COL.detail > 0) {
81
+ const detail = isNR
82
+ ? (ticket.lastComment?.body ? s.dim(truncate(ticket.lastComment.body, COL.detail)) : '')
83
+ : s.dim(`${ticket.daysSinceUpdate ?? '?'}d stale`);
84
+ parts.push(detail);
85
+ } else if (COL.from === 0 && !isNR) {
86
+ // Narrow, no detail col: append stale days compactly after status
87
+ parts.push(s.dim(`${ticket.daysSinceUpdate ?? '?'}d`));
88
+ }
89
+
90
+ const line = parts.join(' ');
91
+ return isSelected ? `\x1b[7m${line}\x1b[27m` : line;
92
+ }
93
+
94
+ function writeHeader() {
95
+ const COL = getColLayout();
96
+ const lines = [];
97
+
98
+ // Title
99
+ const countParts = [];
100
+ if (needsResponse.length > 0) countParts.push(`${needsResponse.length} need response`);
101
+ if (aging.length > 0) countParts.push(`${aging.length} aging`);
102
+ lines.push(s.bold(` ${items.length} tickets need attention`) + ` (${countParts.join(', ')})`);
103
+ lines.push('');
104
+
105
+ // Legend
106
+ if (needsResponse.length > 0) lines.push(` ${s.red('\u25cf')} needs response`);
107
+ if (aging.length > 0) lines.push(` ${s.yellow('\u25cf')} aging`);
108
+ lines.push('');
109
+
110
+ // Column headers + separator — mirrors buildRow column visibility
111
+ const hdrParts = [` ${padRight('', 1)}`, padRight('Ticket', COL.key), padRight('Title', COL.title), padRight('Status', COL.status)];
112
+ const sepParts = [` ${'\u2500'.repeat(1)}`, '\u2500'.repeat(COL.key), '\u2500'.repeat(COL.title), '\u2500'.repeat(COL.status)];
113
+
114
+ if (COL.from > 0) {
115
+ hdrParts.push(padRight('From', COL.from), padRight('When', COL.when));
116
+ sepParts.push('\u2500'.repeat(COL.from), '\u2500'.repeat(COL.when));
117
+ }
118
+ if (COL.detail > 0) {
119
+ hdrParts.push('Detail');
120
+ sepParts.push('\u2500'.repeat(COL.detail));
121
+ }
122
+
123
+ lines.push(s.dim(hdrParts.join(' ')));
124
+ lines.push(s.dim(sepParts.join(' ')));
125
+
126
+ const cols = process.stdout.columns || 120;
127
+ process.stderr.write(lines.map(l => clipToWidth(l, cols)).join('\n') + '\n');
128
+ }
129
+
130
+ function renderDynamic() {
131
+ const cols = process.stdout.columns || 120;
132
+ const termRows = process.stdout.rows || 24;
133
+
134
+ // Erase previous dynamic lines by moving up and clearing each line
135
+ if (dynamicLineCount > 0) {
136
+ // Move up dynamicLineCount lines, clearing each
137
+ for (let i = 0; i < dynamicLineCount; i++) {
138
+ process.stderr.write('\x1b[A'); // move up
139
+ }
140
+ // Now at the top of the dynamic section — erase from here down
141
+ for (let i = 0; i < dynamicLineCount; i++) {
142
+ process.stderr.write('\r\x1b[2K'); // erase line
143
+ if (i < dynamicLineCount - 1) process.stderr.write('\x1b[B'); // move down
144
+ }
145
+ // Move back up to the start of the dynamic section
146
+ for (let i = 0; i < dynamicLineCount - 1; i++) {
147
+ process.stderr.write('\x1b[A');
148
+ }
149
+ process.stderr.write('\r');
150
+ }
151
+
152
+ // Build dynamic lines: data rows + scroll indicator + footer
153
+ const lines = [];
154
+
155
+ // Header section uses ~8-9 lines, but we don't need to know exactly —
156
+ // we control how many data rows to show based on available space.
157
+ // Use a conservative fixed header height estimate.
158
+ const headerHeight = 8; // title + blank + legend1 + legend2 + blank + colhdr + sep + (written above)
159
+ const footerHeight = 2; // blank + keybind hint
160
+ const maxDataRows = Math.max(1, termRows - headerHeight - footerHeight - 1);
161
+
162
+ const maxVisible = Math.min(maxDataRows, items.length);
163
+ if (selectedIndex >= scrollTop + maxVisible) scrollTop = selectedIndex - maxVisible + 1;
164
+ if (selectedIndex < scrollTop) scrollTop = selectedIndex;
165
+
166
+ const visibleEnd = Math.min(scrollTop + maxVisible, items.length);
167
+ for (let i = scrollTop; i < visibleEnd; i++) {
168
+ lines.push(buildRow(items[i], i));
169
+ }
170
+
171
+ // Scroll indicator
172
+ if (items.length > maxVisible) {
173
+ const pos = `${scrollTop + 1}-${visibleEnd} of ${items.length}`;
174
+ lines.push(s.dim(` ... ${pos}`));
175
+ }
176
+
177
+ // Footer
178
+ lines.push('');
179
+ lines.push(s.dim(' \u2191/\u2193 navigate Enter open in browser p switch profile q/Esc exit'));
180
+
181
+ const clipped = lines.map(l => clipToWidth(l, cols));
182
+ process.stderr.write(clipped.join('\n') + '\n');
183
+ dynamicLineCount = clipped.length;
184
+ }
185
+
186
+ function openInBrowser(ticketKey) {
187
+ if (!browseUrl) return;
188
+ const url = browseUrl + ticketKey;
189
+ try {
190
+ const cmd = process.platform === 'darwin' ? 'open'
191
+ : process.platform === 'win32' ? 'cmd'
192
+ : 'xdg-open';
193
+ const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
194
+ const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
195
+ child.unref();
196
+ } catch {
197
+ // Silently ignore if open fails
198
+ }
199
+ }
200
+
201
+ return new Promise((resolve) => {
202
+ const stdin = process.stdin;
203
+ const wasRaw = stdin.isRaw;
204
+
205
+ function cleanup() {
206
+ process.removeListener('SIGWINCH', onResize);
207
+ process.stderr.write('\x1b[?25h'); // show cursor
208
+ stdin.setRawMode(wasRaw ?? false);
209
+ stdin.pause();
210
+ stdin.removeListener('data', onData);
211
+ }
212
+
213
+ function exit() {
214
+ cleanup();
215
+ resolve();
216
+ }
217
+
218
+ function onData(data) {
219
+ const key = data.toString();
220
+
221
+ if (key === '\x03') { exit(); return; } // Ctrl+C
222
+ if (key === 'q' || key === 'Q') { exit(); return; }
223
+ if (key === '\x1b') { exit(); return; } // Escape
224
+
225
+ if (key === '\x1b[A') { // Up
226
+ if (selectedIndex > 0) { selectedIndex--; renderDynamic(); }
227
+ return;
228
+ }
229
+ if (key === '\x1b[B') { // Down
230
+ if (selectedIndex < items.length - 1) { selectedIndex++; renderDynamic(); }
231
+ return;
232
+ }
233
+ if (key === '\r' || key === '\n') { // Enter
234
+ openInBrowser(items[selectedIndex].ticketKey);
235
+ return;
236
+ }
237
+ if (key === 'p' || key === 'P') { // Switch profile
238
+ cleanup();
239
+ runSwitch().then(switched => resolve(switched ? 'switch' : undefined));
240
+ return;
241
+ }
242
+ }
243
+
244
+ function onResize() { renderDynamic(); }
245
+
246
+ // Hide cursor, write static header once, then render dynamic rows
247
+ process.stderr.write('\x1b[?25l');
248
+ stdin.setRawMode(true);
249
+ stdin.resume();
250
+ stdin.setEncoding('utf8');
251
+ stdin.on('data', onData);
252
+ process.on('SIGWINCH', onResize);
253
+
254
+ writeHeader();
255
+ renderDynamic();
256
+ });
257
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Jira REST API client supporting Cloud and Server/Data Center.
3
+ * Normalizes responses into a consistent shape.
4
+ * Supports v2 (Server/DC) and v3 (Cloud) API versions.
5
+ */
6
+
7
+ import { adfToText } from './adf-converter.mjs';
8
+
9
+ function toText(value) {
10
+ if (value == null) return null;
11
+ if (typeof value === 'string') return value;
12
+ return adfToText(value);
13
+ }
14
+
15
+ export function normalizeTicket(raw) {
16
+ const f = raw.fields;
17
+ return {
18
+ key: raw.key,
19
+ summary: f.summary,
20
+ type: f.issuetype?.name ?? null,
21
+ status: f.status?.name ?? null,
22
+ priority: f.priority?.name ?? null,
23
+ assignee: f.assignee?.displayName ?? null,
24
+ reporter: f.reporter?.displayName ?? null,
25
+ description: toText(f.description),
26
+ created: f.created ?? null,
27
+ updated: f.updated ?? null,
28
+ labels: f.labels ?? [],
29
+ components: (f.components ?? []).map(c => c.name),
30
+ comments: (f.comment?.comments ?? []).map(c => ({
31
+ author: c.author?.displayName ?? c.author?.name ?? null,
32
+ authorAccountId: c.author?.accountId ?? null,
33
+ authorName: c.author?.name ?? null,
34
+ body: toText(c.body),
35
+ created: c.created,
36
+ })),
37
+ linkedIssues: (f.issuelinks ?? []).map(link => {
38
+ const direction = link.outwardIssue ? 'outward' : 'inward';
39
+ const issue = link.outwardIssue ?? link.inwardIssue;
40
+ return {
41
+ direction,
42
+ linkType: link.type.name,
43
+ key: issue.key,
44
+ summary: issue.fields.summary,
45
+ status: issue.fields.status?.name ?? null,
46
+ type: issue.fields.issuetype?.name ?? null,
47
+ };
48
+ }),
49
+ attachments: (f.attachment ?? []).map(a => ({
50
+ id: a.id ?? null,
51
+ filename: a.filename,
52
+ mimeType: a.mimeType ?? null,
53
+ size: a.size,
54
+ content: a.content ?? null,
55
+ })),
56
+ };
57
+ }
58
+
59
+ export function buildAuthHeader(env) {
60
+ if (env.JIRA_PAT) {
61
+ return { Authorization: `Bearer ${env.JIRA_PAT}` };
62
+ }
63
+ const encoded = Buffer.from(`${env.JIRA_EMAIL}:${env.JIRA_API_TOKEN}`).toString('base64');
64
+ return { Authorization: `Basic ${encoded}` };
65
+ }
66
+
67
+ export async function fetchCurrentUser(opts = {}) {
68
+ const { env = process.env, fetcher = globalThis.fetch, apiVersion = 2, timeoutMs = 10_000 } = opts;
69
+ const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
70
+ const headers = { ...buildAuthHeader(env), 'Content-Type': 'application/json' };
71
+
72
+ const url = `${baseUrl}/rest/api/${apiVersion}/myself`;
73
+ const fetchOpts = { headers };
74
+ if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
75
+ const response = await fetcher(url, fetchOpts);
76
+
77
+ if (!response.ok) {
78
+ throw new Error(`Jira API error ${response.status} (${response.statusText}) fetching current user`);
79
+ }
80
+
81
+ const raw = await response.json();
82
+ return {
83
+ accountId: raw.accountId ?? null,
84
+ name: raw.name ?? null,
85
+ displayName: raw.displayName ?? null,
86
+ emailAddress: raw.emailAddress ?? null,
87
+ };
88
+ }
89
+
90
+ export async function fetchStatuses(opts = {}) {
91
+ const { env = process.env, fetcher = globalThis.fetch, apiVersion = 2, timeoutMs = 10_000 } = opts;
92
+ const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
93
+ const headers = { ...buildAuthHeader(env), 'Content-Type': 'application/json' };
94
+
95
+ const url = `${baseUrl}/rest/api/${apiVersion}/status`;
96
+ const fetchOpts = { headers };
97
+ if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
98
+ const response = await fetcher(url, fetchOpts);
99
+
100
+ if (!response.ok) {
101
+ throw new Error(`Jira API error ${response.status} (${response.statusText}) fetching statuses`);
102
+ }
103
+
104
+ const raw = await response.json();
105
+ return [...new Set(raw.map(s => s.name))].sort();
106
+ }
107
+
108
+ export async function searchTickets(jql, opts = {}) {
109
+ const { env = process.env, fetcher = globalThis.fetch, maxResults = 50, apiVersion = 2, timeoutMs = 10_000 } = opts;
110
+ const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
111
+ const headers = { ...buildAuthHeader(env), 'Content-Type': 'application/json' };
112
+
113
+ const fields = 'summary,status,assignee,priority,issuetype,comment,updated,statuscategorychangedate';
114
+ const params = new URLSearchParams({ jql, fields, maxResults: String(maxResults) });
115
+ const endpoint = apiVersion >= 3 ? `/rest/api/3/search/jql` : `/rest/api/2/search`;
116
+ const url = `${baseUrl}${endpoint}?${params}`;
117
+ const fetchOpts = { headers };
118
+ if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
119
+ const response = await fetcher(url, fetchOpts);
120
+
121
+ if (!response.ok) {
122
+ let detail = '';
123
+ try { const body = await response.json(); detail = (body.errorMessages || []).join('; '); } catch {}
124
+ const err = new Error(`Jira API error ${response.status} (${response.statusText}) searching tickets${detail ? ': ' + detail : ''}`);
125
+ err.status = response.status;
126
+ err.detail = detail;
127
+ throw err;
128
+ }
129
+
130
+ const raw = await response.json();
131
+ return (raw.issues ?? []).map(normalizeTicket);
132
+ }
133
+
134
+ export async function fetchTicket(ticketKey, opts = {}) {
135
+ const { env = process.env, fetcher = globalThis.fetch, depth = 1, apiVersion = 2, timeoutMs = 10_000, _visited = new Set(), _currentDepth = 0 } = opts;
136
+ const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
137
+ const headers = { ...buildAuthHeader(env), 'Content-Type': 'application/json' };
138
+
139
+ const url = `${baseUrl}/rest/api/${apiVersion}/issue/${encodeURIComponent(ticketKey)}`;
140
+ const fetchOpts = { headers };
141
+ if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
142
+ const response = await fetcher(url, fetchOpts);
143
+
144
+ if (!response.ok) {
145
+ throw new Error(`Jira API error ${response.status} (${response.statusText}) fetching ${ticketKey}`);
146
+ }
147
+
148
+ const raw = await response.json();
149
+ const ticket = normalizeTicket(raw);
150
+ _visited.add(ticketKey);
151
+
152
+ if (_currentDepth < depth) {
153
+ const MAX_TICKETS = 15;
154
+ const linkedKeys = ticket.linkedIssues
155
+ .map(l => l.key)
156
+ .filter(k => !_visited.has(k))
157
+ .slice(0, Math.max(0, MAX_TICKETS - _visited.size));
158
+
159
+ // Pre-mark all siblings before launching parallel fetches to prevent duplicate fetches
160
+ // when the same key appears in multiple link lists at the same depth.
161
+ linkedKeys.forEach(k => _visited.add(k));
162
+
163
+ ticket.linkedTicketDetails = await Promise.all(
164
+ linkedKeys.map(k => fetchTicket(k, { ...opts, _visited, _currentDepth: _currentDepth + 1 }))
165
+ );
166
+ }
167
+
168
+ return ticket;
169
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Compliance Ledger — append-only JSONL audit trail for compliance checks (Pro tier).
3
+ * Named exports only. All fs operations accept an injectable fsModule param.
4
+ */
5
+
6
+ import * as _fs from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { createHmac, randomBytes } from 'node:crypto';
9
+ import { DEFAULT_CONFIG_DIR } from './config.mjs';
10
+
11
+ const LEDGER_FILE = 'ledger.jsonl';
12
+ const KEY_FILE = 'ledger-key';
13
+
14
+ /**
15
+ * Append one compliance record to ledger.jsonl.
16
+ * No-op when isPro is false.
17
+ *
18
+ * @param {{ ticketKey: string, commitSha: string, author: string, coverage: number, missing: string[] }} record
19
+ * @param {{ configDir?: string, fsModule?: object, isPro?: boolean }} opts
20
+ */
21
+ export function appendLedger(record, { configDir = DEFAULT_CONFIG_DIR, fsModule = _fs, isPro = false } = {}) {
22
+ if (!isPro) return;
23
+
24
+ fsModule.mkdirSync(configDir, { recursive: true });
25
+
26
+ const line = JSON.stringify({ ts: new Date().toISOString(), ...record }) + '\n';
27
+ fsModule.appendFileSync(join(configDir, LEDGER_FILE), line, 'utf8');
28
+ }
29
+
30
+ /**
31
+ * Read all records from ledger.jsonl, optionally filtered by a since date.
32
+ *
33
+ * @param {{ configDir?: string, fsModule?: object, since?: string }} opts
34
+ * @returns {object[]}
35
+ */
36
+ export function readLedger({ configDir = DEFAULT_CONFIG_DIR, fsModule = _fs, since } = {}) {
37
+ const ledgerPath = join(configDir, LEDGER_FILE);
38
+ let raw;
39
+ try {
40
+ raw = fsModule.readFileSync(ledgerPath, 'utf8');
41
+ } catch {
42
+ return [];
43
+ }
44
+
45
+ const sinceMs = since ? new Date(since).getTime() : null;
46
+
47
+ return raw
48
+ .split('\n')
49
+ .filter(line => line.trim().length > 0)
50
+ .map(line => JSON.parse(line))
51
+ .filter(record => sinceMs === null || new Date(record.ts).getTime() >= sinceMs);
52
+ }
53
+
54
+ /**
55
+ * Export the ledger in the specified format.
56
+ *
57
+ * @param {'json'|'csv'} format
58
+ * @param {{ configDir?: string, fsModule?: object }} opts
59
+ * @returns {object|string} Object for 'json', string for 'csv'
60
+ */
61
+ export function exportLedger(format, { configDir = DEFAULT_CONFIG_DIR, fsModule = _fs } = {}) {
62
+ const records = readLedger({ configDir, fsModule });
63
+
64
+ if (format === 'csv') {
65
+ const header = 'ts,ticketKey,commitSha,author,coverage,missing';
66
+ const rows = records.map(r => {
67
+ const missing = Array.isArray(r.missing) ? r.missing.join('|') : (r.missing ?? '');
68
+ return [r.ts, r.ticketKey, r.commitSha, r.author, r.coverage, missing]
69
+ .map(v => `"${String(v ?? '').replace(/"/g, '""')}"`)
70
+ .join(',');
71
+ });
72
+ return [header, ...rows].join('\n');
73
+ }
74
+
75
+ // JSON format
76
+ const key = _getOrCreateKey(configDir, fsModule);
77
+ const exportedAt = new Date().toISOString();
78
+ const payload = JSON.stringify({ records, exportedAt });
79
+ const signature = createHmac('sha256', key).update(payload).digest('hex');
80
+
81
+ return { records, exportedAt, signature };
82
+ }
83
+
84
+ // ── Internal helpers ─────────────────────────────────────────────────────────
85
+
86
+ function _getOrCreateKey(configDir, fsModule) {
87
+ const keyPath = join(configDir, KEY_FILE);
88
+ try {
89
+ return fsModule.readFileSync(keyPath, 'utf8').trim();
90
+ } catch {
91
+ const key = randomBytes(32).toString('hex');
92
+ fsModule.mkdirSync(configDir, { recursive: true });
93
+ fsModule.writeFileSync(keyPath, key, { encoding: 'utf8', mode: 0o600 });
94
+ return key;
95
+ }
96
+ }