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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.20.2",
3
+ "version": "0.21.1",
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.20.2 -->
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 sorted = sortByUrgency(actionable);
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
- export function sortByUrgency(scores) {
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
- export function saveCliToken(token, configDir = DEFAULT_CONFIG_DIR) {
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
- writeFileSync(p, JSON.stringify({ token }, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
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
- saveCliTokenFn(token);
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);