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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.8.1",
3
+ "version": "0.9.2",
4
4
  "description": "Jira CLI for developers — fetch ticket context, triage your queue, and stop tab-switching. Zero dependencies, all local.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,4 +1,4 @@
1
- <!-- jtb-skill-version: 0.8.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 = 'http://ticketlens.test';
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, 'clear': 2 };
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
- if (ticket.comments?.length > 0) {
23
- const commentLines = ticket.comments.map(c => {
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', 'Stale'],
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')} Force AI provider ${s.dim('(anthropic|openai|groq)')}`,
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')} Export results to file ${s.dim('(csv|json) [Team]')}`,
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')} Force AI provider ${s.dim('(anthropic|openai|groq)')}`,
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: f.status?.name ?? null,
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 url = `${baseUrl}/rest/api/${apiVersion}/issue/${encodeURIComponent(ticketKey)}`;
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
- linkedKeys.map(k => fetchTicket(k, { ...opts, _visited, _currentDepth: _currentDepth + 1 }))
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', 'Stale'],
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
- if (ticket.comments?.length > 0) {
110
- const commentLines = ticket.comments.map(c => {
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, 'clear': 2 };
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] ?? 2;
130
- const yesterdayOrder = URGENCY_ORDER[y.urgency] ?? 2;
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;