ticketlens 0.9.0 → 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 +4 -1
- package/skills/jtb/scripts/fetch-ticket.mjs +22 -5
- package/skills/jtb/scripts/lib/api-utils.mjs +1 -1
- package/skills/jtb/scripts/lib/brief-assembler.mjs +15 -8
- package/skills/jtb/scripts/lib/help.mjs +5 -3
- package/skills/jtb/scripts/lib/styled-assembler.mjs +16 -9
- package/skills/jtb/scripts/lib/template-resolver.mjs +124 -0
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.9.
|
|
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
|
|
@@ -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.
|
|
@@ -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);
|
|
@@ -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')}`,
|
|
@@ -98,7 +98,8 @@ export function styleTriageSummary(scoredTickets, opts = {}) {
|
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
export function styleBrief(ticket, codeRefs = null, opts = {}) {
|
|
101
|
-
const { styled = true } = opts;
|
|
101
|
+
const { styled = true, templateSections = null } = opts;
|
|
102
|
+
const ts = templateSections;
|
|
102
103
|
const s = createStyler({ forceColor: styled, noColor: !styled });
|
|
103
104
|
|
|
104
105
|
const sections = [];
|
|
@@ -106,7 +107,7 @@ export function styleBrief(ticket, codeRefs = null, opts = {}) {
|
|
|
106
107
|
// Header: ticket key + summary
|
|
107
108
|
sections.push(s.bold(s.brand(`${ticket.key}: ${ticket.summary}`)));
|
|
108
109
|
|
|
109
|
-
// Metadata line
|
|
110
|
+
// Metadata line (always included)
|
|
110
111
|
const meta = [
|
|
111
112
|
`${s.dim('Type:')} ${ticket.type}`,
|
|
112
113
|
`${s.dim('Status:')} ${statusColor(s, ticket.status)}`,
|
|
@@ -118,13 +119,19 @@ export function styleBrief(ticket, codeRefs = null, opts = {}) {
|
|
|
118
119
|
sections.push(meta.join(s.dim(' · ')));
|
|
119
120
|
|
|
120
121
|
// Description
|
|
121
|
-
if (ticket.description) {
|
|
122
|
+
if (ticket.description && (ts === null || ts.description !== false)) {
|
|
122
123
|
sections.push(`${s.bold(s.brand('Description'))}\n${s.dim('─'.repeat(divWidth()))}\n${stripCr(ticket.description)}`);
|
|
123
124
|
}
|
|
124
125
|
|
|
125
126
|
// Comments
|
|
126
|
-
|
|
127
|
-
|
|
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 => {
|
|
128
135
|
const date = c.created ? c.created.split('T')[0] : 'unknown';
|
|
129
136
|
return `${s.brand(c.author)} ${s.dim(`(${date})`)}\n${stripCr(c.body)}`;
|
|
130
137
|
});
|
|
@@ -132,7 +139,7 @@ export function styleBrief(ticket, codeRefs = null, opts = {}) {
|
|
|
132
139
|
}
|
|
133
140
|
|
|
134
141
|
// Linked tickets
|
|
135
|
-
if (ticket.linkedTicketDetails?.length > 0) {
|
|
142
|
+
if (ticket.linkedTicketDetails?.length > 0 && (ts === null || ts.linked !== false)) {
|
|
136
143
|
const linkedSections = ticket.linkedTicketDetails.map(lt => {
|
|
137
144
|
const parts = [`${s.brand(lt.key)}: ${lt.summary}`, `${s.dim('Type:')} ${lt.type} | ${s.dim('Status:')} ${statusColor(s, lt.status)}`];
|
|
138
145
|
if (lt.description) parts.push(stripCr(lt.description));
|
|
@@ -149,7 +156,7 @@ export function styleBrief(ticket, codeRefs = null, opts = {}) {
|
|
|
149
156
|
}
|
|
150
157
|
|
|
151
158
|
// Confluence pages
|
|
152
|
-
if (ticket.confluencePages?.length > 0) {
|
|
159
|
+
if (ticket.confluencePages?.length > 0 && (ts === null || ts.confluence !== false)) {
|
|
153
160
|
const pageLines = ticket.confluencePages.map(p => {
|
|
154
161
|
const parts = [s.brand(p.title ?? p.url)];
|
|
155
162
|
if (p.text) parts.push(p.text);
|
|
@@ -159,7 +166,7 @@ export function styleBrief(ticket, codeRefs = null, opts = {}) {
|
|
|
159
166
|
}
|
|
160
167
|
|
|
161
168
|
// Code references
|
|
162
|
-
if (codeRefs) {
|
|
169
|
+
if (codeRefs && (ts === null || ts.code_refs !== false)) {
|
|
163
170
|
const categories = [
|
|
164
171
|
['File Paths', codeRefs.filePaths],
|
|
165
172
|
['Methods', codeRefs.methods],
|
|
@@ -177,7 +184,7 @@ export function styleBrief(ticket, codeRefs = null, opts = {}) {
|
|
|
177
184
|
}
|
|
178
185
|
}
|
|
179
186
|
|
|
180
|
-
if (ticket.attachments?.length > 0) {
|
|
187
|
+
if (ticket.attachments?.length > 0 && (ts === null || ts.attachments !== false)) {
|
|
181
188
|
const lines = ticket.attachments.map(a => {
|
|
182
189
|
const r = (ticket.localAttachments ?? []).find(x => x.filename === a.filename);
|
|
183
190
|
const sz = formatSize(a.size);
|
|
@@ -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
|
+
}
|