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