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 +1 -1
- package/skills/jtb/SKILL.md +7 -1
- package/skills/jtb/scripts/fetch-my-tickets.mjs +7 -2
- package/skills/jtb/scripts/lib/attention-scorer.mjs +23 -2
- package/skills/jtb/scripts/lib/brief-assembler.mjs +17 -1
- 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 +18 -1
- package/skills/jtb/scripts/lib/sync.mjs +2 -0
- package/skills/jtb/scripts/lib/ticket-payload.mjs +3 -0
- package/skills/jtb/scripts/lib/triage-history.mjs +5 -4
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.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, '
|
|
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', '
|
|
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:
|
|
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
|
);
|
|
@@ -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, '
|
|
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;
|