ticketlens 0.20.2 → 0.21.1
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 +2 -1
- package/skills/jtb/scripts/fetch-my-tickets.mjs +5 -2
- package/skills/jtb/scripts/lib/attention-scorer.mjs +17 -1
- package/skills/jtb/scripts/lib/cli-auth.mjs +26 -2
- package/skills/jtb/scripts/lib/help.mjs +4 -0
- package/skills/jtb/scripts/lib/license.mjs +10 -0
- package/skills/jtb/scripts/lib/login-flow.mjs +6 -1
- package/skills/jtb/scripts/lib/profile-resolver.mjs +1 -0
- package/skills/jtb/scripts/lib/sync.mjs +3 -1
package/README.md
CHANGED
|
@@ -156,6 +156,7 @@ Three system templates ship out of the box:
|
|
|
156
156
|
ticketlens triage # Scan assigned tickets — interactive
|
|
157
157
|
ticketlens triage --profile=acme # Explicit profile
|
|
158
158
|
ticketlens triage --stale=3 # Aging threshold: 3 days (default: 5)
|
|
159
|
+
ticketlens triage --sort=priority # Sort by priority first, then urgency (default: urgency)
|
|
159
160
|
ticketlens triage --status="Code Review,QA" # Override statuses to scan
|
|
160
161
|
ticketlens triage --assignee="Jane Dev" # Another dev's tickets [Team]
|
|
161
162
|
ticketlens triage --sprint="Sprint 12" # Filter by sprint [Team]
|
|
@@ -180,6 +181,8 @@ ticketlens triage --static # Static table, no interactive mo
|
|
|
180
181
|
|
|
181
182
|
`--stale=N` controls both categories. Unanswered comments older than N days downgrade from "needs response" to "aging" automatically.
|
|
182
183
|
|
|
184
|
+
`--sort=priority|urgency` controls display order for this run only. `priority` groups tickets by Jira priority (Highest → Lowest, unknown last) with urgency as the tiebreaker; `urgency` is today's default ordering. Set a permanent default by adding `"sortBy": "priority"` to a profile in `~/.ticketlens/profiles.json` — the flag overrides it for a single run, same relationship `--stale=N` has to `staleDays`. This is a personal, per-profile preference — it does not sync with the Console's own sort-order setting on the Account page, which only affects the Console queue view.
|
|
185
|
+
|
|
183
186
|
Interactive mode: `↑/↓` navigate, `Enter` open in browser, `p` switch profile, `q/Esc` exit. Columns adapt to terminal width.
|
|
184
187
|
|
|
185
188
|
Status mismatch auto-fix: if configured statuses don't match Jira's exact casing, triage shows a diff and offers to update your profile:
|
|
@@ -600,6 +603,7 @@ ticketlens triage # Scan assigned tickets — intera
|
|
|
600
603
|
ticketlens triage --profile=acme # Explicit profile
|
|
601
604
|
ticketlens triage --stale=3 # Needs-response window: 3 days (default: 5)
|
|
602
605
|
ticketlens triage --stale=10 # More lenient — only flag very stale tickets
|
|
606
|
+
ticketlens triage --sort=priority # Sort by priority first, then urgency (default: urgency)
|
|
603
607
|
ticketlens triage --status="Code Review,QA Testing" # Scan these statuses only
|
|
604
608
|
ticketlens triage --static # Static table output (no interactive mode)
|
|
605
609
|
ticketlens triage --plain # Plain markdown — pipe to LLM or file
|
package/package.json
CHANGED
package/skills/jtb/SKILL.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- jtb-skill-version: 0.
|
|
1
|
+
<!-- jtb-skill-version: 0.21.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.
|
|
@@ -28,6 +28,7 @@ Fetches a Jira ticket and produces a structured brief with code references, then
|
|
|
28
28
|
/jtb PROD-1234 --template=full # apply full template (all sections, default)
|
|
29
29
|
/jtb triage # scan your assigned tickets for attention
|
|
30
30
|
/jtb triage --stale=3 # custom aging threshold (days)
|
|
31
|
+
/jtb triage --sort=priority # sort by priority first, then urgency (default: urgency)
|
|
31
32
|
/jtb triage --status=CR,QA # only check specific statuses
|
|
32
33
|
/jtb triage --profile=acme # explicit profile override
|
|
33
34
|
/jtb triage --all # triage all configured profiles at once, merged (Pro)
|
|
@@ -74,7 +74,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
74
74
|
args,
|
|
75
75
|
['--help', '-h', '--static', '--plain', '--styled', '--profile=', '--stale=', '--status=',
|
|
76
76
|
'--assignee=', '--sprint=', '--export=', '--digest', '--push', '--share', '--all', '--save=',
|
|
77
|
-
'--project=', '--label=', '--priority='],
|
|
77
|
+
'--project=', '--label=', '--priority=', '--sort='],
|
|
78
78
|
{ hints: ['--depth=', '--no-attachments', '--no-cache'] }
|
|
79
79
|
);
|
|
80
80
|
if (validatedArgs === null) { process.exitCode = 1; return; }
|
|
@@ -86,6 +86,8 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
86
86
|
const staleArg = args.find(a => a.startsWith('--stale='));
|
|
87
87
|
const staleDays = staleArg ? parseInt(staleArg.split('=')[1], 10) : 5;
|
|
88
88
|
|
|
89
|
+
const sortArg = args.find(a => a.startsWith('--sort='));
|
|
90
|
+
|
|
89
91
|
const statusArg = args.find(a => a.startsWith('--status='));
|
|
90
92
|
const assigneeArg = args.find(a => a.startsWith('--assignee='));
|
|
91
93
|
const sprintArg = args.find(a => a.startsWith('--sprint='));
|
|
@@ -426,7 +428,8 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
426
428
|
|
|
427
429
|
const scored = tickets.map(t => scoreAttention(t, effectiveUser, { staleDays, customRules: conn.attentionRules, staleRule: conn.staleRule ?? null }));
|
|
428
430
|
const actionable = scored.filter(s => s.urgency !== 'clear' && s.urgency !== 'ignore');
|
|
429
|
-
const
|
|
431
|
+
const sortBy = sortArg ? sortArg.split('=')[1] : conn.sortBy;
|
|
432
|
+
const sorted = sortByUrgency(actionable, { sortBy });
|
|
430
433
|
const rawTicketMap = new Map(tickets.map(t => [t.key, t]));
|
|
431
434
|
|
|
432
435
|
// Always save a daily snapshot for history tracking (non-fatal)
|
|
@@ -207,8 +207,24 @@ export function scoreAttention(ticket, currentUser, opts = {}) {
|
|
|
207
207
|
|
|
208
208
|
export const URGENCY_ORDER = { 'needs-response': 0, 'aging': 1, 'stale': 2, 'clear': 3 };
|
|
209
209
|
|
|
210
|
-
|
|
210
|
+
// Standard Jira priority names, case-insensitive. Synonyms for the top tier (some
|
|
211
|
+
// instances/plugins use "Urgent" or "Blocker" instead of "Highest") share rank 0.
|
|
212
|
+
// Anything not in this map (custom priority scheme, or missing) sorts last.
|
|
213
|
+
export const PRIORITY_ORDER = { highest: 0, urgent: 0, blocker: 0, high: 1, medium: 2, low: 3, lowest: 4 };
|
|
214
|
+
|
|
215
|
+
function priorityRank(priority) {
|
|
216
|
+
if (!priority) return Infinity;
|
|
217
|
+
const rank = PRIORITY_ORDER[priority.toLowerCase()];
|
|
218
|
+
return rank === undefined ? Infinity : rank;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function sortByUrgency(scores, opts = {}) {
|
|
222
|
+
const { sortBy } = opts;
|
|
211
223
|
return [...scores].sort((a, b) => {
|
|
224
|
+
if (sortBy === 'priority') {
|
|
225
|
+
const priorityDiff = priorityRank(a.priority) - priorityRank(b.priority);
|
|
226
|
+
if (priorityDiff !== 0) return priorityDiff;
|
|
227
|
+
}
|
|
212
228
|
const orderDiff = URGENCY_ORDER[a.urgency] - URGENCY_ORDER[b.urgency];
|
|
213
229
|
if (orderDiff !== 0) return orderDiff;
|
|
214
230
|
// Within same urgency, sort by most recent activity (lastComment date or ticket updated)
|
|
@@ -25,13 +25,37 @@ export function readCliToken(configDir = DEFAULT_CONFIG_DIR) {
|
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
// Server-asserted tier (personal or team-seat) synced alongside the token on
|
|
29
|
+
// every login/sync — separate from the signed, offline license.json file.
|
|
30
|
+
// Lets team-invited members get Pro-equivalent CLI access without a personal
|
|
31
|
+
// TL- key, mirroring what the manager already pays for.
|
|
32
|
+
export function readCliTokenTier(configDir = DEFAULT_CONFIG_DIR) {
|
|
33
|
+
const p = cliTokenPath(configDir);
|
|
34
|
+
if (!existsSync(p)) return null;
|
|
35
|
+
try {
|
|
36
|
+
const data = JSON.parse(readFileSync(p, 'utf8'));
|
|
37
|
+
return typeof data.tier === 'string' ? data.tier : null;
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function saveCliToken(token, configDir = DEFAULT_CONFIG_DIR, tier = undefined) {
|
|
29
44
|
mkdirSync(configDir, { recursive: true });
|
|
30
45
|
const p = cliTokenPath(configDir);
|
|
31
|
-
|
|
46
|
+
const payload = tier === undefined ? { token } : { token, tier };
|
|
47
|
+
writeFileSync(p, JSON.stringify(payload, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
|
|
32
48
|
chmodSync(p, 0o600);
|
|
33
49
|
}
|
|
34
50
|
|
|
51
|
+
// Updates just the cached tier without touching the token — used by `tl sync`
|
|
52
|
+
// to keep entitlement fresh between logins.
|
|
53
|
+
export function saveCliTokenTier(tier, configDir = DEFAULT_CONFIG_DIR) {
|
|
54
|
+
const token = readCliToken(configDir);
|
|
55
|
+
if (!token) return;
|
|
56
|
+
saveCliToken(token, configDir, tier);
|
|
57
|
+
}
|
|
58
|
+
|
|
35
59
|
export function deleteCliToken(configDir = DEFAULT_CONFIG_DIR) {
|
|
36
60
|
const p = cliTokenPath(configDir);
|
|
37
61
|
if (existsSync(p)) {
|
|
@@ -84,6 +84,7 @@ export function printHelp({ stream = process.stdout } = {}) {
|
|
|
84
84
|
// "--assignee=NAME"=15, "--sprint=NAME"=13, "--static"=8, "--plain"=7 → target=19
|
|
85
85
|
` ${s.brand('--profile')}=${s.dim('NAME')} Use a specific Jira profile`,
|
|
86
86
|
` ${s.brand('--stale')}=${s.dim('N')} Aging threshold in days ${s.dim('(default: 5)')}`,
|
|
87
|
+
` ${s.brand('--sort')}=${s.dim('ORDER')} Sort order ${s.dim('(priority|urgency, default: urgency)')}`,
|
|
87
88
|
` ${s.brand('--status')}=${s.dim('X,Y')} Override statuses to scan`,
|
|
88
89
|
` ${s.brand('--assignee')}=${s.dim('NAME')} Triage another dev's tickets ${s.dim('[Team]')}`,
|
|
89
90
|
` ${s.brand('--sprint')}=${s.dim('NAME')} Filter by sprint name ${s.dim('[Team]')}`,
|
|
@@ -107,6 +108,7 @@ export function printHelp({ stream = process.stdout } = {}) {
|
|
|
107
108
|
` ${s.dim('$')} ticketlens get PROJ-123 --depth=0 --profile=myteam`,
|
|
108
109
|
` ${s.dim('$')} ticketlens triage`,
|
|
109
110
|
` ${s.dim('$')} ticketlens triage --profile=acme --stale=3`,
|
|
111
|
+
` ${s.dim('$')} ticketlens triage --sort=priority`,
|
|
110
112
|
` ${s.dim('$')} ticketlens triage --static`,
|
|
111
113
|
'',
|
|
112
114
|
` ${s.bold('CONFIGURATION')}`,
|
|
@@ -625,6 +627,7 @@ export function printTriageHelp({ stream = process.stdout } = {}) {
|
|
|
625
627
|
// "--assignee=NAME"=15, "--sprint=NAME"=13, "--static"=8, "--plain"=7, "-h, --help"=10 → target=19
|
|
626
628
|
` ${s.brand('--profile')}=${s.dim('NAME')} Use a specific Jira profile`,
|
|
627
629
|
` ${s.brand('--stale')}=${s.dim('N')} Aging threshold in days ${s.dim('(default: 5)')}`,
|
|
630
|
+
` ${s.brand('--sort')}=${s.dim('ORDER')} Sort order ${s.dim('(priority|urgency, default: urgency)')}`,
|
|
628
631
|
` ${s.brand('--status')}=${s.dim('X,Y')} Override statuses to scan`,
|
|
629
632
|
` ${s.brand('--assignee')}=${s.dim('NAME')} Triage another dev's tickets ${s.dim('[Team]')}`,
|
|
630
633
|
` ${s.brand('--sprint')}=${s.dim('NAME')} Filter by sprint name ${s.dim('[Team]')}`,
|
|
@@ -645,6 +648,7 @@ export function printTriageHelp({ stream = process.stdout } = {}) {
|
|
|
645
648
|
'',
|
|
646
649
|
` ${s.dim('$')} ticketlens triage`,
|
|
647
650
|
` ${s.dim('$')} ticketlens triage --profile=acme`,
|
|
651
|
+
` ${s.dim('$')} ticketlens triage --sort=priority`,
|
|
648
652
|
` ${s.dim('$')} ticketlens triage --stale=3 --status="Code Review,QA Testing"`,
|
|
649
653
|
` ${s.dim('$')} ticketlens triage --assignee="Jane Dev" --sprint="Sprint 12"`,
|
|
650
654
|
` ${s.dim('$')} ticketlens triage --project=MYPROJ --priority=High`,
|
|
@@ -5,6 +5,7 @@ import crypto from 'node:crypto';
|
|
|
5
5
|
import { DEFAULT_CONFIG_DIR } from './config.mjs';
|
|
6
6
|
import { createStyler } from './ansi.mjs';
|
|
7
7
|
import { siteBase } from './api-utils.mjs';
|
|
8
|
+
import { readCliTokenTier } from './cli-auth.mjs';
|
|
8
9
|
|
|
9
10
|
// Mixed into the HMAC key so that knowing the license key alone is not sufficient
|
|
10
11
|
// to forge a valid signature — an attacker also needs this constant from the source.
|
|
@@ -100,6 +101,15 @@ export function isLicensed(tier, configDir = DEFAULT_CONFIG_DIR) {
|
|
|
100
101
|
if (required === 0) return true;
|
|
101
102
|
if (process.env.TICKETLENS_SKIP_LICENSE === 'true') return true;
|
|
102
103
|
|
|
104
|
+
if (checkOwnLicense(required, configDir)) return true;
|
|
105
|
+
|
|
106
|
+
// Team-seat entitlement, server-asserted and synced via login/`tl sync` —
|
|
107
|
+
// covers invited team members who never activated a personal TL- key.
|
|
108
|
+
const syncedTier = LICENSE_TIERS[readCliTokenTier(configDir)] ?? 0;
|
|
109
|
+
return syncedTier >= required;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function checkOwnLicense(required, configDir) {
|
|
103
113
|
const license = readLicense(configDir);
|
|
104
114
|
if (!license) return false;
|
|
105
115
|
|
|
@@ -83,7 +83,12 @@ export async function runLogin({
|
|
|
83
83
|
return;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
|
|
86
|
+
let tier;
|
|
87
|
+
try {
|
|
88
|
+
tier = (await res.json())?.tier;
|
|
89
|
+
} catch { /* tier is best-effort — token is still valid */ }
|
|
90
|
+
|
|
91
|
+
saveCliTokenFn(token, undefined, tier);
|
|
87
92
|
stream.write(`\x1b[A\r\x1b[2K ${s.green('✔')} Logged in.\n`);
|
|
88
93
|
|
|
89
94
|
// Flow 1: pull team Jira config for Pro/Team members (silently skipped for Free)
|
|
@@ -233,6 +233,7 @@ export function resolveConnection(ticketKey, opts = {}) {
|
|
|
233
233
|
ticketPrefixes: profile.ticketPrefixes || null,
|
|
234
234
|
attentionRules: profile.attentionRules ?? null,
|
|
235
235
|
staleRule: profile.staleRule ?? null,
|
|
236
|
+
sortBy: profile.sortBy ?? null,
|
|
236
237
|
allowPrivateIp: profile.allowPrivateIp || false,
|
|
237
238
|
source: 'profile',
|
|
238
239
|
profileName: profile.name,
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Server wins on all non-credential fields (shape, prefixes, URLs, etc.).
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { readCliToken } from './cli-auth.mjs';
|
|
8
|
+
import { readCliToken, saveCliTokenTier } from './cli-auth.mjs';
|
|
9
9
|
import {
|
|
10
10
|
loadProfiles,
|
|
11
11
|
loadCredentials,
|
|
@@ -88,6 +88,8 @@ export async function syncProfiles({
|
|
|
88
88
|
let json;
|
|
89
89
|
try { json = await res.json(); } catch { return { error: 'invalid-json' }; }
|
|
90
90
|
|
|
91
|
+
if (typeof json?.tier === 'string') saveCliTokenTier(json.tier, configDir);
|
|
92
|
+
|
|
91
93
|
const remoteProfiles = Array.isArray(json?.profiles) ? json.profiles : [];
|
|
92
94
|
const localConfig = loadProfiles(configDir) || { profiles: {} };
|
|
93
95
|
const localCreds = loadCredentials(configDir);
|