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,152 @@
1
+ /**
2
+ * Pure scoring logic for ticket triage.
3
+ * No I/O — all functions take data and return results.
4
+ */
5
+
6
+ const BOT_PATTERNS = [
7
+ /^automatic\s*bot$/i,
8
+ /^jira\s*(software|automation)?$/i,
9
+ /^svn\s*commit/i,
10
+ /^bitbucket/i,
11
+ /^github/i,
12
+ /^gitlab/i,
13
+ /^jenkins/i,
14
+ /^bamboo/i,
15
+ /\bbot\b/i,
16
+ /\bautomation\b/i,
17
+ ];
18
+
19
+ export function isBot(authorName) {
20
+ if (!authorName) return false;
21
+ return BOT_PATTERNS.some(p => p.test(authorName));
22
+ }
23
+
24
+ const VCS_COMMIT_PATTERN = /\bsvn\s*commit\b|\bcommit\b.*\brevision\b|\bgit\s*commit\b/i;
25
+ const VCS_AUTHOR_PATTERN = /\*Author:\*\s*(\S+)/i;
26
+
27
+ /**
28
+ * Check if a bot comment is a VCS commit by the current user.
29
+ * Jira SVN/Git integrations post commits as bot users (e.g. "Automatic Bot")
30
+ * but the commit body contains the actual author.
31
+ */
32
+ export function isBotCommitByUser(comment, currentUser) {
33
+ if (!comment?.body || !currentUser) return false;
34
+ if (!VCS_COMMIT_PATTERN.test(comment.body)) return false;
35
+ const match = comment.body.match(VCS_AUTHOR_PATTERN);
36
+ if (!match) return false;
37
+ const commitAuthor = match[1].toLowerCase();
38
+ return (
39
+ (currentUser.name && commitAuthor === currentUser.name.toLowerCase()) ||
40
+ (currentUser.displayName && commitAuthor === currentUser.displayName.toLowerCase())
41
+ );
42
+ }
43
+
44
+ export function isFromCurrentUser(comment, currentUser) {
45
+ if (!comment || !currentUser) return false;
46
+ // Cloud: match by accountId
47
+ if (currentUser.accountId && comment.authorAccountId) {
48
+ return comment.authorAccountId === currentUser.accountId;
49
+ }
50
+ // Server: match by name/username
51
+ if (currentUser.name && comment.authorName) {
52
+ return comment.authorName === currentUser.name;
53
+ }
54
+ // Fallback: displayName match
55
+ if (currentUser.displayName && comment.author) {
56
+ return comment.author === currentUser.displayName;
57
+ }
58
+ return false;
59
+ }
60
+
61
+ /**
62
+ * Get the effective "last comment" considering bot VCS commits.
63
+ * Walk backwards through all comments (including bots).
64
+ * - If the last comment is a bot VCS commit by the current user, treat as user's response.
65
+ * - If the last comment is any other bot, skip it.
66
+ * - If a human comment, use it directly.
67
+ */
68
+ export function findLastEffectiveComment(comments, currentUser) {
69
+ for (let i = comments.length - 1; i >= 0; i--) {
70
+ const c = comments[i];
71
+ if (!isBot(c.author)) return { comment: c, fromCurrentUser: isFromCurrentUser(c, currentUser) };
72
+ if (isBotCommitByUser(c, currentUser)) return { comment: c, fromCurrentUser: true };
73
+ // Other bot comment — skip and keep looking
74
+ }
75
+ return { comment: null, fromCurrentUser: false };
76
+ }
77
+
78
+ export function scoreAttention(ticket, currentUser, opts = {}) {
79
+ const { staleDays = 5, now = new Date() } = opts;
80
+
81
+ const { comment: lastComment, fromCurrentUser } = findLastEffectiveComment(
82
+ ticket.comments || [], currentUser
83
+ );
84
+
85
+ // Check needs-response: last effective comment is NOT from current user
86
+ if (lastComment && !fromCurrentUser) {
87
+ const commentDate = lastComment.created ? new Date(lastComment.created) : null;
88
+ const daysSinceComment = commentDate ? (now - commentDate) / (1000 * 60 * 60 * 24) : 0;
89
+
90
+ // If the unanswered comment is older than the stale threshold, treat as aging
91
+ // rather than urgent — the window to respond promptly has passed.
92
+ if (daysSinceComment >= staleDays) {
93
+ return {
94
+ ticketKey: ticket.key,
95
+ summary: ticket.summary,
96
+ status: ticket.status,
97
+ urgency: 'aging',
98
+ reason: `${lastComment.author} commented ${Math.floor(daysSinceComment)}d ago`,
99
+ lastComment,
100
+ daysSinceUpdate: Math.floor(daysSinceComment),
101
+ };
102
+ }
103
+
104
+ return {
105
+ ticketKey: ticket.key,
106
+ summary: ticket.summary,
107
+ status: ticket.status,
108
+ urgency: 'needs-response',
109
+ reason: `${lastComment.author} commented`,
110
+ lastComment,
111
+ };
112
+ }
113
+
114
+ // Check aging: no activity for >= staleDays
115
+ const updatedDate = ticket.updated ? new Date(ticket.updated) : null;
116
+ if (updatedDate) {
117
+ const daysSinceUpdate = (now - updatedDate) / (1000 * 60 * 60 * 24);
118
+ if (daysSinceUpdate >= staleDays) {
119
+ return {
120
+ ticketKey: ticket.key,
121
+ summary: ticket.summary,
122
+ status: ticket.status,
123
+ urgency: 'aging',
124
+ reason: `No activity for ${Math.floor(daysSinceUpdate)} days`,
125
+ lastComment,
126
+ daysSinceUpdate: Math.floor(daysSinceUpdate),
127
+ };
128
+ }
129
+ }
130
+
131
+ return {
132
+ ticketKey: ticket.key,
133
+ summary: ticket.summary,
134
+ status: ticket.status,
135
+ urgency: 'clear',
136
+ reason: 'Up to date',
137
+ lastComment,
138
+ };
139
+ }
140
+
141
+ const URGENCY_ORDER = { 'needs-response': 0, 'aging': 1, 'clear': 2 };
142
+
143
+ export function sortByUrgency(scores) {
144
+ return [...scores].sort((a, b) => {
145
+ const orderDiff = URGENCY_ORDER[a.urgency] - URGENCY_ORDER[b.urgency];
146
+ if (orderDiff !== 0) return orderDiff;
147
+ // Within same urgency, sort by most recent activity (lastComment date or ticket updated)
148
+ const dateA = a.lastComment?.created ? new Date(a.lastComment.created) : new Date(0);
149
+ const dateB = b.lastComment?.created ? new Date(b.lastComment.created) : new Date(0);
150
+ return dateB - dateA; // most recent first
151
+ });
152
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Session banner for TicketLens CLI.
3
+ * Renders a colored header box with embedded spinner, and a footer for errors.
4
+ * Writes to stderr so stdout stays clean for piped output.
5
+ */
6
+
7
+ import { createStyler } from './ansi.mjs';
8
+ import { getVersion } from './config.mjs';
9
+
10
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
11
+ const visibleLength = (str) => str.replace(ANSI_RE, '').length;
12
+
13
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
14
+ const SPINNER_INTERVAL = 80;
15
+
16
+
17
+ function buildBox(lines, { s, borderColor = 'cyan' }) {
18
+ const maxVisible = lines.reduce((max, l) => Math.max(max, visibleLength(l)), 0);
19
+ const innerWidth = maxVisible + 2;
20
+
21
+ const padLine = (line) => {
22
+ const pad = innerWidth - visibleLength(line) - 1;
23
+ return ' ' + line + ' '.repeat(Math.max(0, pad));
24
+ };
25
+
26
+ const bc = s[borderColor] || s.cyan;
27
+ const top = bc('╭' + '─'.repeat(innerWidth) + '╮');
28
+ const bot = bc('╰' + '─'.repeat(innerWidth) + '╯');
29
+ const body = lines.map(l => bc('│') + padLine(l) + bc('│')).join('\n');
30
+
31
+ return { top, body, bot, innerWidth, bc };
32
+ }
33
+
34
+ export function createSession(conn, { stream = process.stderr } = {}) {
35
+ const isTTY = stream.isTTY;
36
+ const s = createStyler({ isTTY });
37
+ const version = getVersion();
38
+
39
+ const hostname = conn.baseUrl ? new URL(conn.baseUrl).hostname : 'unknown';
40
+ const profileLabel = conn.profileName || 'default';
41
+ const userLabel = conn.email || (conn.pat ? 'token auth' : 'unknown');
42
+
43
+ const jiraLabel = conn.profileName
44
+ ? conn.profileName.charAt(0).toUpperCase() + conn.profileName.slice(1) + ' Jira'
45
+ : hostname;
46
+
47
+ // Pre-build the info lines to calculate box width including status line.
48
+ const infoLines = [
49
+ `${s.bold(s.brand('◆ TicketLens'))} ${s.dim(`v${version}`)}`,
50
+ ` ${s.dim('Profile:')} ${profileLabel}`,
51
+ ` ${s.dim('Server:')} ${hostname}`,
52
+ ` ${s.dim('User:')} ${userLabel}`,
53
+ ];
54
+ // Reserve width for the longest possible status message (failure is longer than success).
55
+ const allLines = [...infoLines, '', `● Connection to ${jiraLabel} failed.`];
56
+ const maxVisible = allLines.reduce((max, l) => Math.max(max, visibleLength(l)), 0);
57
+ const innerWidth = maxVisible + 2;
58
+
59
+ const bc = s.brand; // border color
60
+ let timer = null;
61
+ let boxOpen = false;
62
+
63
+ const padInner = (line) => {
64
+ const pad = innerWidth - visibleLength(line) - 1;
65
+ return ' ' + line + ' '.repeat(Math.max(0, pad));
66
+ };
67
+
68
+ const writeLine = (content) => {
69
+ stream.write(bc('│') + padInner(content) + bc('│'));
70
+ };
71
+
72
+ return {
73
+ /** Render a full pre-connection banner (non-TTY fallback). */
74
+ _plainBanner() {
75
+ stream.write(`[TicketLens v${version}] profile: ${profileLabel} server: ${hostname}\n`);
76
+ },
77
+
78
+ /** Start the header: renders full closed box with spinner on status line. */
79
+ spin(message) {
80
+ if (!isTTY) {
81
+ this._plainBanner();
82
+ return this;
83
+ }
84
+
85
+ const bot = bc('╰' + '─'.repeat(innerWidth) + '╯');
86
+
87
+ // Top border + info lines + separator
88
+ const top = bc('╭' + '─'.repeat(innerWidth) + '╮');
89
+ stream.write(top + '\n');
90
+ for (const line of infoLines) {
91
+ writeLine(line);
92
+ stream.write('\n');
93
+ }
94
+ // Blank separator line
95
+ writeLine('');
96
+ stream.write('\n');
97
+ boxOpen = true;
98
+
99
+ // Render initial spinner line + bottom border (box appears closed)
100
+ let frame = 0;
101
+ const writeSpinnerAndBorder = () => {
102
+ const content = `${s.brand(SPINNER_FRAMES[frame])} ${message}`;
103
+ writeLine(content);
104
+ stream.write('\n');
105
+ stream.write(bot);
106
+ };
107
+
108
+ stream.write('\x1b[?25l'); // hide cursor
109
+ writeSpinnerAndBorder();
110
+
111
+ timer = setInterval(() => {
112
+ frame = (frame + 1) % SPINNER_FRAMES.length;
113
+ // Clear bottom border line, move up, clear spinner line, redraw both
114
+ stream.write('\r\x1b[2K\x1b[A\r\x1b[2K');
115
+ writeSpinnerAndBorder();
116
+ }, SPINNER_INTERVAL);
117
+
118
+ return this;
119
+ },
120
+
121
+ /** Stop spinner, show green status, redraw final box. */
122
+ connected() {
123
+ if (timer) {
124
+ clearInterval(timer);
125
+ timer = null;
126
+ }
127
+ if (!isTTY) return this;
128
+
129
+ const bot = bc('╰' + '─'.repeat(innerWidth) + '╯');
130
+ const status = `${s.green('●')} Connected to ${jiraLabel}.`;
131
+ // Clear bottom border line, move up, clear spinner line, redraw both
132
+ stream.write('\r\x1b[2K\x1b[A\r\x1b[2K');
133
+ writeLine(status);
134
+ stream.write('\n');
135
+ stream.write(bot + '\n');
136
+ stream.write('\x1b[?25h'); // restore cursor
137
+ boxOpen = false;
138
+ return this;
139
+ },
140
+
141
+ /** Stop spinner, show red status, redraw final box. */
142
+ failed() {
143
+ if (timer) {
144
+ clearInterval(timer);
145
+ timer = null;
146
+ }
147
+ if (!isTTY) return this;
148
+
149
+ const bot = bc('╰' + '─'.repeat(innerWidth) + '╯');
150
+ const status = `${s.red('●')} Connection to ${jiraLabel} failed.`;
151
+ // Clear bottom border line, move up, clear spinner line, redraw both
152
+ stream.write('\r\x1b[2K\x1b[A\r\x1b[2K');
153
+ writeLine(status);
154
+ stream.write('\n');
155
+ stream.write(bot + '\n');
156
+ stream.write('\x1b[?25h'); // restore cursor
157
+ boxOpen = false;
158
+ return this;
159
+ },
160
+
161
+ /** Render a colored footer box for errors or info messages. */
162
+ footer(message, type = 'error', hint = null) {
163
+ if (!isTTY) {
164
+ stream.write(`${message}\n`);
165
+ if (hint) stream.write(` ${hint}\n`);
166
+ return this;
167
+ }
168
+
169
+ const icon = type === 'error' ? s.red('✖') : s.brand('ℹ');
170
+ const colorFn = type === 'error' ? s.red : s.dim;
171
+ const contentLine = `${icon} ${message}`;
172
+ const lines = [contentLine];
173
+ if (hint) lines.push(` ${s.dim(hint)}`);
174
+
175
+ const maxContent = lines.reduce((max, l) => Math.max(max, visibleLength(l)), 0);
176
+ const footerWidth = Math.max(maxContent + 2, innerWidth);
177
+
178
+ const padFooter = (line) => {
179
+ const pad = footerWidth - visibleLength(line) - 1;
180
+ return ' ' + line + ' '.repeat(Math.max(0, pad));
181
+ };
182
+
183
+ const top = colorFn('╭' + '─'.repeat(footerWidth) + '╮');
184
+ const body = lines.map(l => colorFn('│') + padFooter(l) + colorFn('│')).join('\n');
185
+ const bot = colorFn('╰' + '─'.repeat(footerWidth) + '╯');
186
+
187
+ stream.write(`${top}\n${body}\n${bot}\n`);
188
+ return this;
189
+ },
190
+
191
+ /** Expose for external use (e.g. scan spinner outside box). */
192
+ get styler() { return s; },
193
+ get label() { return jiraLabel; },
194
+ };
195
+ }
196
+
197
+ // Keep backward compat for tests — renderBanner delegates to createSession.
198
+ export function renderBanner(conn, opts = {}) {
199
+ const session = createSession(conn, opts);
200
+ session._plainBanner();
201
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Assembles a normalized ticket into a structured markdown TicketBrief.
3
+ */
4
+
5
+ import { formatTable } from './table-formatter.mjs';
6
+ import { formatSize } from './attachment-downloader.mjs';
7
+ import { timeAgo, truncate, stripCr } from './config.mjs';
8
+
9
+ export function assembleBrief(ticket, codeRefs = null) {
10
+ const sections = [];
11
+ sections.push(`# ${ticket.key}: ${ticket.summary}`);
12
+
13
+ const meta = [`**Type:** ${ticket.type}`, `**Status:** ${ticket.status}`, `**Priority:** ${ticket.priority}`, `**Assignee:** ${ticket.assignee ?? 'Unassigned'}`, `**Reporter:** ${ticket.reporter ?? 'Unknown'}`];
14
+ if (ticket.created) meta.push(`**Created:** ${ticket.created.split('T')[0]}`);
15
+ if (ticket.updated) meta.push(`**Updated:** ${ticket.updated.split('T')[0]}`);
16
+ sections.push(meta.join(' | '));
17
+
18
+ if (ticket.description) {
19
+ sections.push(`## Description\n\n${stripCr(ticket.description)}`);
20
+ }
21
+
22
+ if (ticket.comments?.length > 0) {
23
+ const commentLines = ticket.comments.map(c => {
24
+ const date = c.created ? c.created.split('T')[0] : 'unknown';
25
+ return `### **${c.author}** (${date})\n\n${c.body.replace(/\r/g, '')}`;
26
+ });
27
+ sections.push(`## Comments\n\n${commentLines.join('\n\n---\n\n')}`);
28
+ }
29
+
30
+ if (ticket.linkedTicketDetails?.length > 0) {
31
+ const linkedSections = ticket.linkedTicketDetails.map(lt => {
32
+ const parts = [`### ${lt.key}: ${lt.summary}`, `**Type:** ${lt.type} | **Status:** ${lt.status}`];
33
+ if (lt.description) parts.push(stripCr(lt.description));
34
+ if (lt.comments?.length > 0) {
35
+ const cmts = lt.comments.map(c => {
36
+ const date = c.created ? c.created.split('T')[0] : 'unknown';
37
+ return `**${c.author}** (${date}): ${c.body.replace(/\r/g, '')}`;
38
+ });
39
+ parts.push(cmts.join('\n\n'));
40
+ }
41
+ return parts.join('\n\n');
42
+ });
43
+ sections.push(`## Linked Tickets\n\n${linkedSections.join('\n\n---\n\n')}`);
44
+ }
45
+
46
+ if (codeRefs) {
47
+ const categories = [
48
+ ['File Paths', codeRefs.filePaths],
49
+ ['Methods', codeRefs.methods],
50
+ ['Classes', codeRefs.classes],
51
+ ['Git SHAs', codeRefs.shas],
52
+ ['SVN Revisions', codeRefs.svnRevisions],
53
+ ['Branches', codeRefs.branches],
54
+ ['Namespaces', codeRefs.namespaces],
55
+ ];
56
+ const filled = categories
57
+ .filter(([, items]) => items?.length > 0)
58
+ .map(([label, items]) => `**${label}:** ${items.map(i => '`' + i + '`').join(', ')}`);
59
+ if (filled.length > 0) {
60
+ sections.push(`## Code References\n\n${filled.join('\n')}`);
61
+ }
62
+ }
63
+
64
+ if (ticket.attachments?.length > 0) {
65
+ const lines = ticket.attachments.map(a => {
66
+ const r = (ticket.localAttachments ?? []).find(x => x.filename === a.filename);
67
+ const sz = formatSize(a.size);
68
+ if (r?.localPath) {
69
+ const note = r.skipReason === 'cached' ? ', cached' : '';
70
+ return `- \`${r.localPath}\` _(${a.filename}, ${sz}${note})_`;
71
+ }
72
+ if (r?.skipReason === 'too-large') return `- ${a.filename} _(${sz} — exceeds 10 MB limit)_`;
73
+ if (r?.skipReason === 'limit') return `- ${a.filename} _(${sz} — attachment limit reached)_`;
74
+ if (r?.skipReason === 'error') return `- ${a.filename} _(${sz} — download failed: ${r.error})_`;
75
+ return `- ${a.filename} _(${sz})_`;
76
+ });
77
+ sections.push(`## Attachments\n\n${lines.join('\n')}`);
78
+ }
79
+
80
+ return sections.join('\n\n');
81
+ }
82
+
83
+ export function assembleTriageSummary(scoredTickets, opts = {}) {
84
+ const { staleDays = 5, baseUrl } = opts;
85
+ const browseUrl = baseUrl ? baseUrl.replace(/\/$/, '') + '/browse/' : null;
86
+ const actionable = scoredTickets.filter(t => t.urgency !== 'clear');
87
+
88
+ if (actionable.length === 0) {
89
+ return 'All clear — no tickets need your attention right now.';
90
+ }
91
+
92
+ const sections = [];
93
+ sections.push(`Tickets Needing Your Attention (${actionable.length} found)`);
94
+
95
+ const needsResponse = actionable.filter(t => t.urgency === 'needs-response');
96
+ const aging = actionable.filter(t => t.urgency === 'aging');
97
+ const allKeys = [];
98
+
99
+ if (needsResponse.length > 0) {
100
+ const tableRows = needsResponse.map((t, i) => {
101
+ allKeys.push(t.ticketKey);
102
+ const ago = t.lastComment ? timeAgo(t.lastComment.created) : '';
103
+ const commenter = t.lastComment?.author ?? 'Unknown';
104
+ const snippet = t.lastComment?.body ? truncate(t.lastComment.body, 60) : '';
105
+ return [String(i + 1), t.ticketKey, truncate(t.summary, 50), t.status, commenter, ago, snippet];
106
+ });
107
+ const table = formatTable(
108
+ ['#', 'Ticket', 'Summary', 'Status', 'From', 'When', 'Comment'],
109
+ tableRows,
110
+ { maxWidths: { 2: 50, 6: 60 } },
111
+ );
112
+ sections.push(`Needs Response (${needsResponse.length})\n\n${table}`);
113
+ }
114
+
115
+ if (aging.length > 0) {
116
+ const agingOffset = needsResponse.length;
117
+ const tableRows = aging.map((t, i) => {
118
+ allKeys.push(t.ticketKey);
119
+ const days = t.daysSinceUpdate ?? '?';
120
+ return [String(agingOffset + i + 1), t.ticketKey, truncate(t.summary, 50), t.status, `${days}d`];
121
+ });
122
+ const table = formatTable(
123
+ ['#', 'Ticket', 'Summary', 'Status', 'Stale'],
124
+ tableRows,
125
+ { maxWidths: { 2: 50 } },
126
+ );
127
+ sections.push(`Aging — no activity > ${staleDays} days (${aging.length})\n\n${table}`);
128
+ }
129
+
130
+ if (browseUrl && allKeys.length > 0) {
131
+ const links = allKeys.map((k, i) => `[${i + 1}] ${k}: ${browseUrl}${k}`);
132
+ sections.push(`Quick Links\n\n${links.join('\n')}`);
133
+ }
134
+
135
+ return sections.join('\n\n');
136
+ }
137
+
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Brief data cache — stores full normalized ticket JSON so repeat fetches
3
+ * skip the Jira API call entirely.
4
+ *
5
+ * Path: ~/.ticketlens/cache/PROFILE/TICKET-KEY/brief.json
6
+ * Format: { fetchedAt, depth, ticket }
7
+ * TTL: 4 hours (bypassed by --no-cache)
8
+ */
9
+
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { DEFAULT_CONFIG_DIR } from './config.mjs';
13
+ export const BRIEF_TTL_MS = 4 * 60 * 60 * 1000; // 4 hours (default)
14
+ export const DEFAULT_BRIEF_TTL = '4h'; // human-readable default for display/config
15
+
16
+ /**
17
+ * Returns the absolute path to the brief cache file for a given ticket + profile.
18
+ */
19
+ export function briefCachePath(ticketKey, profileName, configDir = DEFAULT_CONFIG_DIR) {
20
+ const safeProfile = (profileName || '_default').replace(/[^a-zA-Z0-9_\-]/g, '_');
21
+ const safeKey = ticketKey.replace(/[^a-zA-Z0-9_\-]/g, '_');
22
+ return path.join(configDir, 'cache', safeProfile, safeKey, 'brief.json');
23
+ }
24
+
25
+ /**
26
+ * Reads a cached brief. Returns null on:
27
+ * - cache miss
28
+ * - expired TTL
29
+ * - cached depth is less than the requested depth
30
+ *
31
+ * @param {string} ticketKey
32
+ * @param {string|null} profileName
33
+ * @param {number} depth
34
+ * @param {string} [configDir]
35
+ * @param {number} [ttlMs] - override TTL in ms; defaults to BRIEF_TTL_MS (4h)
36
+ * Returns { ticket, fetchedAt, cachedDepth } on hit.
37
+ */
38
+ export function readBriefCache(ticketKey, profileName, depth, configDir = DEFAULT_CONFIG_DIR, ttlMs = BRIEF_TTL_MS) {
39
+ const filePath = briefCachePath(ticketKey, profileName, configDir);
40
+ if (!fs.existsSync(filePath)) return null;
41
+
42
+ let data;
43
+ try {
44
+ data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
45
+ } catch {
46
+ return null;
47
+ }
48
+
49
+ const age = Date.now() - new Date(data.fetchedAt).getTime();
50
+ if (age > ttlMs) {
51
+ try { fs.unlinkSync(filePath); } catch { /* non-fatal */ }
52
+ return null;
53
+ }
54
+
55
+ // Serve cache only if cached depth covers the requested depth
56
+ if ((data.depth ?? 0) < depth) return null;
57
+
58
+ return { ticket: data.ticket, fetchedAt: data.fetchedAt, cachedDepth: data.depth };
59
+ }
60
+
61
+ /**
62
+ * Writes a normalized ticket to the brief cache.
63
+ * Non-fatal — a write failure does not affect the output.
64
+ */
65
+ export function writeBriefCache(ticketKey, profileName, depth, ticket, configDir = DEFAULT_CONFIG_DIR) {
66
+ const filePath = briefCachePath(ticketKey, profileName, configDir);
67
+ try {
68
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
69
+ fs.writeFileSync(filePath, JSON.stringify({ fetchedAt: new Date().toISOString(), depth, ticket }));
70
+ } catch {
71
+ // Non-fatal
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Removes the brief cache file for a specific ticket + profile.
77
+ */
78
+ export function clearBriefCache(ticketKey, profileName, configDir = DEFAULT_CONFIG_DIR) {
79
+ const filePath = briefCachePath(ticketKey, profileName, configDir);
80
+ try {
81
+ if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
82
+ } catch { /* non-fatal */ }
83
+ }
84
+
85
+ /**
86
+ * Returns all brief cache entries across all profiles.
87
+ * Each entry: { profileName, ticketKey, filePath, size, mtimeMs, fetchedAt, depth }
88
+ */
89
+ export function getBriefCacheEntries(configDir = DEFAULT_CONFIG_DIR) {
90
+ const cacheDir = path.join(configDir, 'cache');
91
+ if (!fs.existsSync(cacheDir)) return [];
92
+
93
+ const entries = [];
94
+ let profileDirs;
95
+ try {
96
+ profileDirs = fs.readdirSync(cacheDir).filter(d => {
97
+ try { return fs.statSync(path.join(cacheDir, d)).isDirectory(); } catch { return false; }
98
+ });
99
+ } catch { return []; }
100
+
101
+ for (const profileName of profileDirs) {
102
+ const profileDir = path.join(cacheDir, profileName);
103
+ let ticketDirs;
104
+ try { ticketDirs = fs.readdirSync(profileDir); } catch { continue; }
105
+
106
+ for (const ticketKey of ticketDirs) {
107
+ const briefFile = path.join(profileDir, ticketKey, 'brief.json');
108
+ if (!fs.existsSync(briefFile)) continue;
109
+ try {
110
+ const stat = fs.statSync(briefFile);
111
+ let fetchedAt = null;
112
+ let depth = null;
113
+ try {
114
+ const data = JSON.parse(fs.readFileSync(briefFile, 'utf8'));
115
+ fetchedAt = data.fetchedAt ?? null;
116
+ depth = data.depth ?? null;
117
+ } catch { /* use nulls */ }
118
+ entries.push({ profileName, ticketKey, filePath: briefFile, size: stat.size, mtimeMs: stat.mtimeMs, fetchedAt, depth });
119
+ } catch { /* deleted between readdir and stat */ }
120
+ }
121
+ }
122
+
123
+ return entries;
124
+ }
125
+
126
+ /**
127
+ * Human-readable age string from an ISO timestamp.
128
+ */
129
+ export function briefCacheAge(fetchedAt) {
130
+ const ms = Date.now() - new Date(fetchedAt).getTime();
131
+ const mins = Math.floor(ms / 60000);
132
+ if (mins < 1) return 'just now';
133
+ if (mins < 60) return `${mins}m ago`;
134
+ const hours = Math.floor(mins / 60);
135
+ if (hours < 24) return `${hours}h ago`;
136
+ return `${Math.floor(hours / 24)}d ago`;
137
+ }