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,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
|
+
}
|