ticketlens 0.8.1 → 0.9.2
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/README.md +4 -0
- package/package.json +1 -1
- package/skills/jtb/SKILL.md +10 -1
- package/skills/jtb/scripts/fetch-my-tickets.mjs +7 -2
- package/skills/jtb/scripts/fetch-ticket.mjs +22 -5
- package/skills/jtb/scripts/lib/api-utils.mjs +1 -1
- package/skills/jtb/scripts/lib/attention-scorer.mjs +23 -2
- package/skills/jtb/scripts/lib/brief-assembler.mjs +32 -9
- package/skills/jtb/scripts/lib/help.mjs +5 -3
- package/skills/jtb/scripts/lib/jira-client.mjs +34 -6
- package/skills/jtb/scripts/lib/profile-resolver.mjs +1 -0
- package/skills/jtb/scripts/lib/styled-assembler.mjs +34 -10
- package/skills/jtb/scripts/lib/sync.mjs +2 -0
- package/skills/jtb/scripts/lib/template-resolver.mjs +124 -0
- package/skills/jtb/scripts/lib/triage-history.mjs +5 -4
package/README.md
CHANGED
|
@@ -102,6 +102,7 @@ ticketlens CNV1-2 --summarize --provider=groq # Force a specific AI provider [P
|
|
|
102
102
|
ticketlens CNV1-2 --summarize --cloud # AI summary routed through TicketLens API [Pro]
|
|
103
103
|
ticketlens CNV1-2 --handoff # AI handoff brief from comment thread (BYOK) [Pro]
|
|
104
104
|
ticketlens CNV1-2 --handoff --cloud # AI handoff brief via TicketLens API [Pro]
|
|
105
|
+
ticketlens CNV1-2 --template=quick # Apply a brief template (full|quick|code-review, or custom [Team])
|
|
105
106
|
```
|
|
106
107
|
|
|
107
108
|
| `--depth` | Scope |
|
|
@@ -528,6 +529,9 @@ ticketlens CNV1-2 --summarize --provider=groq # Force Groq (Llama 3.1, free ti
|
|
|
528
529
|
ticketlens CNV1-2 --summarize --cloud # AI summary via TicketLens API [Pro]
|
|
529
530
|
ticketlens CNV1-2 --handoff # AI handoff brief from comment thread (BYOK) [Pro]
|
|
530
531
|
ticketlens CNV1-2 --handoff --cloud # AI handoff brief via TicketLens API [Pro]
|
|
532
|
+
ticketlens CNV1-2 --template=quick # Apply quick template (meta + 2 comments only)
|
|
533
|
+
ticketlens CNV1-2 --template=code-review # Apply code-review template (meta + desc + linked + code refs)
|
|
534
|
+
ticketlens CNV1-2 --template=full # Apply full template (all sections, default)
|
|
531
535
|
ticketlens CNV1-2 --depth=2 --profile=acme --plain # Combine flags freely
|
|
532
536
|
|
|
533
537
|
# Pipe plain output to clipboard, LLM, or file
|
package/package.json
CHANGED
package/skills/jtb/SKILL.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- jtb-skill-version: 0.
|
|
1
|
+
<!-- jtb-skill-version: 0.9.1 -->
|
|
2
2
|
---
|
|
3
3
|
name: jtb
|
|
4
4
|
description: Fetch a Jira ticket's full context (description, comments, linked issues, code references) and assemble a structured TicketBrief for implementation planning. Use when user types /jtb, mentions a Jira ticket key, or wants to plan work from a Jira ticket.
|
|
@@ -23,6 +23,9 @@ Fetches a Jira ticket and produces a structured brief with code references, then
|
|
|
23
23
|
/jtb PROD-1234 --summarize # AI summary of the brief (Pro)
|
|
24
24
|
/jtb PROD-1234 --summarize --cloud # summary via TicketLens cloud (Pro)
|
|
25
25
|
/jtb PROD-1234 --handoff # structured handoff brief from comments (Pro)
|
|
26
|
+
/jtb PROD-1234 --template=quick # apply quick template (meta + 2 comments only)
|
|
27
|
+
/jtb PROD-1234 --template=code-review # apply code-review template (meta + desc + linked + code refs)
|
|
28
|
+
/jtb PROD-1234 --template=full # apply full template (all sections, default)
|
|
26
29
|
/jtb triage # scan your assigned tickets for attention
|
|
27
30
|
/jtb triage --stale=3 # custom aging threshold (days)
|
|
28
31
|
/jtb triage --status=CR,QA # only check specific statuses
|
|
@@ -72,6 +75,12 @@ node ~/.agents/skills/jtb/scripts/fetch-my-tickets.mjs $EXTRA_ARGS
|
|
|
72
75
|
|
|
73
76
|
Where `$EXTRA_ARGS` are any flags passed (e.g. `--stale=3 --status=QA --profile=acme`).
|
|
74
77
|
|
|
78
|
+
**Urgency levels** (highest → lowest priority):
|
|
79
|
+
- 🔴 `needs-response` — someone commented and you haven't replied
|
|
80
|
+
- 🟡 `aging` — no activity for ≥ `staleDays` (default 5d)
|
|
81
|
+
- 🔵 `stale` — ticket stuck in the same Jira status for ≥ N days (Pro — requires a stale rule configured in the Console)
|
|
82
|
+
- 🟢 `clear` — up to date, no action needed
|
|
83
|
+
|
|
75
84
|
**IMPORTANT:** Copy the script's stdout and display it directly as your response text (not inside a tool result). This ensures the markdown table renders visibly and URLs are clickable in the terminal. No VCS enrichment, no plan mode. Stop here.
|
|
76
85
|
|
|
77
86
|
---
|
|
@@ -293,8 +293,13 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
293
293
|
session.spin(`Connecting to ${session.label}…`);
|
|
294
294
|
|
|
295
295
|
// Fire both requests concurrently — they are independent of each other
|
|
296
|
+
// expandChangelog is derived from staleRule, which the sync API only sends to Pro/Team/Enterprise
|
|
297
|
+
// users. A user who manually adds staleRule to their local profile.json would still expand
|
|
298
|
+
// their own Jira data — that is acceptable (Jira credentials are theirs; we gate TicketLens
|
|
299
|
+
// features server-side, not Jira API access).
|
|
300
|
+
const expandChangelog = !!(conn.staleRule?.enabled);
|
|
296
301
|
const userPromise = adapter.fetchCurrentUser();
|
|
297
|
-
const ticketsPromise = adapter.searchTickets(jql);
|
|
302
|
+
const ticketsPromise = adapter.searchTickets(jql, { expandChangelog });
|
|
298
303
|
|
|
299
304
|
let currentUser;
|
|
300
305
|
try {
|
|
@@ -407,7 +412,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
407
412
|
process.stderr.write(`Viewing ${assigneeName}'s tickets\n\n`);
|
|
408
413
|
}
|
|
409
414
|
|
|
410
|
-
const scored = tickets.map(t => scoreAttention(t, effectiveUser, { staleDays, customRules: conn.attentionRules }));
|
|
415
|
+
const scored = tickets.map(t => scoreAttention(t, effectiveUser, { staleDays, customRules: conn.attentionRules, staleRule: conn.staleRule ?? null }));
|
|
411
416
|
const actionable = scored.filter(s => s.urgency !== 'clear' && s.urgency !== 'ignore');
|
|
412
417
|
const sorted = sortByUrgency(actionable);
|
|
413
418
|
const rawTicketMap = new Map(tickets.map(t => [t.key, t]));
|
|
@@ -24,6 +24,8 @@ import { handleUnknownFlags } from './lib/arg-validator.mjs';
|
|
|
24
24
|
import { TICKET_KEY_PATTERN } from './lib/cli.mjs';
|
|
25
25
|
import { downloadAttachments } from './lib/attachment-downloader.mjs';
|
|
26
26
|
import { readBriefCache, writeBriefCache, briefCacheAge, BRIEF_TTL_MS } from './lib/brief-cache.mjs';
|
|
27
|
+
import { readCliToken } from './lib/cli-auth.mjs';
|
|
28
|
+
import { resolveTemplate } from './lib/template-resolver.mjs';
|
|
27
29
|
import { parseAge } from './lib/cache-manager.mjs';
|
|
28
30
|
import { createStyler } from './lib/ansi.mjs';
|
|
29
31
|
import { isLicensed, showUpgradePrompt, readLicense } from './lib/license.mjs';
|
|
@@ -836,12 +838,27 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
836
838
|
|
|
837
839
|
const validatedArgs = await handleUnknownFlags(
|
|
838
840
|
args,
|
|
839
|
-
['--help', '-h', '--plain', '--styled', '--no-attachments', '--no-cache', '--profile=', '--depth=', '--check', '--summarize', '--cloud', '--compliance', '--budget=', '--handoff', '--provider='],
|
|
841
|
+
['--help', '-h', '--plain', '--styled', '--no-attachments', '--no-cache', '--profile=', '--depth=', '--check', '--summarize', '--cloud', '--compliance', '--budget=', '--handoff', '--provider=', '--template='],
|
|
840
842
|
{ hints: ['--stale=', '--status=', '--static'] } // triage-only flags — shown as hints, not applied
|
|
841
843
|
);
|
|
842
844
|
if (validatedArgs === null) { process.exitCode = 1; return; }
|
|
843
845
|
args = validatedArgs;
|
|
844
846
|
|
|
847
|
+
// Resolve --template= before cache check so both paths share the same sections.
|
|
848
|
+
const templateArg = args.find(a => a.startsWith('--template='));
|
|
849
|
+
let templateSections = null;
|
|
850
|
+
if (templateArg) {
|
|
851
|
+
const templateSlug = templateArg.split('=')[1];
|
|
852
|
+
try {
|
|
853
|
+
const tpl = await resolveTemplate(templateSlug, { token: readCliToken(configDir), fetcher });
|
|
854
|
+
templateSections = tpl.sections;
|
|
855
|
+
} catch (err) {
|
|
856
|
+
process.stderr.write(`Error: ${err.message}\n`);
|
|
857
|
+
process.exitCode = 1;
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
845
862
|
// When multiple profiles share the same ticket prefix and we're in a TTY,
|
|
846
863
|
// ask the user to choose rather than silently picking the first match.
|
|
847
864
|
if (!profileName && process.stderr.isTTY && process.stdin.setRawMode) {
|
|
@@ -942,7 +959,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
942
959
|
|
|
943
960
|
// Apply --budget pruning on the plain brief before styling (Pro only).
|
|
944
961
|
// When --budget is active, always output plain text (pruning operates on unescaped chars).
|
|
945
|
-
let plainBrief = assembleBrief(cached.ticket, codeRefs);
|
|
962
|
+
let plainBrief = assembleBrief(cached.ticket, codeRefs, templateSections);
|
|
946
963
|
const budgetArgCached = args.find(a => a.startsWith('--budget='));
|
|
947
964
|
if (budgetArgCached) {
|
|
948
965
|
const budgetN = parseInt(budgetArgCached.split('=')[1], 10);
|
|
@@ -956,7 +973,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
956
973
|
}
|
|
957
974
|
let brief = (budgetArgCached && licensedFn('pro', configDir))
|
|
958
975
|
? plainBrief
|
|
959
|
-
: (useStyled ? styleBrief(cached.ticket, codeRefs, { styled: true }) : plainBrief);
|
|
976
|
+
: (useStyled ? styleBrief(cached.ticket, codeRefs, { styled: true, templateSections }) : plainBrief);
|
|
960
977
|
|
|
961
978
|
if (args.includes('--handoff')) {
|
|
962
979
|
const handoffResult = await applyHandoff(cached.ticket, args, opts, configDir, licensedFn, upgradeFn);
|
|
@@ -1128,7 +1145,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
1128
1145
|
|
|
1129
1146
|
// Apply --budget pruning on the plain brief before styling (Pro only).
|
|
1130
1147
|
// When --budget is active, always output plain text (pruning operates on unescaped chars).
|
|
1131
|
-
let plainOutput = assembleBrief(ticket, codeRefs);
|
|
1148
|
+
let plainOutput = assembleBrief(ticket, codeRefs, templateSections);
|
|
1132
1149
|
const budgetArg = args.find(a => a.startsWith('--budget='));
|
|
1133
1150
|
if (budgetArg) {
|
|
1134
1151
|
const budgetN = parseInt(budgetArg.split('=')[1], 10);
|
|
@@ -1142,7 +1159,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
1142
1159
|
}
|
|
1143
1160
|
let output = (budgetArg && licensedFn('pro', configDir))
|
|
1144
1161
|
? plainOutput
|
|
1145
|
-
: (useStyled ? styleBrief(ticket, codeRefs, { styled: true }) : plainOutput);
|
|
1162
|
+
: (useStyled ? styleBrief(ticket, codeRefs, { styled: true, templateSections }) : plainOutput);
|
|
1146
1163
|
|
|
1147
1164
|
if (args.includes('--handoff')) {
|
|
1148
1165
|
const handoffResult = await applyHandoff(ticket, args, opts, configDir, licensedFn, upgradeFn);
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Centralised here to avoid triplicating the regex and warning logic.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
export const DEFAULT_API_BASE = '
|
|
6
|
+
export const DEFAULT_API_BASE = 'https://api.ticketlens.app';
|
|
7
7
|
|
|
8
8
|
// Matches localhost, 127.0.0.1, and any hostname ending in .test or .local,
|
|
9
9
|
// with an optional port — all treated as local-only addresses.
|
|
@@ -97,7 +97,7 @@ function applyCustomRule(ticket, rule) {
|
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
export function scoreAttention(ticket, currentUser, opts = {}) {
|
|
100
|
-
const { staleDays = 5, now = new Date(), customRules } = opts;
|
|
100
|
+
const { staleDays = 5, now = new Date(), customRules, staleRule } = opts;
|
|
101
101
|
|
|
102
102
|
// Custom rules evaluated first — first match wins
|
|
103
103
|
if (Array.isArray(customRules)) {
|
|
@@ -162,6 +162,27 @@ export function scoreAttention(ticket, currentUser, opts = {}) {
|
|
|
162
162
|
}
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
// Stale check — only when rule is active and statusChangedAt was captured via changelog expand.
|
|
166
|
+
// Runs as final gate so needs-response and aging always take precedence.
|
|
167
|
+
if (staleRule?.enabled && ticket.statusChangedAt) {
|
|
168
|
+
const daysInCurrentStatus = (now - new Date(ticket.statusChangedAt)) / (1000 * 60 * 60 * 24);
|
|
169
|
+
if (
|
|
170
|
+
daysInCurrentStatus >= (staleRule.stale_days ?? 14) &&
|
|
171
|
+
Array.isArray(staleRule.statuses) &&
|
|
172
|
+
staleRule.statuses.includes(ticket.status)
|
|
173
|
+
) {
|
|
174
|
+
return {
|
|
175
|
+
ticketKey: ticket.key,
|
|
176
|
+
summary: ticket.summary,
|
|
177
|
+
status: ticket.status,
|
|
178
|
+
urgency: 'stale',
|
|
179
|
+
reason: `Stuck in '${ticket.status}' for ${Math.floor(daysInCurrentStatus)}d`,
|
|
180
|
+
lastComment,
|
|
181
|
+
daysInCurrentStatus: Math.floor(daysInCurrentStatus),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
165
186
|
return {
|
|
166
187
|
ticketKey: ticket.key,
|
|
167
188
|
summary: ticket.summary,
|
|
@@ -172,7 +193,7 @@ export function scoreAttention(ticket, currentUser, opts = {}) {
|
|
|
172
193
|
};
|
|
173
194
|
}
|
|
174
195
|
|
|
175
|
-
const URGENCY_ORDER = { 'needs-response': 0, 'aging': 1, '
|
|
196
|
+
export const URGENCY_ORDER = { 'needs-response': 0, 'aging': 1, 'stale': 2, 'clear': 3 };
|
|
176
197
|
|
|
177
198
|
export function sortByUrgency(scores) {
|
|
178
199
|
return [...scores].sort((a, b) => {
|
|
@@ -6,7 +6,8 @@ import { formatTable } from './table-formatter.mjs';
|
|
|
6
6
|
import { formatSize } from './attachment-downloader.mjs';
|
|
7
7
|
import { timeAgo, truncate, stripCr } from './config.mjs';
|
|
8
8
|
|
|
9
|
-
export function assembleBrief(ticket, codeRefs = null) {
|
|
9
|
+
export function assembleBrief(ticket, codeRefs = null, templateSections = null) {
|
|
10
|
+
const s = templateSections;
|
|
10
11
|
const sections = [];
|
|
11
12
|
sections.push(`# ${ticket.key}: ${ticket.summary}`);
|
|
12
13
|
|
|
@@ -15,19 +16,25 @@ export function assembleBrief(ticket, codeRefs = null) {
|
|
|
15
16
|
if (ticket.updated) meta.push(`**Updated:** ${ticket.updated.split('T')[0]}`);
|
|
16
17
|
sections.push(meta.join(' | '));
|
|
17
18
|
|
|
18
|
-
if (ticket.description) {
|
|
19
|
+
if (ticket.description && (s === null || s.description !== false)) {
|
|
19
20
|
sections.push(`## Description\n\n${stripCr(ticket.description)}`);
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
const commentsEnabled = s === null || s.comments?.enabled !== false;
|
|
24
|
+
const rawMax = s?.comments?.max;
|
|
25
|
+
const commentsMax = (typeof rawMax === 'number' && rawMax >= 0) ? rawMax : Infinity;
|
|
26
|
+
const visibleComments = commentsEnabled && ticket.comments?.length > 0
|
|
27
|
+
? ticket.comments.slice(0, commentsMax === Infinity ? ticket.comments.length : commentsMax)
|
|
28
|
+
: [];
|
|
29
|
+
if (visibleComments.length > 0) {
|
|
30
|
+
const commentLines = visibleComments.map(c => {
|
|
24
31
|
const date = c.created ? c.created.split('T')[0] : 'unknown';
|
|
25
32
|
return `### **${c.author}** (${date})\n\n${c.body.replace(/\r/g, '')}`;
|
|
26
33
|
});
|
|
27
34
|
sections.push(`## Comments\n\n${commentLines.join('\n\n---\n\n')}`);
|
|
28
35
|
}
|
|
29
36
|
|
|
30
|
-
if (ticket.linkedTicketDetails?.length > 0) {
|
|
37
|
+
if (ticket.linkedTicketDetails?.length > 0 && (s === null || s.linked !== false)) {
|
|
31
38
|
const linkedSections = ticket.linkedTicketDetails.map(lt => {
|
|
32
39
|
const parts = [`### ${lt.key}: ${lt.summary}`, `**Type:** ${lt.type} | **Status:** ${lt.status}`];
|
|
33
40
|
if (lt.description) parts.push(stripCr(lt.description));
|
|
@@ -43,7 +50,7 @@ export function assembleBrief(ticket, codeRefs = null) {
|
|
|
43
50
|
sections.push(`## Linked Tickets\n\n${linkedSections.join('\n\n---\n\n')}`);
|
|
44
51
|
}
|
|
45
52
|
|
|
46
|
-
if (ticket.confluencePages?.length > 0) {
|
|
53
|
+
if (ticket.confluencePages?.length > 0 && (s === null || s.confluence !== false)) {
|
|
47
54
|
const pageLines = ticket.confluencePages.map(p => {
|
|
48
55
|
const parts = [`### ${p.title ?? p.url}`];
|
|
49
56
|
if (p.text) parts.push(p.text);
|
|
@@ -52,7 +59,7 @@ export function assembleBrief(ticket, codeRefs = null) {
|
|
|
52
59
|
sections.push(`## Confluence Pages\n\n${pageLines.join('\n\n---\n\n')}`);
|
|
53
60
|
}
|
|
54
61
|
|
|
55
|
-
if (codeRefs) {
|
|
62
|
+
if (codeRefs && (s === null || s.code_refs !== false)) {
|
|
56
63
|
const categories = [
|
|
57
64
|
['File Paths', codeRefs.filePaths],
|
|
58
65
|
['Methods', codeRefs.methods],
|
|
@@ -70,7 +77,7 @@ export function assembleBrief(ticket, codeRefs = null) {
|
|
|
70
77
|
}
|
|
71
78
|
}
|
|
72
79
|
|
|
73
|
-
if (ticket.attachments?.length > 0) {
|
|
80
|
+
if (ticket.attachments?.length > 0 && (s === null || s.attachments !== false)) {
|
|
74
81
|
const lines = ticket.attachments.map(a => {
|
|
75
82
|
const r = (ticket.localAttachments ?? []).find(x => x.filename === a.filename);
|
|
76
83
|
const sz = formatSize(a.size);
|
|
@@ -103,6 +110,7 @@ export function assembleTriageSummary(scoredTickets, opts = {}) {
|
|
|
103
110
|
|
|
104
111
|
const needsResponse = actionable.filter(t => t.urgency === 'needs-response');
|
|
105
112
|
const aging = actionable.filter(t => t.urgency === 'aging');
|
|
113
|
+
const stale = actionable.filter(t => t.urgency === 'stale');
|
|
106
114
|
const allKeys = [];
|
|
107
115
|
|
|
108
116
|
if (needsResponse.length > 0) {
|
|
@@ -129,13 +137,28 @@ export function assembleTriageSummary(scoredTickets, opts = {}) {
|
|
|
129
137
|
return [String(agingOffset + i + 1), t.ticketKey, truncate(t.summary, 50), t.status, `${days}d`];
|
|
130
138
|
});
|
|
131
139
|
const table = formatTable(
|
|
132
|
-
['#', 'Ticket', 'Summary', 'Status', '
|
|
140
|
+
['#', 'Ticket', 'Summary', 'Status', 'Idle'],
|
|
133
141
|
tableRows,
|
|
134
142
|
{ maxWidths: { 2: 50 } },
|
|
135
143
|
);
|
|
136
144
|
sections.push(`Aging — no activity > ${staleDays} days (${aging.length})\n\n${table}`);
|
|
137
145
|
}
|
|
138
146
|
|
|
147
|
+
if (stale.length > 0) {
|
|
148
|
+
const staleOffset = needsResponse.length + aging.length;
|
|
149
|
+
const tableRows = stale.map((t, i) => {
|
|
150
|
+
allKeys.push(t.ticketKey);
|
|
151
|
+
const days = t.daysInCurrentStatus ?? '?';
|
|
152
|
+
return [String(staleOffset + i + 1), t.ticketKey, truncate(t.summary, 50), t.status, `${days}d`];
|
|
153
|
+
});
|
|
154
|
+
const table = formatTable(
|
|
155
|
+
['#', 'Ticket', 'Summary', 'Status', 'Stuck'],
|
|
156
|
+
tableRows,
|
|
157
|
+
{ maxWidths: { 2: 50 } },
|
|
158
|
+
);
|
|
159
|
+
sections.push(`Stale — stuck in same status (${stale.length})\n\n${table}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
139
162
|
if (browseUrl && allKeys.length > 0) {
|
|
140
163
|
const links = allKeys.map((k, i) => `[${i + 1}] ${k}: ${browseUrl}${k}`);
|
|
141
164
|
sections.push(`Quick Links\n\n${links.join('\n')}`);
|
|
@@ -67,7 +67,8 @@ export function printHelp({ stream = process.stdout } = {}) {
|
|
|
67
67
|
` ${s.brand('--summarize')} Generate AI summary ${s.dim('(BYOK or --cloud) [Pro]')}`,
|
|
68
68
|
` ${s.brand('--handoff')} AI handoff brief from comment thread ${s.dim('(BYOK or --cloud) [Pro]')}`,
|
|
69
69
|
` ${s.brand('--cloud')} Route AI request through TicketLens API ${s.dim('[Pro]')}`,
|
|
70
|
-
` ${s.brand('--provider')}=${s.dim('NAME')}
|
|
70
|
+
` ${s.brand('--provider')}=${s.dim('NAME')} Force AI provider ${s.dim('(anthropic|openai|groq)')}`,
|
|
71
|
+
` ${s.brand('--template')}=${s.dim('SLUG')} Apply a brief template ${s.dim('(full|quick|code-review, or custom [Team])')}`,
|
|
71
72
|
'',
|
|
72
73
|
` ${s.bold('TRIAGE OPTIONS')}`,
|
|
73
74
|
'',
|
|
@@ -81,7 +82,7 @@ export function printHelp({ stream = process.stdout } = {}) {
|
|
|
81
82
|
` ${s.brand('--project')}=${s.dim('PROJ')} Filter by Jira project key ${s.dim('[Team]')}`,
|
|
82
83
|
` ${s.brand('--label')}=${s.dim('X,Y')} Filter by label(s) ${s.dim('[Team]')}`,
|
|
83
84
|
` ${s.brand('--priority')}=${s.dim('LEVEL')} Filter by priority ${s.dim('(e.g. High, Blocker) [Team]')}`,
|
|
84
|
-
` ${s.brand('--export')}=${s.dim('FORMAT')}
|
|
85
|
+
` ${s.brand('--export')}=${s.dim('FORMAT')} Export results to file ${s.dim('(csv|json) [Team]')}`,
|
|
85
86
|
` ${s.brand('--push')} Push snapshot to Console queue ${s.dim('[Team]')}`,
|
|
86
87
|
` ${s.brand('--share')} Generate a 24h share URL ${s.dim('(no login required) [Team]')}`,
|
|
87
88
|
` ${s.brand('--all')} Triage all configured profiles at once ${s.dim('[Pro]')}`,
|
|
@@ -149,7 +150,8 @@ export function printFetchHelp({ stream = process.stdout } = {}) {
|
|
|
149
150
|
` ${s.brand('--summarize')} Generate AI summary ${s.dim('(BYOK or --cloud) [Pro]')}`,
|
|
150
151
|
` ${s.brand('--handoff')} AI handoff brief from comment thread ${s.dim('(BYOK or --cloud) [Pro]')}`,
|
|
151
152
|
` ${s.brand('--cloud')} Route AI request through TicketLens API ${s.dim('[Pro]')}`,
|
|
152
|
-
` ${s.brand('--provider')}=${s.dim('NAME')}
|
|
153
|
+
` ${s.brand('--provider')}=${s.dim('NAME')} Force AI provider ${s.dim('(anthropic|openai|groq)')}`,
|
|
154
|
+
` ${s.brand('--template')}=${s.dim('SLUG')} Apply a brief template ${s.dim('(full|quick|code-review, or custom [Team])')}`,
|
|
153
155
|
` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
|
|
154
156
|
'',
|
|
155
157
|
` ${s.bold('EXAMPLES')}`,
|
|
@@ -12,19 +12,44 @@ function toText(value) {
|
|
|
12
12
|
return adfToText(value);
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Returns the ISO date string when the ticket entered its current status,
|
|
17
|
+
* derived from Jira's changelog history. Walks backwards (most recent first)
|
|
18
|
+
* to find the last transition whose `toString` matches `currentStatus`.
|
|
19
|
+
* Returns null when changelog is absent or has no matching transition.
|
|
20
|
+
*/
|
|
21
|
+
export function parseStatusChangedAt(changelog, currentStatus) {
|
|
22
|
+
if (!changelog?.histories?.length || !currentStatus) return null;
|
|
23
|
+
for (let i = changelog.histories.length - 1; i >= 0; i--) {
|
|
24
|
+
const history = changelog.histories[i];
|
|
25
|
+
const item = history.items?.find(it => it.field === 'status' && it.toString === currentStatus);
|
|
26
|
+
if (item) return history.created ?? null;
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
15
31
|
export function normalizeTicket(raw) {
|
|
16
32
|
const f = raw.fields;
|
|
33
|
+
const currentStatus = f.status?.name ?? null;
|
|
34
|
+
// statusChangedAt: when the ticket entered its current status.
|
|
35
|
+
// Only populated when raw.changelog is present (i.e. ?expand=changelog was requested).
|
|
36
|
+
// Falls back to ticket.created when changelog is present but no matching transition exists
|
|
37
|
+
// (ticket was created in its current status and never transitioned away then back).
|
|
38
|
+
const statusChangedAt = raw.changelog !== undefined
|
|
39
|
+
? (parseStatusChangedAt(raw.changelog, currentStatus) ?? (f.created ?? null))
|
|
40
|
+
: null;
|
|
17
41
|
return {
|
|
18
42
|
key: raw.key,
|
|
19
43
|
summary: f.summary,
|
|
20
44
|
type: f.issuetype?.name ?? null,
|
|
21
|
-
status:
|
|
45
|
+
status: currentStatus,
|
|
22
46
|
priority: f.priority?.name ?? null,
|
|
23
47
|
assignee: f.assignee?.displayName ?? null,
|
|
24
48
|
reporter: f.reporter?.displayName ?? null,
|
|
25
49
|
description: toText(f.description),
|
|
26
50
|
created: f.created ?? null,
|
|
27
51
|
updated: f.updated ?? null,
|
|
52
|
+
statusChangedAt,
|
|
28
53
|
labels: f.labels ?? [],
|
|
29
54
|
components: (f.components ?? []).map(c => c.name),
|
|
30
55
|
comments: (f.comment?.comments ?? []).map(c => ({
|
|
@@ -110,12 +135,13 @@ export async function fetchStatuses(opts = {}) {
|
|
|
110
135
|
}
|
|
111
136
|
|
|
112
137
|
export async function searchTickets(jql, opts = {}) {
|
|
113
|
-
const { env = process.env, fetcher = globalThis.fetch, maxResults = 50, apiVersion = 2, timeoutMs = 10_000 } = opts;
|
|
138
|
+
const { env = process.env, fetcher = globalThis.fetch, maxResults = 50, apiVersion = 2, timeoutMs = 10_000, expandChangelog = false } = opts;
|
|
114
139
|
const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
|
|
115
140
|
const headers = { ...buildAuthHeader(env), 'Content-Type': 'application/json' };
|
|
116
141
|
|
|
117
|
-
const fields = 'summary,status,assignee,priority,issuetype,comment,updated,statuscategorychangedate';
|
|
142
|
+
const fields = 'summary,status,assignee,priority,issuetype,comment,updated,statuscategorychangedate,created';
|
|
118
143
|
const params = new URLSearchParams({ jql, fields, maxResults: String(maxResults) });
|
|
144
|
+
if (expandChangelog) params.set('expand', 'changelog');
|
|
119
145
|
const endpoint = apiVersion >= 3 ? `/rest/api/3/search/jql` : `/rest/api/2/search`;
|
|
120
146
|
const url = `${baseUrl}${endpoint}?${params}`;
|
|
121
147
|
const fetchOpts = { headers };
|
|
@@ -153,11 +179,12 @@ export async function fetchRemoteLinks(ticketKey, opts = {}) {
|
|
|
153
179
|
}
|
|
154
180
|
|
|
155
181
|
export async function fetchTicket(ticketKey, opts = {}) {
|
|
156
|
-
const { env = process.env, fetcher = globalThis.fetch, depth = 1, apiVersion = 2, timeoutMs = 10_000, _visited = new Set(), _currentDepth = 0 } = opts;
|
|
182
|
+
const { env = process.env, fetcher = globalThis.fetch, depth = 1, apiVersion = 2, timeoutMs = 10_000, expandChangelog = false, _visited = new Set(), _currentDepth = 0 } = opts;
|
|
157
183
|
const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
|
|
158
184
|
const headers = { ...buildAuthHeader(env), 'Content-Type': 'application/json' };
|
|
159
185
|
|
|
160
|
-
const
|
|
186
|
+
const expand = expandChangelog ? '?expand=changelog' : '';
|
|
187
|
+
const url = `${baseUrl}/rest/api/${apiVersion}/issue/${encodeURIComponent(ticketKey)}${expand}`;
|
|
161
188
|
const fetchOpts = { headers };
|
|
162
189
|
if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
|
|
163
190
|
const response = await fetcher(url, fetchOpts);
|
|
@@ -184,7 +211,8 @@ export async function fetchTicket(ticketKey, opts = {}) {
|
|
|
184
211
|
linkedKeys.forEach(k => _visited.add(k));
|
|
185
212
|
|
|
186
213
|
ticket.linkedTicketDetails = await Promise.all(
|
|
187
|
-
|
|
214
|
+
// Linked tickets are context only — never scored, so skip changelog expansion for them.
|
|
215
|
+
linkedKeys.map(k => fetchTicket(k, { ...opts, expandChangelog: false, _visited, _currentDepth: _currentDepth + 1 }))
|
|
188
216
|
);
|
|
189
217
|
}
|
|
190
218
|
|
|
@@ -232,6 +232,7 @@ export function resolveConnection(ticketKey, opts = {}) {
|
|
|
232
232
|
triageStatuses: profile.triageStatuses || null,
|
|
233
233
|
ticketPrefixes: profile.ticketPrefixes || null,
|
|
234
234
|
attentionRules: profile.attentionRules ?? null,
|
|
235
|
+
staleRule: profile.staleRule ?? null,
|
|
235
236
|
source: 'profile',
|
|
236
237
|
profileName: profile.name,
|
|
237
238
|
};
|
|
@@ -28,10 +28,12 @@ export function styleTriageSummary(scoredTickets, opts = {}) {
|
|
|
28
28
|
|
|
29
29
|
const needsResponse = actionable.filter(t => t.urgency === 'needs-response');
|
|
30
30
|
const aging = actionable.filter(t => t.urgency === 'aging');
|
|
31
|
+
const stale = actionable.filter(t => t.urgency === 'stale');
|
|
31
32
|
|
|
32
33
|
const parts = [];
|
|
33
34
|
if (needsResponse.length > 0) parts.push(`${needsResponse.length} need response`);
|
|
34
35
|
if (aging.length > 0) parts.push(`${aging.length} aging`);
|
|
36
|
+
if (stale.length > 0) parts.push(`${stale.length} stale`);
|
|
35
37
|
|
|
36
38
|
const sections = [];
|
|
37
39
|
sections.push(s.bold(`${actionable.length} tickets need attention`) + ' ' + s.dim(`(${parts.join(', ')})`) );
|
|
@@ -40,6 +42,7 @@ export function styleTriageSummary(scoredTickets, opts = {}) {
|
|
|
40
42
|
const legendParts = [];
|
|
41
43
|
if (needsResponse.length > 0) legendParts.push(`${s.red('●')} needs response`);
|
|
42
44
|
if (aging.length > 0) legendParts.push(`${s.yellow('●')} aging`);
|
|
45
|
+
if (stale.length > 0) legendParts.push(`${s.cyan('●')} stale`);
|
|
43
46
|
let legend = legendParts.join(' ');
|
|
44
47
|
if (browseUrl) legend += `\n${s.dim('Open:')} ${browseUrl}${s.dim('<key>')}`;
|
|
45
48
|
sections.push(legend);
|
|
@@ -70,7 +73,21 @@ export function styleTriageSummary(scoredTickets, opts = {}) {
|
|
|
70
73
|
return [String(agingOffset + i + 1), ticketCell(t.ticketKey, s.yellow), truncate(t.summary, 45), t.status, `${days}d`];
|
|
71
74
|
});
|
|
72
75
|
const table = formatTable(
|
|
73
|
-
['#', 'Ticket', 'Title', 'Status', '
|
|
76
|
+
['#', 'Ticket', 'Title', 'Status', 'Idle'],
|
|
77
|
+
tableRows,
|
|
78
|
+
{ maxWidths: { 2: 45 } },
|
|
79
|
+
);
|
|
80
|
+
sections.push(table);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (stale.length > 0) {
|
|
84
|
+
const staleOffset = needsResponse.length + aging.length;
|
|
85
|
+
const tableRows = stale.map((t, i) => {
|
|
86
|
+
const days = t.daysInCurrentStatus ?? '?';
|
|
87
|
+
return [String(staleOffset + i + 1), ticketCell(t.ticketKey, s.cyan), truncate(t.summary, 45), t.status, `${days}d`];
|
|
88
|
+
});
|
|
89
|
+
const table = formatTable(
|
|
90
|
+
['#', 'Ticket', 'Title', 'Status', 'Stuck'],
|
|
74
91
|
tableRows,
|
|
75
92
|
{ maxWidths: { 2: 45 } },
|
|
76
93
|
);
|
|
@@ -81,7 +98,8 @@ export function styleTriageSummary(scoredTickets, opts = {}) {
|
|
|
81
98
|
}
|
|
82
99
|
|
|
83
100
|
export function styleBrief(ticket, codeRefs = null, opts = {}) {
|
|
84
|
-
const { styled = true } = opts;
|
|
101
|
+
const { styled = true, templateSections = null } = opts;
|
|
102
|
+
const ts = templateSections;
|
|
85
103
|
const s = createStyler({ forceColor: styled, noColor: !styled });
|
|
86
104
|
|
|
87
105
|
const sections = [];
|
|
@@ -89,7 +107,7 @@ export function styleBrief(ticket, codeRefs = null, opts = {}) {
|
|
|
89
107
|
// Header: ticket key + summary
|
|
90
108
|
sections.push(s.bold(s.brand(`${ticket.key}: ${ticket.summary}`)));
|
|
91
109
|
|
|
92
|
-
// Metadata line
|
|
110
|
+
// Metadata line (always included)
|
|
93
111
|
const meta = [
|
|
94
112
|
`${s.dim('Type:')} ${ticket.type}`,
|
|
95
113
|
`${s.dim('Status:')} ${statusColor(s, ticket.status)}`,
|
|
@@ -101,13 +119,19 @@ export function styleBrief(ticket, codeRefs = null, opts = {}) {
|
|
|
101
119
|
sections.push(meta.join(s.dim(' · ')));
|
|
102
120
|
|
|
103
121
|
// Description
|
|
104
|
-
if (ticket.description) {
|
|
122
|
+
if (ticket.description && (ts === null || ts.description !== false)) {
|
|
105
123
|
sections.push(`${s.bold(s.brand('Description'))}\n${s.dim('─'.repeat(divWidth()))}\n${stripCr(ticket.description)}`);
|
|
106
124
|
}
|
|
107
125
|
|
|
108
126
|
// Comments
|
|
109
|
-
|
|
110
|
-
|
|
127
|
+
const commentsEnabled = ts === null || ts.comments?.enabled !== false;
|
|
128
|
+
const rawMax = ts?.comments?.max;
|
|
129
|
+
const commentsMax = (typeof rawMax === 'number' && rawMax >= 0) ? rawMax : Infinity;
|
|
130
|
+
const visibleComments = commentsEnabled && ticket.comments?.length > 0
|
|
131
|
+
? ticket.comments.slice(0, commentsMax === Infinity ? ticket.comments.length : commentsMax)
|
|
132
|
+
: [];
|
|
133
|
+
if (visibleComments.length > 0) {
|
|
134
|
+
const commentLines = visibleComments.map(c => {
|
|
111
135
|
const date = c.created ? c.created.split('T')[0] : 'unknown';
|
|
112
136
|
return `${s.brand(c.author)} ${s.dim(`(${date})`)}\n${stripCr(c.body)}`;
|
|
113
137
|
});
|
|
@@ -115,7 +139,7 @@ export function styleBrief(ticket, codeRefs = null, opts = {}) {
|
|
|
115
139
|
}
|
|
116
140
|
|
|
117
141
|
// Linked tickets
|
|
118
|
-
if (ticket.linkedTicketDetails?.length > 0) {
|
|
142
|
+
if (ticket.linkedTicketDetails?.length > 0 && (ts === null || ts.linked !== false)) {
|
|
119
143
|
const linkedSections = ticket.linkedTicketDetails.map(lt => {
|
|
120
144
|
const parts = [`${s.brand(lt.key)}: ${lt.summary}`, `${s.dim('Type:')} ${lt.type} | ${s.dim('Status:')} ${statusColor(s, lt.status)}`];
|
|
121
145
|
if (lt.description) parts.push(stripCr(lt.description));
|
|
@@ -132,7 +156,7 @@ export function styleBrief(ticket, codeRefs = null, opts = {}) {
|
|
|
132
156
|
}
|
|
133
157
|
|
|
134
158
|
// Confluence pages
|
|
135
|
-
if (ticket.confluencePages?.length > 0) {
|
|
159
|
+
if (ticket.confluencePages?.length > 0 && (ts === null || ts.confluence !== false)) {
|
|
136
160
|
const pageLines = ticket.confluencePages.map(p => {
|
|
137
161
|
const parts = [s.brand(p.title ?? p.url)];
|
|
138
162
|
if (p.text) parts.push(p.text);
|
|
@@ -142,7 +166,7 @@ export function styleBrief(ticket, codeRefs = null, opts = {}) {
|
|
|
142
166
|
}
|
|
143
167
|
|
|
144
168
|
// Code references
|
|
145
|
-
if (codeRefs) {
|
|
169
|
+
if (codeRefs && (ts === null || ts.code_refs !== false)) {
|
|
146
170
|
const categories = [
|
|
147
171
|
['File Paths', codeRefs.filePaths],
|
|
148
172
|
['Methods', codeRefs.methods],
|
|
@@ -160,7 +184,7 @@ export function styleBrief(ticket, codeRefs = null, opts = {}) {
|
|
|
160
184
|
}
|
|
161
185
|
}
|
|
162
186
|
|
|
163
|
-
if (ticket.attachments?.length > 0) {
|
|
187
|
+
if (ticket.attachments?.length > 0 && (ts === null || ts.attachments !== false)) {
|
|
164
188
|
const lines = ticket.attachments.map(a => {
|
|
165
189
|
const r = (ticket.localAttachments ?? []).find(x => x.filename === a.filename);
|
|
166
190
|
const sz = formatSize(a.size);
|
|
@@ -33,6 +33,8 @@ export function serverToCliProfile(serverProfile) {
|
|
|
33
33
|
...(p.ticket_prefixes?.length ? { ticketPrefixes: p.ticket_prefixes } : {}),
|
|
34
34
|
...(p.project_paths?.length ? { projectPaths: p.project_paths } : {}),
|
|
35
35
|
...(p.triage_statuses?.length ? { triageStatuses: p.triage_statuses } : {}),
|
|
36
|
+
...(p.stale_rule ? { staleRule: p.stale_rule } : {}),
|
|
37
|
+
...(p.known_statuses?.length ? { knownStatuses: p.known_statuses, statusesCachedAt: p.statuses_cached_at ?? null } : {}),
|
|
36
38
|
};
|
|
37
39
|
return { name: p.name, profileData };
|
|
38
40
|
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolves a brief template by slug.
|
|
3
|
+
* System templates (full / quick / code-review) are hardcoded and work offline.
|
|
4
|
+
* Custom team templates require a CLI token and are fetched from /v1/templates.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { getApiBase } from './sync.mjs';
|
|
8
|
+
|
|
9
|
+
export const SYSTEM_TEMPLATES = [
|
|
10
|
+
{
|
|
11
|
+
slug: 'full',
|
|
12
|
+
name: 'Full Brief',
|
|
13
|
+
is_system: true,
|
|
14
|
+
sections: {
|
|
15
|
+
meta: true,
|
|
16
|
+
description: true,
|
|
17
|
+
comments: { enabled: true, max: 10 },
|
|
18
|
+
linked: true,
|
|
19
|
+
code_refs: true,
|
|
20
|
+
confluence: true,
|
|
21
|
+
attachments: true,
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
slug: 'quick',
|
|
26
|
+
name: 'Quick Scan',
|
|
27
|
+
is_system: true,
|
|
28
|
+
sections: {
|
|
29
|
+
meta: true,
|
|
30
|
+
description: false,
|
|
31
|
+
comments: { enabled: true, max: 2 },
|
|
32
|
+
linked: false,
|
|
33
|
+
code_refs: false,
|
|
34
|
+
confluence: false,
|
|
35
|
+
attachments: false,
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
slug: 'code-review',
|
|
40
|
+
name: 'Code Review',
|
|
41
|
+
is_system: true,
|
|
42
|
+
sections: {
|
|
43
|
+
meta: true,
|
|
44
|
+
description: true,
|
|
45
|
+
comments: { enabled: false, max: 0 },
|
|
46
|
+
linked: true,
|
|
47
|
+
code_refs: true,
|
|
48
|
+
confluence: false,
|
|
49
|
+
attachments: false,
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
const SYSTEM_SLUGS = new Set(SYSTEM_TEMPLATES.map(t => t.slug));
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Resolve a template by slug.
|
|
58
|
+
*
|
|
59
|
+
* @param {string} slug
|
|
60
|
+
* @param {object} opts
|
|
61
|
+
* @param {string|null} opts.token CLI token (null = offline)
|
|
62
|
+
* @param {Function} [opts.fetcher] globalThis.fetch override (for testing)
|
|
63
|
+
* @returns {Promise<object>} template with { slug, sections, is_system, ... }
|
|
64
|
+
* @throws {Error} if slug is unknown and cannot be resolved
|
|
65
|
+
*/
|
|
66
|
+
export async function resolveTemplate(slug, { token, fetcher = globalThis.fetch } = {}) {
|
|
67
|
+
if (!slug || !/^[a-z0-9_-]{1,64}$/.test(slug)) {
|
|
68
|
+
throw new Error(`Invalid template slug "${slug}". Use lowercase letters, numbers, hyphens, or underscores (max 64 chars).`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const systemMatch = SYSTEM_TEMPLATES.find(t => t.slug === slug);
|
|
72
|
+
|
|
73
|
+
if (!token) {
|
|
74
|
+
if (systemMatch) {
|
|
75
|
+
process.stderr.write(
|
|
76
|
+
` ○ Template "${slug}" resolved from local system definitions (no login required).\n`,
|
|
77
|
+
);
|
|
78
|
+
return systemMatch;
|
|
79
|
+
}
|
|
80
|
+
throw new Error(
|
|
81
|
+
`Template "${slug}" not found. Custom templates require \`ticketlens login\`.`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Try the API first.
|
|
86
|
+
let apiTemplates = null;
|
|
87
|
+
try {
|
|
88
|
+
const res = await fetcher(`${getApiBase()}/v1/templates`, {
|
|
89
|
+
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
|
90
|
+
signal: AbortSignal.timeout(10_000),
|
|
91
|
+
});
|
|
92
|
+
if (res.ok) {
|
|
93
|
+
apiTemplates = await res.json();
|
|
94
|
+
} else {
|
|
95
|
+
process.stderr.write(` ⚠ Could not fetch templates (HTTP ${res.status}) — using local system definitions.\n`);
|
|
96
|
+
}
|
|
97
|
+
} catch {
|
|
98
|
+
process.stderr.write(` ⚠ Could not reach templates API — using local system definitions.\n`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (apiTemplates) {
|
|
102
|
+
if (!Array.isArray(apiTemplates)) {
|
|
103
|
+
process.stderr.write(` ⚠ Unexpected templates API response format — using local system definitions.\n`);
|
|
104
|
+
} else {
|
|
105
|
+
const found = apiTemplates.find(t => t.slug === slug);
|
|
106
|
+
if (found) return found;
|
|
107
|
+
const available = apiTemplates.map(t => t.slug).join(', ');
|
|
108
|
+
throw new Error(
|
|
109
|
+
`Template "${slug}" not found. Available templates: ${available}.\n` +
|
|
110
|
+
` To create custom templates, go to Admin → Brief Templates in your TicketLens Console.`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// API unreachable — fall back to system templates
|
|
116
|
+
if (systemMatch) {
|
|
117
|
+
process.stderr.write(` ⚠ Using local system template "${slug}" (API unavailable).\n`);
|
|
118
|
+
return systemMatch;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
throw new Error(
|
|
122
|
+
`Template "${slug}" not found and API is unreachable. Custom templates require \`ticketlens login\`.`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
@@ -10,7 +10,7 @@ import { join } from 'node:path';
|
|
|
10
10
|
|
|
11
11
|
export const DEFAULT_CONFIG_DIR = join(homedir(), '.ticketlens');
|
|
12
12
|
|
|
13
|
-
const URGENCY_ORDER = { 'needs-response': 0, 'aging': 1, '
|
|
13
|
+
const URGENCY_ORDER = { 'needs-response': 0, 'aging': 1, 'stale': 2, 'clear': 3 };
|
|
14
14
|
|
|
15
15
|
// ---------------------------------------------------------------------------
|
|
16
16
|
// Internal helpers
|
|
@@ -126,8 +126,8 @@ export function diffSnapshots(today, yesterday) {
|
|
|
126
126
|
const changes = [];
|
|
127
127
|
|
|
128
128
|
// Urgency worsened
|
|
129
|
-
const todayOrder = URGENCY_ORDER[t.urgency] ??
|
|
130
|
-
const yesterdayOrder = URGENCY_ORDER[y.urgency] ??
|
|
129
|
+
const todayOrder = URGENCY_ORDER[t.urgency] ?? 3;
|
|
130
|
+
const yesterdayOrder = URGENCY_ORDER[y.urgency] ?? 3;
|
|
131
131
|
if (todayOrder < yesterdayOrder) {
|
|
132
132
|
changes.push(`${y.urgency} \u2192 ${t.urgency}`);
|
|
133
133
|
}
|
|
@@ -284,7 +284,7 @@ function loadSnapshotForDate(dateStr, profile, configDir, fsModule) {
|
|
|
284
284
|
* medianResponseHours: number|null,
|
|
285
285
|
* clearRate: number|null,
|
|
286
286
|
* triageRunCount: number,
|
|
287
|
-
* currentUrgency: { needsResponse: number, aging: number, clear: number }|null,
|
|
287
|
+
* currentUrgency: { needsResponse: number, aging: number, stale: number, clear: number }|null,
|
|
288
288
|
* windowDays: number,
|
|
289
289
|
* trendHours: number|null,
|
|
290
290
|
* }}
|
|
@@ -354,6 +354,7 @@ export function computeResponseMetrics(profile, {
|
|
|
354
354
|
? {
|
|
355
355
|
needsResponse: latest.tickets.filter(t => t.urgency === 'needs-response').length,
|
|
356
356
|
aging: latest.tickets.filter(t => t.urgency === 'aging').length,
|
|
357
|
+
stale: latest.tickets.filter(t => t.urgency === 'stale').length,
|
|
357
358
|
clear: latest.tickets.filter(t => t.urgency === 'clear').length,
|
|
358
359
|
}
|
|
359
360
|
: null;
|