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,112 @@
1
+ /**
2
+ * Parses owner and repo from a GitHub profile baseUrl.
3
+ * Expected format: https://github.com/OWNER/REPO
4
+ */
5
+ export function parseGitHubRepo(baseUrl) {
6
+ let url;
7
+ try {
8
+ url = new URL(baseUrl);
9
+ } catch {
10
+ throw new Error(`Invalid GitHub baseUrl: ${baseUrl}`);
11
+ }
12
+ const parts = url.pathname.replace(/^\//, '').split('/').filter(Boolean);
13
+ if (parts.length < 2) {
14
+ throw new Error(
15
+ `GitHub profile baseUrl must include owner and repo: https://github.com/OWNER/REPO (got: ${baseUrl})`,
16
+ );
17
+ }
18
+ return { owner: parts[0], repo: parts[1] };
19
+ }
20
+
21
+ /**
22
+ * Maps a raw GitHub issue + comments array to the normalized ticket shape.
23
+ * @param {object} raw - GitHub issue object
24
+ * @param {object[]} comments - GitHub issue comments array
25
+ * @param {string} [keyPrefix] - prefix used to construct the ticket key (e.g. 'GH')
26
+ */
27
+ export function normalizeGitHubIssue(raw, comments = [], keyPrefix = 'GH') {
28
+ return {
29
+ key: `${keyPrefix}-${raw.number}`,
30
+ summary: raw.title,
31
+ type: 'Issue',
32
+ status: raw.state,
33
+ priority: null,
34
+ assignee: raw.assignees?.[0]?.login ?? raw.assignee?.login ?? null,
35
+ reporter: raw.user?.login ?? null,
36
+ description: raw.body ?? null,
37
+ created: raw.created_at ?? null,
38
+ updated: raw.updated_at ?? null,
39
+ labels: (raw.labels ?? []).map(l => l.name),
40
+ components: [],
41
+ comments: comments.map(c => ({
42
+ author: c.user?.login ?? null,
43
+ authorAccountId: null,
44
+ authorName: c.user?.login ?? null,
45
+ body: c.body ?? '',
46
+ created: c.created_at ?? null,
47
+ })),
48
+ linkedIssues: [],
49
+ attachments: [],
50
+ };
51
+ }
52
+
53
+ const GITHUB_API = 'https://api.github.com';
54
+
55
+ /**
56
+ * Returns a tracker adapter backed by the GitHub Issues REST API.
57
+ * Profile baseUrl must be https://github.com/OWNER/REPO.
58
+ * Auth token stored as apiToken (or pat) in credentials.json.
59
+ */
60
+ export function createGitHubAdapter(conn, { fetcher = globalThis.fetch } = {}) {
61
+ const { owner, repo } = parseGitHubRepo(conn.baseUrl);
62
+ const keyPrefix = conn.ticketPrefixes?.[0] ?? 'GH';
63
+ const token = conn.apiToken || conn.pat;
64
+ const headers = {
65
+ Authorization: `Bearer ${token}`,
66
+ Accept: 'application/vnd.github+json',
67
+ 'X-GitHub-Api-Version': '2022-11-28',
68
+ };
69
+
70
+ return {
71
+ type: 'github',
72
+
73
+ async fetchTicket(key, opts = {}) {
74
+ const number = parseInt(key.split('-').pop(), 10);
75
+ const signal = AbortSignal.timeout(opts.timeoutMs ?? 10_000);
76
+ const [issueRes, commentsRes] = await Promise.all([
77
+ fetcher(`${GITHUB_API}/repos/${owner}/${repo}/issues/${number}`, { headers, signal }),
78
+ fetcher(`${GITHUB_API}/repos/${owner}/${repo}/issues/${number}/comments`, { headers, signal }),
79
+ ]);
80
+ if (!issueRes.ok) {
81
+ throw new Error(`GitHub API error ${issueRes.status} (${issueRes.statusText}) fetching ${key}`);
82
+ }
83
+ const raw = await issueRes.json();
84
+ const comments = commentsRes.ok ? await commentsRes.json() : [];
85
+ return normalizeGitHubIssue(raw, comments, keyPrefix);
86
+ },
87
+
88
+ async fetchCurrentUser(opts = {}) {
89
+ const res = await fetcher(`${GITHUB_API}/user`, {
90
+ headers,
91
+ signal: AbortSignal.timeout(opts.timeoutMs ?? 10_000),
92
+ });
93
+ if (!res.ok) throw new Error(`GitHub API error ${res.status} fetching current user`);
94
+ const raw = await res.json();
95
+ return { displayName: raw.name || raw.login, email: raw.email ?? null };
96
+ },
97
+
98
+ async searchTickets(_query, opts = {}) {
99
+ const res = await fetcher(
100
+ `${GITHUB_API}/repos/${owner}/${repo}/issues?state=open&assignee=me&per_page=50`,
101
+ { headers, signal: AbortSignal.timeout(opts.timeoutMs ?? 10_000) },
102
+ );
103
+ if (!res.ok) throw new Error(`GitHub API error ${res.status} searching issues`);
104
+ const raw = await res.json();
105
+ return raw.map(issue => normalizeGitHubIssue(issue, [], keyPrefix));
106
+ },
107
+
108
+ async fetchStatuses() {
109
+ return ['open', 'closed'];
110
+ },
111
+ };
112
+ }
@@ -0,0 +1,19 @@
1
+ import { fetchTicket, fetchCurrentUser, searchTickets, fetchStatuses } from '../jira-client.mjs';
2
+ import { buildJiraEnv } from '../config.mjs';
3
+
4
+ /**
5
+ * Returns a tracker adapter backed by the Jira REST API.
6
+ * Binds connection credentials so callers never touch jira-client directly.
7
+ */
8
+ export function createJiraAdapter(conn, { fetcher = globalThis.fetch } = {}) {
9
+ const env = buildJiraEnv(conn);
10
+ const apiVersion = conn.auth === 'cloud' ? 3 : 2;
11
+
12
+ return {
13
+ type: 'jira',
14
+ fetchTicket: (key, opts = {}) => fetchTicket(key, { env, fetcher, apiVersion, ...opts }),
15
+ fetchCurrentUser: (opts = {}) => fetchCurrentUser({ env, fetcher, apiVersion, ...opts }),
16
+ searchTickets: (query, opts = {}) => searchTickets(query, { env, fetcher, apiVersion, ...opts }),
17
+ fetchStatuses: (opts = {}) => fetchStatuses({ env, fetcher, apiVersion, ...opts }),
18
+ };
19
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Converts Atlassian Document Format (ADF) to plain text.
3
+ * Used for Jira Cloud v3 API responses where description and
4
+ * comment bodies are ADF objects instead of plain text strings.
5
+ */
6
+
7
+ export function adfToText(value) {
8
+ if (value == null) return '';
9
+ if (typeof value === 'string') return value;
10
+ if (typeof value !== 'object' || value.type !== 'doc') return '';
11
+
12
+ return extractBlocks(value.content || []);
13
+ }
14
+
15
+ function extractBlocks(nodes) {
16
+ return nodes.map(extractBlock).filter(Boolean).join('\n\n');
17
+ }
18
+
19
+ function extractBlock(node) {
20
+ switch (node.type) {
21
+ case 'paragraph':
22
+ case 'heading':
23
+ case 'codeBlock':
24
+ return extractInlines(node.content || []);
25
+ case 'bulletList':
26
+ case 'orderedList':
27
+ return (node.content || []).map(li => extractBlock(li)).filter(Boolean).join('\n');
28
+ case 'listItem':
29
+ return extractBlocks(node.content || []);
30
+ case 'blockquote':
31
+ case 'panel':
32
+ case 'expand':
33
+ case 'nestedExpand':
34
+ case 'layoutSection':
35
+ case 'layoutColumn':
36
+ case 'tableCell':
37
+ case 'tableHeader':
38
+ case 'tableRow':
39
+ case 'table':
40
+ return extractBlocks(node.content || []);
41
+ default:
42
+ if (node.content) return extractBlocks(node.content);
43
+ return '';
44
+ }
45
+ }
46
+
47
+ function extractInlines(nodes) {
48
+ return nodes.map(extractInline).join('');
49
+ }
50
+
51
+ function extractInline(node) {
52
+ switch (node.type) {
53
+ case 'text':
54
+ return node.text || '';
55
+ case 'mention':
56
+ return node.attrs?.text || '';
57
+ case 'inlineCard':
58
+ return node.attrs?.url || '';
59
+ case 'emoji':
60
+ return node.attrs?.shortName || '';
61
+ case 'hardBreak':
62
+ return '\n';
63
+ default:
64
+ if (node.content) return extractInlines(node.content);
65
+ return '';
66
+ }
67
+ }
@@ -0,0 +1,87 @@
1
+ const ESC = '\x1b[';
2
+ const codes = {
3
+ bold: [1, 22], dim: [2, 22],
4
+ blue: [34, 39], cyan: [36, 39],
5
+ magenta: [35, 39], white: [37, 39],
6
+ brightGreen: [92, 39], brightYellow: [93, 39], brightCyan: [96, 39],
7
+ };
8
+
9
+ // Brand design tokens mapped from landing page CSS variables
10
+ const TOKENS = {
11
+ brand: { rgb: [121, 192, 255], idx: 117 }, // #79c0ff — landing page .t-blue
12
+ green: { rgb: [63, 185, 80 ], idx: 71 }, // #3fb950
13
+ yellow: { rgb: [227, 179, 65 ], idx: 178 }, // #e3b341
14
+ red: { rgb: [248, 81, 73 ], idx: 203 }, // #f85149
15
+ };
16
+
17
+ function detectStyled({ forceColor, noColor, term, isTTY } = {}) {
18
+ if (forceColor) return true;
19
+ if (noColor) return false;
20
+ if (term === 'dumb') return false;
21
+ return !!isTTY;
22
+ }
23
+
24
+ function detectTruecolor({ colorterm } = {}) {
25
+ return colorterm === 'truecolor' || colorterm === '24bit';
26
+ }
27
+
28
+ export function createStyler(opts = {}) {
29
+ const enabled = detectStyled(opts);
30
+ const tc = enabled && detectTruecolor(opts);
31
+
32
+ const wrap = (open, close) => (text) =>
33
+ enabled ? `${ESC}${open}m${text}${ESC}${close}m` : String(text);
34
+
35
+ const wrapToken = (token) => {
36
+ const { rgb: [r, g, b], idx } = TOKENS[token];
37
+ const open = tc ? `38;2;${r};${g};${b}` : `38;5;${idx}`;
38
+ return (text) => enabled ? `${ESC}${open}m${text}${ESC}39m` : String(text);
39
+ };
40
+
41
+ const link = (url, text) =>
42
+ enabled ? `\x1b]8;;${url}\x07${text}\x1b]8;;\x07` : String(text);
43
+
44
+ return {
45
+ bold: wrap(codes.bold[0], codes.bold[1]),
46
+ dim: wrap(codes.dim[0], codes.dim[1]),
47
+ red: wrapToken('red'),
48
+ green: wrapToken('green'),
49
+ yellow: wrapToken('yellow'),
50
+ blue: wrap(codes.blue[0], codes.blue[1]),
51
+ cyan: wrap(codes.cyan[0], codes.cyan[1]),
52
+ magenta: wrap(codes.magenta[0], codes.magenta[1]),
53
+ white: wrap(codes.white[0], codes.white[1]),
54
+ brightGreen: wrap(codes.brightGreen[0], codes.brightGreen[1]),
55
+ brightYellow: wrap(codes.brightYellow[0], codes.brightYellow[1]),
56
+ brightCyan: wrap(codes.brightCyan[0], codes.brightCyan[1]),
57
+ brand: wrapToken('brand'),
58
+ link,
59
+ enabled,
60
+ };
61
+ }
62
+
63
+ function envStyler() {
64
+ return createStyler({
65
+ forceColor: !!process.env.FORCE_COLOR,
66
+ noColor: !!process.env.NO_COLOR,
67
+ term: process.env.TERM,
68
+ colorterm: process.env.COLORTERM,
69
+ isTTY: process.stdout.isTTY,
70
+ });
71
+ }
72
+
73
+ const defaultStyler = envStyler();
74
+ export const bold = defaultStyler.bold;
75
+ export const dim = defaultStyler.dim;
76
+ export const red = defaultStyler.red;
77
+ export const green = defaultStyler.green;
78
+ export const yellow = defaultStyler.yellow;
79
+ export const blue = defaultStyler.blue;
80
+ export const cyan = defaultStyler.cyan;
81
+ export const magenta = defaultStyler.magenta;
82
+ export const white = defaultStyler.white;
83
+ export const brightGreen = defaultStyler.brightGreen;
84
+ export const brightYellow = defaultStyler.brightYellow;
85
+ export const brightCyan = defaultStyler.brightCyan;
86
+ export const brand = defaultStyler.brand;
87
+ export const isStyled = () => defaultStyler.enabled;
@@ -0,0 +1,178 @@
1
+ /**
2
+ * CLI argument validation — unknown flag detection with "did you mean?" and
3
+ * an interactive y/N prompt to apply the corrected flag before re-running.
4
+ *
5
+ * Known-flags format
6
+ * ──────────────────
7
+ * '--stale=' trailing '=' → flag takes a value (--stale=5)
8
+ * '--plain' no trailing '=' → boolean flag (--plain)
9
+ *
10
+ * hints (optional)
11
+ * ────────────────
12
+ * Same format, but these flags are from *other* commands.
13
+ * They widen the suggestion pool so a cross-command typo like
14
+ * --dept in triage can still surface "--depth (fetch flag)" as a hint.
15
+ * Hint suggestions are shown as informational only — no y/N prompt,
16
+ * since applying them wouldn't work for the current command.
17
+ */
18
+
19
+ import { createStyler } from './ansi.mjs';
20
+
21
+ function levenshtein(a, b) {
22
+ const m = a.length, n = b.length;
23
+ const dp = Array.from({ length: m + 1 }, (_, i) =>
24
+ Array.from({ length: n + 1 }, (_, j) => i ? (j ? 0 : i) : j)
25
+ );
26
+ for (let i = 1; i <= m; i++) {
27
+ for (let j = 1; j <= n; j++) {
28
+ dp[i][j] = a[i - 1] === b[j - 1]
29
+ ? dp[i - 1][j - 1]
30
+ : 1 + Math.min(dp[i - 1][j - 1], dp[i - 1][j], dp[i][j - 1]);
31
+ }
32
+ }
33
+ return dp[m][n];
34
+ }
35
+
36
+ // Strip leading dashes before distance comparison so '--dept' vs '--depth' = 1 edit,
37
+ // not 3 (the shared '--' prefix would inflate every distance by 0 but skew alignment).
38
+ function bare(flag) {
39
+ return flag.startsWith('--') ? flag.slice(2) : flag.startsWith('-') ? flag.slice(1) : flag;
40
+ }
41
+
42
+ // Threshold scales with bare flag length so short names require tight matches:
43
+ // 4-char bare ('dept') → 2 — 'dept' vs 'help' = 3 > 2, no false positive
44
+ // 5-char bare ('state') → 2 — 'state' vs 'stale' = 1 ≤ 2, correct match
45
+ // 7-char bare ('profile') → 3 — 'profle' vs 'profile' = 1 ≤ 3, correct
46
+ function threshold(bareFlag) {
47
+ return Math.max(1, Math.ceil(bareFlag.length / 3));
48
+ }
49
+
50
+ /**
51
+ * Find the best-matching flag name (without trailing '=') from `candidates`, or null.
52
+ *
53
+ * @param {string} inputFlag e.g. '--state'
54
+ * @param {string[]} candidates from knownFlags and/or hints
55
+ * @param {boolean} hasValue true when the user typed --flag=VALUE
56
+ */
57
+ function findSuggestion(inputFlag, candidates, hasValue) {
58
+ const input = bare(inputFlag);
59
+ const limit = threshold(input);
60
+
61
+ // Value-type filtering: --flag=value inputs should only match value-taking flags.
62
+ // This prevents --help (boolean) from ever being suggested for --dept=5.
63
+ const pool = hasValue ? candidates.filter(f => f.endsWith('=')) : candidates;
64
+
65
+ const ranked = pool
66
+ .map(f => {
67
+ const name = f.replace(/=$/, '');
68
+ return { name, dist: levenshtein(input, bare(name)) };
69
+ })
70
+ .sort((a, b) => a.dist - b.dist);
71
+
72
+ return ranked[0]?.dist <= limit ? ranked[0].name : null;
73
+ }
74
+
75
+ function correctedArg(arg, suggestion) {
76
+ const eqIdx = arg.indexOf('=');
77
+ return eqIdx === -1 ? suggestion : `${suggestion}=${arg.slice(eqIdx + 1)}`;
78
+ }
79
+
80
+ /**
81
+ * Detect unrecognized flags and either interactively fix them or signal exit.
82
+ *
83
+ * TTY + fixable suggestion: prints styled error + "Apply <fix>? (y/N)"
84
+ * y → returns corrected args (caller re-runs with the fixed flag)
85
+ * N → returns null (caller sets exitCode=1)
86
+ *
87
+ * TTY + hint-only suggestion: prints error + informational "closest match" line, exits.
88
+ *
89
+ * Non-TTY: prints error + "Try: <fix>" or no tip, returns null.
90
+ *
91
+ * No unknowns → returns original args unchanged.
92
+ *
93
+ * @param {string[]} args CLI args (already normalised, e.g. --project→--profile)
94
+ * @param {string[]} knownFlags Flags this command accepts; append '=' for value-taking
95
+ * @param {{ stream?: NodeJS.WritableStream, hints?: string[] }} opts
96
+ * hints: cross-command flags used only for suggestions, never auto-applied
97
+ * @returns {Promise<string[]|null>}
98
+ */
99
+ export async function handleUnknownFlags(args, knownFlags, { stream = process.stderr, hints = [] } = {}) {
100
+ const s = createStyler({ isTTY: stream.isTTY });
101
+
102
+ // Lookup set — strips trailing '=' so '--stale=3' matches the known entry '--stale='
103
+ const knownNames = new Set(knownFlags.map(f => f.replace(/=$/, '')));
104
+
105
+ // Suggestion pool = command flags + cross-command hints
106
+ const allCandidates = [...knownFlags, ...hints];
107
+
108
+ const unknowns = [];
109
+ for (const arg of args) {
110
+ if (!arg.startsWith('-')) continue;
111
+ const flagName = arg.split('=')[0];
112
+ if (knownNames.has(flagName)) continue;
113
+ const hasValue = arg.includes('=');
114
+ const suggestion = findSuggestion(flagName, allCandidates, hasValue);
115
+ // isApplicable: suggestion is in this command's known flags (can be auto-applied)
116
+ const isApplicable = suggestion !== null && knownNames.has(suggestion.replace(/=$/, ''));
117
+ unknowns.push({ arg, flagName, hasValue, suggestion, isApplicable });
118
+ }
119
+
120
+ if (unknowns.length === 0) return args;
121
+
122
+ // ── One diagnostic line per unknown flag ──────────────────────────────────
123
+ stream.write('\n');
124
+ for (const { flagName, suggestion, isApplicable } of unknowns) {
125
+ if (suggestion && isApplicable) {
126
+ stream.write(
127
+ ` ${s.red('✗')} ${s.cyan(flagName)} is not a recognized flag — did you mean ${s.cyan(suggestion)}?\n`
128
+ );
129
+ } else if (suggestion) {
130
+ // Cross-command hint: helpful but can't be auto-applied
131
+ stream.write(
132
+ ` ${s.red('✗')} ${s.cyan(flagName)} is not a recognized flag — closest match: ${s.cyan(suggestion)} ${s.dim('(not available in this command)')}\n`
133
+ );
134
+ } else {
135
+ stream.write(` ${s.red('✗')} ${s.cyan(flagName)} is not a recognized flag.\n`);
136
+ }
137
+ }
138
+
139
+ const fixable = unknowns.filter(u => u.suggestion && u.isApplicable);
140
+
141
+ // ── TTY: offer an interactive fix for applicable suggestions ──────────────
142
+ if (stream.isTTY && process.stdin.setRawMode && fixable.length > 0) {
143
+ const preview = fixable.map(u => s.cyan(correctedArg(u.arg, u.suggestion))).join(', ');
144
+ stream.write(`\n Apply ${preview}? ${s.dim('y/N')} `);
145
+
146
+ const answer = await new Promise(res => {
147
+ process.stdin.setRawMode(true);
148
+ process.stdin.resume();
149
+ process.stdin.setEncoding('utf8');
150
+ process.stdin.once('data', char => {
151
+ process.stdin.setRawMode(false);
152
+ process.stdin.pause();
153
+ stream.write('\n\n');
154
+ if (char === '\x03') process.exit(0);
155
+ res(char === 'y' || char === 'Y');
156
+ });
157
+ });
158
+
159
+ if (answer) {
160
+ let corrected = args;
161
+ for (const { arg, suggestion } of fixable) {
162
+ const fixed = correctedArg(arg, suggestion);
163
+ corrected = corrected.map(a => a === arg ? fixed : a);
164
+ }
165
+ return corrected;
166
+ }
167
+
168
+ return null;
169
+ }
170
+
171
+ // ── Non-TTY: print tip and signal exit ────────────────────────────────────
172
+ if (fixable.length > 0) {
173
+ const tips = fixable.map(u => s.cyan(correctedArg(u.arg, u.suggestion))).join(', ');
174
+ stream.write(` ${s.dim('Try:')} ${tips}\n`);
175
+ }
176
+ stream.write('\n');
177
+ return null;
178
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Downloads ticket attachments from Jira to a local cache directory.
3
+ * Supports all file types; respects a per-file size cap.
4
+ * Auth headers are required — Jira attachment URLs are not public.
5
+ */
6
+
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { buildAuthHeader } from './jira-client.mjs';
10
+ import { DEFAULT_CONFIG_DIR } from './config.mjs';
11
+ const MAX_ATTACHMENTS = 20;
12
+ const MAX_FILE_BYTES = 10 * 1024 * 1024; // 10 MB
13
+
14
+ /**
15
+ * @param {object} ticket Normalized ticket (from normalizeTicket)
16
+ * @param {object} opts
17
+ * @param {object} opts.env Env-like object with JIRA_BASE_URL + auth vars
18
+ * @param {Function} opts.fetcher fetch-compatible function (default: globalThis.fetch)
19
+ * @param {string} opts.configDir Base config dir (default: ~/.ticketlens)
20
+ * @param {boolean} opts.noCache Force re-download even if cached (default: false)
21
+ * @param {Function} opts.onProgress Optional callback(msg: string) for progress lines
22
+ *
23
+ * @returns {Promise<Array<{filename, mimeType, size, localPath, skipped, skipReason, error}>>}
24
+ */
25
+ export async function downloadAttachments(ticket, opts = {}) {
26
+ const {
27
+ env = process.env,
28
+ fetcher = globalThis.fetch,
29
+ configDir = DEFAULT_CONFIG_DIR,
30
+ noCache = false,
31
+ onProgress = null,
32
+ } = opts;
33
+
34
+ const attachments = (ticket.attachments ?? []).filter(a => a.content);
35
+ if (attachments.length === 0) return [];
36
+
37
+ const cacheDir = path.join(configDir, 'cache', ticket.key);
38
+ fs.mkdirSync(cacheDir, { recursive: true });
39
+
40
+ const jiraOrigin = env.JIRA_BASE_URL ? new URL(env.JIRA_BASE_URL).origin : null;
41
+
42
+ // Attachments beyond the limit are bulk-skipped
43
+ const capped = attachments.slice(0, MAX_ATTACHMENTS);
44
+ const limited = attachments.slice(MAX_ATTACHMENTS).map(a => makeResult(a, null, 'limit', null));
45
+
46
+ // Pre-classify: resolve immediate results, collect pending downloads
47
+ const results = new Array(capped.length);
48
+ const pending = [];
49
+
50
+ for (let i = 0; i < capped.length; i++) {
51
+ const a = capped[i];
52
+
53
+ if (a.size && a.size > MAX_FILE_BYTES) {
54
+ onProgress?.(` skipped ${a.filename} (${formatSize(a.size)} — exceeds 10 MB limit)`);
55
+ results[i] = makeResult(a, null, 'too-large', null);
56
+ continue;
57
+ }
58
+
59
+ const localPath = path.join(cacheDir, sanitizeFilename(a.filename));
60
+
61
+ if (!noCache && fs.existsSync(localPath)) {
62
+ onProgress?.(` cached ${a.filename}`);
63
+ results[i] = makeResult(a, localPath, 'cached', null);
64
+ continue;
65
+ }
66
+
67
+ // SSRF protection: only send auth headers to the configured Jira origin
68
+ let contentOrigin;
69
+ try { contentOrigin = new URL(a.content).origin; } catch { contentOrigin = null; }
70
+ if (!contentOrigin || contentOrigin !== jiraOrigin) {
71
+ onProgress?.(` blocked ${a.filename} (cross-origin URL rejected)`);
72
+ results[i] = makeResult(a, null, 'ssrf-blocked', null);
73
+ continue;
74
+ }
75
+
76
+ pending.push({ a, localPath, i });
77
+ }
78
+
79
+ // Download pending attachments in parallel batches of 3
80
+ const BATCH = 3;
81
+ for (let b = 0; b < pending.length; b += BATCH) {
82
+ await Promise.all(pending.slice(b, b + BATCH).map(async ({ a, localPath, i }) => {
83
+ onProgress?.(` download ${a.filename}`);
84
+ try {
85
+ const headers = buildAuthHeader(env);
86
+ const response = await fetcher(a.content, { headers });
87
+ if (!response.ok) throw new Error(`HTTP ${response.status} (${response.statusText})`);
88
+ const buffer = await response.arrayBuffer();
89
+ fs.writeFileSync(localPath, Buffer.from(buffer));
90
+ results[i] = makeResult(a, localPath, null, null);
91
+ } catch (err) {
92
+ process.stderr.write(` warning: failed to download ${a.filename}: ${err.message}\n`);
93
+ results[i] = makeResult(a, null, 'error', err.message);
94
+ }
95
+ }));
96
+ }
97
+
98
+ return [...results, ...limited];
99
+ }
100
+
101
+ export function formatSize(bytes) {
102
+ if (!bytes) return '?';
103
+ if (bytes < 1024) return `${bytes}B`;
104
+ if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)}KB`;
105
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
106
+ }
107
+
108
+ function makeResult(attachment, localPath, skipReason, error) {
109
+ return {
110
+ filename: attachment.filename,
111
+ mimeType: attachment.mimeType,
112
+ size: attachment.size,
113
+ localPath,
114
+ skipped: localPath === null || skipReason === 'cached',
115
+ skipReason,
116
+ error,
117
+ };
118
+ }
119
+
120
+ function sanitizeFilename(filename) {
121
+ // Strip directory components, replace unsafe chars, preserve extension
122
+ return path.basename(filename).replace(/[^a-zA-Z0-9._\-]/g, '_');
123
+ }