ticketlens 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
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.0 -->
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.
@@ -72,6 +72,12 @@ node ~/.agents/skills/jtb/scripts/fetch-my-tickets.mjs $EXTRA_ARGS
72
72
 
73
73
  Where `$EXTRA_ARGS` are any flags passed (e.g. `--stale=3 --status=QA --profile=acme`).
74
74
 
75
+ **Urgency levels** (highest → lowest priority):
76
+ - 🔴 `needs-response` — someone commented and you haven't replied
77
+ - 🟡 `aging` — no activity for ≥ `staleDays` (default 5d)
78
+ - 🔵 `stale` — ticket stuck in the same Jira status for ≥ N days (Pro — requires a stale rule configured in the Console)
79
+ - 🟢 `clear` — up to date, no action needed
80
+
75
81
  **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
82
 
77
83
  ---
@@ -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]));
@@ -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) => {
@@ -103,6 +103,7 @@ export function assembleTriageSummary(scoredTickets, opts = {}) {
103
103
 
104
104
  const needsResponse = actionable.filter(t => t.urgency === 'needs-response');
105
105
  const aging = actionable.filter(t => t.urgency === 'aging');
106
+ const stale = actionable.filter(t => t.urgency === 'stale');
106
107
  const allKeys = [];
107
108
 
108
109
  if (needsResponse.length > 0) {
@@ -129,13 +130,28 @@ export function assembleTriageSummary(scoredTickets, opts = {}) {
129
130
  return [String(agingOffset + i + 1), t.ticketKey, truncate(t.summary, 50), t.status, `${days}d`];
130
131
  });
131
132
  const table = formatTable(
132
- ['#', 'Ticket', 'Summary', 'Status', 'Stale'],
133
+ ['#', 'Ticket', 'Summary', 'Status', 'Idle'],
133
134
  tableRows,
134
135
  { maxWidths: { 2: 50 } },
135
136
  );
136
137
  sections.push(`Aging — no activity > ${staleDays} days (${aging.length})\n\n${table}`);
137
138
  }
138
139
 
140
+ if (stale.length > 0) {
141
+ const staleOffset = needsResponse.length + aging.length;
142
+ const tableRows = stale.map((t, i) => {
143
+ allKeys.push(t.ticketKey);
144
+ const days = t.daysInCurrentStatus ?? '?';
145
+ return [String(staleOffset + i + 1), t.ticketKey, truncate(t.summary, 50), t.status, `${days}d`];
146
+ });
147
+ const table = formatTable(
148
+ ['#', 'Ticket', 'Summary', 'Status', 'Stuck'],
149
+ tableRows,
150
+ { maxWidths: { 2: 50 } },
151
+ );
152
+ sections.push(`Stale — stuck in same status (${stale.length})\n\n${table}`);
153
+ }
154
+
139
155
  if (browseUrl && allKeys.length > 0) {
140
156
  const links = allKeys.map((k, i) => `[${i + 1}] ${k}: ${browseUrl}${k}`);
141
157
  sections.push(`Quick Links\n\n${links.join('\n')}`);
@@ -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
  );
@@ -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
  }
@@ -11,6 +11,9 @@ export function buildTicketPayload(scored, rawMap, baseUrl) {
11
11
  assignee: raw?.assignee ?? null,
12
12
  attention_score: null,
13
13
  flags: scored.urgency === 'clear' ? [] : [scored.urgency],
14
+ // last_comment_at feeds server-side response-time metrics (F19c).
15
+ // null for custom-rule overrides (urgency forced without a real comment).
16
+ last_comment_at: scored.lastComment?.created ?? null,
14
17
  compliance_coverage: null,
15
18
  compliance_status: 'unknown',
16
19
  url: baseUrl ? `${baseUrl}/browse/${scored.ticketKey}` : null,
@@ -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;