ticketlens 0.1.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.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +457 -0
  3. package/bin/ticketlens.mjs +376 -0
  4. package/package.json +37 -0
  5. package/skills/jtb/scripts/fetch-my-tickets.mjs +377 -0
  6. package/skills/jtb/scripts/fetch-ticket.mjs +682 -0
  7. package/skills/jtb/scripts/lib/adapters/github-adapter.mjs +112 -0
  8. package/skills/jtb/scripts/lib/adapters/jira-adapter.mjs +19 -0
  9. package/skills/jtb/scripts/lib/adf-converter.mjs +67 -0
  10. package/skills/jtb/scripts/lib/ansi.mjs +87 -0
  11. package/skills/jtb/scripts/lib/arg-validator.mjs +178 -0
  12. package/skills/jtb/scripts/lib/attachment-downloader.mjs +123 -0
  13. package/skills/jtb/scripts/lib/attention-scorer.mjs +152 -0
  14. package/skills/jtb/scripts/lib/banner.mjs +201 -0
  15. package/skills/jtb/scripts/lib/brief-assembler.mjs +137 -0
  16. package/skills/jtb/scripts/lib/brief-cache.mjs +137 -0
  17. package/skills/jtb/scripts/lib/budget-pruner.mjs +242 -0
  18. package/skills/jtb/scripts/lib/cache-manager.mjs +499 -0
  19. package/skills/jtb/scripts/lib/cli-auth.mjs +40 -0
  20. package/skills/jtb/scripts/lib/cli.mjs +87 -0
  21. package/skills/jtb/scripts/lib/code-ref-parser.mjs +113 -0
  22. package/skills/jtb/scripts/lib/commit-linker.mjs +42 -0
  23. package/skills/jtb/scripts/lib/compliance-checker.mjs +92 -0
  24. package/skills/jtb/scripts/lib/config-wizard.mjs +392 -0
  25. package/skills/jtb/scripts/lib/config.mjs +63 -0
  26. package/skills/jtb/scripts/lib/diff-analyzer.mjs +66 -0
  27. package/skills/jtb/scripts/lib/drift-tracker.mjs +120 -0
  28. package/skills/jtb/scripts/lib/error-classifier.mjs +119 -0
  29. package/skills/jtb/scripts/lib/help.mjs +253 -0
  30. package/skills/jtb/scripts/lib/hook-installer.mjs +81 -0
  31. package/skills/jtb/scripts/lib/init-wizard.mjs +508 -0
  32. package/skills/jtb/scripts/lib/interactive-list.mjs +257 -0
  33. package/skills/jtb/scripts/lib/jira-client.mjs +169 -0
  34. package/skills/jtb/scripts/lib/ledger.mjs +96 -0
  35. package/skills/jtb/scripts/lib/license.mjs +195 -0
  36. package/skills/jtb/scripts/lib/pr-assembler.mjs +186 -0
  37. package/skills/jtb/scripts/lib/profile-picker.mjs +216 -0
  38. package/skills/jtb/scripts/lib/profile-resolver.mjs +236 -0
  39. package/skills/jtb/scripts/lib/profile-switcher.mjs +147 -0
  40. package/skills/jtb/scripts/lib/prompt-helpers.mjs +122 -0
  41. package/skills/jtb/scripts/lib/requirement-extractor.mjs +52 -0
  42. package/skills/jtb/scripts/lib/resolve-adapter.mjs +28 -0
  43. package/skills/jtb/scripts/lib/schedule-wizard.mjs +153 -0
  44. package/skills/jtb/scripts/lib/select-prompt.mjs +106 -0
  45. package/skills/jtb/scripts/lib/spinner.mjs +44 -0
  46. package/skills/jtb/scripts/lib/styled-assembler.mjs +183 -0
  47. package/skills/jtb/scripts/lib/summarizer.mjs +109 -0
  48. package/skills/jtb/scripts/lib/sync.mjs +119 -0
  49. package/skills/jtb/scripts/lib/table-formatter.mjs +48 -0
  50. package/skills/jtb/scripts/lib/triage-exporter.mjs +93 -0
  51. package/skills/jtb/scripts/lib/triage-history.mjs +166 -0
  52. package/skills/jtb/scripts/lib/triage-push.mjs +98 -0
  53. package/skills/jtb/scripts/lib/usage-tracker.mjs +54 -0
  54. package/skills/jtb/scripts/lib/vcs-detector.mjs +12 -0
@@ -0,0 +1,377 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * CLI entry point: scans assigned tickets and surfaces what needs attention.
5
+ * Usage: node fetch-my-tickets.mjs [--stale=N] [--status=X,Y] [--profile=NAME]
6
+ */
7
+
8
+ import { scoreAttention, sortByUrgency } from './lib/attention-scorer.mjs';
9
+ import { assembleTriageSummary } from './lib/brief-assembler.mjs';
10
+ import { styleTriageSummary } from './lib/styled-assembler.mjs';
11
+ import { resolveConnection, loadProfiles, saveProfile } from './lib/profile-resolver.mjs';
12
+ import { resolveAdapter } from './lib/resolve-adapter.mjs';
13
+ import { createSpinner } from './lib/spinner.mjs';
14
+ import { createSession } from './lib/banner.mjs';
15
+ import { classifyError } from './lib/error-classifier.mjs';
16
+ import { runInteractiveList } from './lib/interactive-list.mjs';
17
+ import { promptProfileSelect } from './lib/profile-picker.mjs';
18
+ import { printTriageHelp } from './lib/help.mjs';
19
+ import { handleUnknownFlags } from './lib/arg-validator.mjs';
20
+ import { isLicensed, showUpgradePrompt, revalidateIfStale, readLicense } from './lib/license.mjs';
21
+
22
+ const DEFAULT_STATUSES = ['In Progress', 'Code Review', 'QA'];
23
+
24
+ async function defaultDigestDeliverer(payload) {
25
+ const { readLicense } = await import('./lib/license.mjs');
26
+ const licenseKey = readLicense()?.key;
27
+ const res = await fetch('https://api.ticketlens.dev/v1/digest/deliver', {
28
+ method: 'POST',
29
+ signal: AbortSignal.timeout(10_000),
30
+ headers: {
31
+ 'Content-Type': 'application/json',
32
+ 'Authorization': `Bearer ${licenseKey}`,
33
+ },
34
+ body: JSON.stringify(payload),
35
+ });
36
+ if (!res.ok) throw new Error(`Digest delivery failed: ${res.status}`);
37
+ return true;
38
+ }
39
+
40
+ function escapeJql(s) {
41
+ return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
42
+ }
43
+
44
+ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fetch, configDir = undefined) {
45
+ // Support both legacy positional form run(args, env, fetcher, configDir)
46
+ // and new opts-object form run(args, { env, fetcher, configDir, exporter, isLicensed, showUpgradePrompt, print })
47
+ let env, opts;
48
+ if (envOrOpts && typeof envOrOpts === 'object' && 'env' in envOrOpts) {
49
+ opts = envOrOpts;
50
+ env = opts.env ?? process.env;
51
+ fetcher = opts.fetcher ?? globalThis.fetch;
52
+ configDir = opts.configDir ?? undefined;
53
+ } else {
54
+ opts = {};
55
+ env = envOrOpts;
56
+ }
57
+
58
+ // Strip leading 'triage' subcommand if present (when called via CLI router)
59
+ if (args[0] === 'triage') args = args.slice(1);
60
+
61
+ if (args.includes('--help') || args.includes('-h')) {
62
+ printTriageHelp();
63
+ return;
64
+ }
65
+ // Normalize --project= alias once at entry so all recursive calls only see --profile=
66
+ const projectArg = args.find(a => a.startsWith('--project='));
67
+ if (projectArg) {
68
+ process.stderr.write(`Hint: --project recognized as alias for --profile=${projectArg.split('=')[1]}\n\n`);
69
+ args = args.map(a => a.startsWith('--project=') ? `--profile=${a.split('=')[1]}` : a);
70
+ }
71
+
72
+ const profileArg = args.find(a => a.startsWith('--profile='));
73
+ const profileName = profileArg ? profileArg.split('=')[1] : undefined;
74
+
75
+ const validatedArgs = await handleUnknownFlags(
76
+ args,
77
+ ['--help', '-h', '--static', '--plain', '--styled', '--profile=', '--stale=', '--status=', '--assignee=', '--sprint=', '--export=', '--digest', '--push'],
78
+ { hints: ['--depth=', '--no-attachments', '--no-cache'] } // fetch-only flags — shown as hints, not applied
79
+ );
80
+ if (validatedArgs === null) { process.exitCode = 1; return; }
81
+ args = validatedArgs;
82
+
83
+ // Fire-and-forget: silently refresh license.json if >7 days since last validation
84
+ revalidateIfStale({ configDir, fetcher });
85
+
86
+ const staleArg = args.find(a => a.startsWith('--stale='));
87
+ const staleDays = staleArg ? parseInt(staleArg.split('=')[1], 10) : 5;
88
+
89
+ const statusArg = args.find(a => a.startsWith('--status='));
90
+ const assigneeArg = args.find(a => a.startsWith('--assignee='));
91
+ const sprintArg = args.find(a => a.startsWith('--sprint='));
92
+ const exportArg = args.find(a => a.startsWith('--export='))?.split('=')[1] ?? null;
93
+ const digestFlag = args.includes('--digest');
94
+ const pushFlag = args.includes('--push');
95
+
96
+ if (exportArg && exportArg !== 'csv' && exportArg !== 'json') {
97
+ process.stderr.write(`Error: --export must be csv or json, got: ${exportArg}\n`);
98
+ process.exitCode = 1;
99
+ return;
100
+ }
101
+
102
+ const licensedFn = opts.isLicensed ?? isLicensed;
103
+ const upgradeFn = opts.showUpgradePrompt ?? showUpgradePrompt;
104
+
105
+ // Team-tier gate: --assignee and --sprint require a Team license
106
+ if ((assigneeArg || sprintArg) && !licensedFn('team', configDir)) {
107
+ upgradeFn('team', assigneeArg ? '--assignee' : '--sprint');
108
+ process.exitCode = 1;
109
+ return;
110
+ }
111
+
112
+ // Team-tier gate: --export requires a Team license
113
+ if (exportArg && !licensedFn('team', configDir)) {
114
+ upgradeFn('team', '--export');
115
+ process.exitCode = 1;
116
+ return;
117
+ }
118
+
119
+ const assigneeName = assigneeArg ? assigneeArg.split('=').slice(1).join('=') : null;
120
+ const sprintName = sprintArg ? sprintArg.split('=').slice(1).join('=') : null;
121
+
122
+ const cwd = process.cwd();
123
+ let profileError = null;
124
+ const conn = resolveConnection(null, {
125
+ env,
126
+ configDir,
127
+ profileName,
128
+ cwd,
129
+ onWarning: (w) => process.stderr.write(w + '\n'),
130
+ onProfileNotFound: (info) => { profileError = info; },
131
+ });
132
+
133
+ const hasAuth = conn.pat || (conn.email && conn.apiToken);
134
+ if (!conn.baseUrl || !hasAuth) {
135
+ if (profileError) {
136
+ const picked = await promptProfileSelect(profileError);
137
+ if (picked) {
138
+ // Re-run with the selected profile
139
+ const newArgs = args.filter(a => !a.startsWith('--profile='));
140
+ newArgs.push(`--profile=${picked}`);
141
+ return run(newArgs, { ...opts, env, fetcher, configDir });
142
+ }
143
+ } else {
144
+ const noProfiles = !loadProfiles(configDir)?.profiles;
145
+ const msg = noProfiles
146
+ ? 'Error: Could not determine Jira profile.\nRun `ticketlens init` to set up your connection.'
147
+ : 'Error: Could not determine Jira profile. Use --profile=NAME or add projectPaths to ~/.ticketlens/profiles.json';
148
+ process.stderr.write(msg + '\n');
149
+ }
150
+ process.exitCode = 1;
151
+ return;
152
+ }
153
+
154
+ const adapter = resolveAdapter(conn, { fetcher });
155
+
156
+ // Status resolution: --status flag > profile triageStatuses > defaults
157
+ const statuses = statusArg
158
+ ? statusArg.split('=')[1].split(',').map(s => s.trim())
159
+ : conn.triageStatuses || DEFAULT_STATUSES;
160
+
161
+ // Build JQL before any I/O — pure computation, no dependency on currentUser
162
+ const statusList = statuses.map(s => `"${escapeJql(s)}"`).join(',');
163
+ const assigneeClause = assigneeName ? `assignee = "${escapeJql(assigneeName)}"` : `assignee = currentUser()`;
164
+ const sprintClause = sprintName ? ` AND sprint = "${escapeJql(sprintName)}"` : '';
165
+ const jql = `${assigneeClause} AND status IN (${statusList})${sprintClause} ORDER BY updated DESC`;
166
+
167
+ const session = createSession(conn);
168
+ session.spin(`Connecting to ${session.label}…`);
169
+
170
+ // Fire both requests concurrently — they are independent of each other
171
+ const userPromise = adapter.fetchCurrentUser();
172
+ const ticketsPromise = adapter.searchTickets(jql);
173
+
174
+ let currentUser;
175
+ try {
176
+ currentUser = await userPromise;
177
+ } catch (err) {
178
+ ticketsPromise.catch(() => {}); // prevent unhandled rejection — we're bailing on the user request
179
+ const classified = classifyError(err, conn);
180
+ session.failed();
181
+ session.footer(classified.message, 'error', classified.hint);
182
+ process.exitCode = 1;
183
+ return;
184
+ }
185
+
186
+ session.connected();
187
+ process.stderr.write('\n');
188
+
189
+ const scanSpinner = createSpinner('Scanning tickets…');
190
+ scanSpinner.start();
191
+
192
+ let tickets;
193
+ try {
194
+ tickets = await ticketsPromise;
195
+ } catch (err) {
196
+ scanSpinner.stop();
197
+ if (err.status === 400 && err.detail && /does not exist for the field 'status'/.test(err.detail)) {
198
+ const s = session.styler;
199
+ const out = process.stderr;
200
+ out.write(`\n ${s.yellow('○')} Status mismatch — checking Jira...\n`);
201
+ try {
202
+ const available = await adapter.fetchStatuses();
203
+ const lowerMap = new Map(available.map(n => [n.toLowerCase(), n]));
204
+
205
+ // Map each configured status to its best match (exact → case-insensitive → partial)
206
+ const mappings = statuses.map(name => {
207
+ if (available.includes(name)) return { input: name, fix: name, ok: true };
208
+ const caseMatch = lowerMap.get(name.toLowerCase());
209
+ if (caseMatch) return { input: name, fix: caseMatch, ok: false };
210
+ const partial = available.find(a =>
211
+ a.toLowerCase().includes(name.toLowerCase()) ||
212
+ name.toLowerCase().startsWith(a.toLowerCase().split(' ')[0])
213
+ );
214
+ return { input: name, fix: partial || null, ok: false };
215
+ });
216
+
217
+ out.write('\n');
218
+ for (const m of mappings) {
219
+ if (m.ok) out.write(` ${s.green('✔')} ${m.input}\n`);
220
+ else if (m.fix) out.write(` ${s.yellow('~')} ${s.dim(m.input)} → ${s.cyan(m.fix)}\n`);
221
+ else out.write(` ${s.red('✖')} ${m.input} ${s.dim('(not found in this Jira instance)')}\n`);
222
+ }
223
+
224
+ const suggested = mappings.filter(m => m.fix).map(m => m.fix);
225
+
226
+ // On TTY: offer to auto-fix the profile and re-run
227
+ if (suggested.length > 0 && conn.profileName && out.isTTY && process.stdin.setRawMode) {
228
+ out.write(`\n Update ${s.cyan(`"${conn.profileName}"`)} with corrected statuses? ${s.dim('y/N')} `);
229
+ const answer = await new Promise(res => {
230
+ process.stdin.setRawMode(true);
231
+ process.stdin.resume();
232
+ process.stdin.setEncoding('utf8');
233
+ process.stdin.once('data', char => {
234
+ process.stdin.setRawMode(false);
235
+ process.stdin.pause();
236
+ out.write('\n');
237
+ if (char === '\x03') process.exit(0);
238
+ res(char === 'y' || char === 'Y');
239
+ });
240
+ });
241
+ if (answer) {
242
+ const config = loadProfiles(configDir);
243
+ if (config?.profiles[conn.profileName]) {
244
+ const existing = config.profiles[conn.profileName].triageStatuses || [];
245
+ const merged = [...new Set([...existing, ...suggested])];
246
+ const updated = { ...config.profiles[conn.profileName], triageStatuses: merged };
247
+ saveProfile(conn.profileName, updated, {}, configDir);
248
+ out.write(` ${s.green('✔')} Profile updated. Rerunning...\n\n`);
249
+ // Strip --status flag so the re-run uses the corrected profile statuses
250
+ const rerunArgs = args.filter(a => !a.startsWith('--status='));
251
+ return run(rerunArgs, { ...opts, env, fetcher, configDir });
252
+ }
253
+ }
254
+ }
255
+
256
+ // Fallback: show compact fix hint
257
+ if (suggested.length > 0) {
258
+ out.write(`\n ${s.dim('Suggested fix for')} ~/.ticketlens/profiles.json ${s.dim(`→ "${conn.profileName || 'your-profile'}"`)}\n`);
259
+ out.write(` ${s.cyan('"triageStatuses"')}: ${JSON.stringify(suggested)}\n\n`);
260
+ out.write(` ${s.dim('Or:')} ticketlens triage --status=${suggested.join(',')}\n`);
261
+ }
262
+ } catch {
263
+ out.write(`\n ${s.dim('Could not fetch available statuses.')}\n`);
264
+ }
265
+ process.exitCode = 1;
266
+ return;
267
+ }
268
+ const classified = classifyError(err, conn);
269
+ session.footer(classified.message, 'error', classified.hint);
270
+ process.exitCode = 1;
271
+ return;
272
+ }
273
+
274
+ scanSpinner.stop();
275
+
276
+ // When viewing another dev's tickets, score from their perspective (they need to respond)
277
+ const effectiveUser = assigneeName
278
+ ? { displayName: assigneeName, name: null, accountId: null, emailAddress: null }
279
+ : currentUser;
280
+
281
+ if (assigneeName) {
282
+ process.stderr.write(`Viewing ${assigneeName}'s tickets\n\n`);
283
+ }
284
+
285
+ const scored = tickets.map(t => scoreAttention(t, effectiveUser, { staleDays }));
286
+ const actionable = scored.filter(s => s.urgency !== 'clear');
287
+ const sorted = sortByUrgency(actionable);
288
+ const rawTicketMap = new Map(tickets.map(t => [t.key, t]));
289
+
290
+ // --digest: POST scored results to the digest backend endpoint
291
+ if (digestFlag) {
292
+ if (!licensedFn('pro', configDir)) {
293
+ upgradeFn('pro', '--digest');
294
+ process.exitCode = 1;
295
+ return;
296
+ }
297
+ const deliverer = opts.digestDeliverer ?? defaultDigestDeliverer;
298
+
299
+ // Triage history delta (non-fatal — wrapped in try/catch)
300
+ let delta = null;
301
+ try {
302
+ const { saveTriageSnapshot, loadYesterdaySnapshot, diffSnapshots, buildDeltaSection } =
303
+ await import('./lib/triage-history.mjs');
304
+ saveTriageSnapshot(sorted, { profile: profileName ?? 'default', configDir });
305
+ const yesterday = loadYesterdaySnapshot({ profile: profileName ?? 'default', configDir });
306
+ if (yesterday) {
307
+ const deltas = diffSnapshots(sorted, yesterday);
308
+ delta = buildDeltaSection(deltas) || null;
309
+ }
310
+ } catch { /* non-fatal — digest still sends */ }
311
+
312
+ await deliverer({
313
+ profile: profileName ?? 'default',
314
+ staleDays,
315
+ summary: {
316
+ total: sorted.length,
317
+ needsResponse: sorted.filter(t => t.urgency === 'needs-response').length,
318
+ aging: sorted.filter(t => t.urgency === 'aging').length,
319
+ },
320
+ tickets: sorted,
321
+ delta,
322
+ });
323
+ return;
324
+ }
325
+
326
+ // --export: write results to file instead of (or in addition to) printing
327
+ if (exportArg) {
328
+ const { exportTriage } = await import('./lib/triage-exporter.mjs');
329
+ const exporterFn = opts.exporter ?? exportTriage;
330
+ const outputPath = await Promise.resolve(exporterFn({ tickets: sorted, format: exportArg, profile: profileName ?? 'default', configDir }));
331
+ const printFn = opts.print ?? ((msg) => process.stdout.write(msg + '\n'));
332
+ printFn(`Export written to ${outputPath}`);
333
+ return;
334
+ }
335
+
336
+ // Interactive mode: TTY + not --plain + not --static
337
+ const wantInteractive = process.stdout.isTTY && !args.includes('--plain') && !args.includes('--static');
338
+ if (wantInteractive && process.stdin.setRawMode) {
339
+ const result = await runInteractiveList(sorted, { baseUrl: conn.baseUrl, staleDays, styled: true });
340
+ if (result === 'switch') {
341
+ const cleanArgs = args.filter(a => !a.startsWith('--profile=') && !a.startsWith('--project='));
342
+ return run(cleanArgs, { ...opts, env, fetcher, configDir });
343
+ }
344
+ return;
345
+ }
346
+
347
+ const useStyled = args.includes('--styled') || (!args.includes('--plain') && process.stdout.isTTY);
348
+ const summary = useStyled
349
+ ? styleTriageSummary(sorted, { styled: true, staleDays, baseUrl: conn.baseUrl })
350
+ : assembleTriageSummary(sorted, { staleDays, baseUrl: conn.baseUrl });
351
+ process.stdout.write(summary + '\n');
352
+
353
+ if (pushFlag) {
354
+ const { pushTriageSnapshot } = await import('./lib/triage-push.mjs');
355
+ const pushFn = opts.pushFn ?? pushTriageSnapshot;
356
+ const licenseKey = readLicense(configDir)?.key ?? null;
357
+ const printFn = opts.print ?? ((s) => process.stdout.write(s));
358
+ await pushFn({
359
+ sorted,
360
+ rawTicketMap,
361
+ profile: profileName ?? 'default',
362
+ baseUrl: conn.baseUrl,
363
+ licenseKey,
364
+ fetcher,
365
+ print: printFn,
366
+ });
367
+ }
368
+ }
369
+
370
+ // Run if invoked directly
371
+ const isMain = process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/.*\//, ''));
372
+ if (isMain) {
373
+ run(process.argv.slice(2)).catch(err => {
374
+ process.stderr.write(`Error: ${err.message}\n`);
375
+ process.exitCode = 1;
376
+ });
377
+ }