ticketlens 0.1.14 → 0.1.15

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
@@ -35,6 +35,7 @@ Zero npm dependencies. Node.js built-ins only.
35
35
  - **Scriptable** — standard CLI output: pipe to cron, git hooks, CI/CD, or any LLM tool
36
36
  - **Multi-profile** — connect multiple Jira instances simultaneously; auto-route by ticket prefix or project path
37
37
  - **Attachments included** — images, PDFs, and text files downloaded locally; Claude Code reads them as context
38
+ - **Confluence pages** — linked Confluence pages fetched and included in the brief automatically (Jira only)
38
39
 
39
40
  ---
40
41
 
@@ -93,7 +94,7 @@ ticketlens CNV1-2 --depth=2 # + linked-of-linked (full graph)
93
94
  ticketlens CNV1-2 --plain # Plain markdown — pipe-safe, LLM-ready
94
95
  ticketlens CNV1-2 --profile=acme # Force a specific profile
95
96
  ticketlens CNV1-2 --no-cache # Bypass cache, re-fetch from Jira
96
- ticketlens CNV1-2 --no-attachments # Skip attachment download
97
+ ticketlens CNV1-2 --no-attachments # Skip attachment download and Confluence page fetching
97
98
  ticketlens CNV1-2 --check # Append local VCS diff + Claude Code review instructions
98
99
  ticketlens CNV1-2 --compliance # Check ticket requirements against local diff [Pro/Free 3/mo]
99
100
  ticketlens CNV1-2 --summarize # AI summary via your own API key (BYOK) [Pro]
@@ -102,7 +103,7 @@ ticketlens CNV1-2 --summarize --cloud # AI summary routed through TicketLens AP
102
103
 
103
104
  | `--depth` | Scope |
104
105
  |-----------|-------|
105
- | `0` | Target ticket: description, comments, attachments |
106
+ | `0` | Target ticket: description, comments, attachments, Confluence pages |
106
107
  | `1` | + linked tickets: descriptions and comments _(default)_ |
107
108
  | `2` | + linked-of-linked: key and summary only |
108
109
 
@@ -116,6 +117,8 @@ After the first fetch, ticket data is cached to `~/.ticketlens/cache/PROFILE/TIC
116
117
 
117
118
  Attachments download to `~/.ticketlens/cache/TICKET-KEY/` (10 MB per-file cap). Claude Code reads images multimodally, extracts PDF text, and reads plain text files as context.
118
119
 
120
+ Confluence pages linked to the ticket via Jira Remote Links are fetched automatically and included as plain text in the brief (Jira profiles only, same-origin). Use `--no-attachments` to skip both attachments and Confluence pages.
121
+
119
122
  ---
120
123
 
121
124
  ### Triage
@@ -455,12 +458,12 @@ npm test
455
458
  See [ROADMAP.md](ROADMAP.md) for the full plan.
456
459
 
457
460
  Recently shipped:
461
+ - **Confluence pages** — linked Confluence pages fetched automatically and included in the brief; origin-validated, non-fatal, capped at 10 pages
458
462
  - **Linear support** — `ticketlens init` → Linear; connects via GraphQL API key, fetches tickets, triage, and statuses
459
463
  - **GitHub Issues support** — `ticketlens init` → GitHub Issues; PAT-based, same normalized ticket shape
460
464
  - **Tracker-aware config** — `ticketlens config` shows the right labels and skips irrelevant prompts per tracker type; always re-validates the connection
461
465
 
462
466
  Coming up:
463
- - Confluence/wiki page fetching (F45)
464
467
  - "TicketLens for PRs" — PR context assembly with linked tickets and compliance (F46)
465
468
 
466
469
  ---
@@ -23,6 +23,7 @@ import {
23
23
  printActivateHelp, printLicenseHelp, printDeleteHelp,
24
24
  printProfilesHelp, printScheduleHelp,
25
25
  printInitHelp, printSwitchHelp, printConfigHelp,
26
+ printReviewHelp,
26
27
  } from '../skills/jtb/scripts/lib/help.mjs';
27
28
  import { createStyler } from '../skills/jtb/scripts/lib/ansi.mjs';
28
29
  import { readCliToken, saveCliToken, deleteCliToken } from '../skills/jtb/scripts/lib/cli-auth.mjs';
@@ -262,6 +263,14 @@ switch (command) {
262
263
  });
263
264
  break;
264
265
 
266
+ case 'review':
267
+ if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printReviewHelp(); break; }
268
+ runFetch(['review', ...cmdArgs]).catch(err => {
269
+ process.stderr.write(`Error: ${err.message}\n`);
270
+ process.exitCode = 1;
271
+ });
272
+ break;
273
+
265
274
  case 'ledger':
266
275
  runFetch(['ledger', ...cmdArgs]).catch(err => {
267
276
  process.stderr.write(`Error: ${err.message}\n`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
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": {
@@ -342,6 +342,87 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
342
342
  return;
343
343
  }
344
344
 
345
+ if (args[0] === 'review') {
346
+ const { assemblePrReview, extractTicketKeys } = await import('./lib/pr-review-assembler.mjs');
347
+ const { analyzeDiff } = await import('./lib/diff-analyzer.mjs');
348
+ const { extractRequirements } = await import('./lib/requirement-extractor.mjs');
349
+
350
+ const resolvedConfigDir = configDir ?? (await import('./lib/config.mjs')).DEFAULT_CONFIG_DIR;
351
+ const execFn = opts.execFn ?? spawnSync;
352
+ const cwd = process.cwd();
353
+
354
+ // Resolve base branch: --base=BRANCH or auto-detect main/master/develop
355
+ const baseArg = args.find(a => a.startsWith('--base='));
356
+ let baseBranch = baseArg ? baseArg.split('=')[1] : null;
357
+ if (!baseBranch) {
358
+ for (const candidate of ['main', 'master', 'develop']) {
359
+ const r = execFn('git', ['rev-parse', '--verify', candidate], { encoding: 'utf8', cwd, timeout: 5_000 });
360
+ if (r.status === 0) { baseBranch = candidate; break; }
361
+ }
362
+ baseBranch = baseBranch ?? 'main';
363
+ }
364
+
365
+ // Current branch name
366
+ const headResult = execFn('git', ['branch', '--show-current'], { encoding: 'utf8', cwd, timeout: 5_000 });
367
+ const headBranch = headResult.status === 0 ? (headResult.stdout.trim() || null) : null;
368
+
369
+ // Commits in range
370
+ const logResult = execFn('git', ['log', '--oneline', `${baseBranch}..HEAD`], { encoding: 'utf8', cwd, timeout: 10_000 });
371
+ const commitMessages = logResult.status === 0 ? (logResult.stdout ?? '') : '';
372
+
373
+ // Diff between base and HEAD
374
+ const diffResult = execFn('git', ['diff', `${baseBranch}..HEAD`], { encoding: 'utf8', cwd, timeout: 30_000 });
375
+ const diff = diffResult.status === 0 ? (diffResult.stdout || null) : null;
376
+
377
+ // Extract ticket keys from branch name + commit messages
378
+ const allText = [headBranch ?? '', commitMessages].join('\n');
379
+ const ticketKeys = extractTicketKeys(allText);
380
+
381
+ // Fetch tickets (best-effort — gracefully degrade if no credentials)
382
+ const profileArgR = args.find(a => a.startsWith('--profile='));
383
+ const profileNameR = profileArgR ? profileArgR.split('=')[1] : undefined;
384
+ const tickets = [];
385
+
386
+ if (ticketKeys.length > 0) {
387
+ const connR = resolveConnection(ticketKeys[0], {
388
+ env,
389
+ configDir: resolvedConfigDir,
390
+ profileName: profileNameR,
391
+ cwd,
392
+ onWarning: () => {},
393
+ onProfileNotFound: () => {},
394
+ });
395
+ const hasAuthR = connR.pat || (connR.email && connR.apiToken);
396
+ if (connR.baseUrl && hasAuthR) {
397
+ const adapterR = resolveAdapter(connR, { fetcher });
398
+ for (const key of ticketKeys) {
399
+ try {
400
+ tickets.push(await adapterR.fetchTicket(key, { depth: 0 }));
401
+ } catch { /* non-fatal — skip ticket */ }
402
+ }
403
+ } else {
404
+ process.stderr.write(
405
+ `Note: Found tickets [${ticketKeys.join(', ')}] but no profile configured. Run 'ticketlens init' to set up.\n`
406
+ );
407
+ }
408
+ }
409
+
410
+ const isLic = opts.isLicensedFn ?? ((tier) => isLicensed(tier, resolvedConfigDir));
411
+ const assembleFn = opts.assemblePrReviewFn ?? assemblePrReview;
412
+
413
+ const md = await assembleFn({
414
+ diff,
415
+ tickets,
416
+ baseBranch,
417
+ headBranch,
418
+ isLicensedFn: isLic,
419
+ analyzeDiffFn: analyzeDiff,
420
+ extractRequirementsFn: extractRequirements,
421
+ });
422
+ printFn(md + '\n');
423
+ return;
424
+ }
425
+
345
426
  const ticketKey = args.find(a => !a.startsWith('--'));
346
427
  if (!ticketKey) {
347
428
  printFetchHelp({ stream: process.stderr });
@@ -38,6 +38,7 @@ export function printHelp({ stream = process.stdout } = {}) {
38
38
  ` ${s.brand('ticketlens')} ${s.dim('<TICKET-KEY>')} ${s.dim('[options]')} Fetch a ticket brief`,
39
39
  ` ${s.brand('ticketlens')} get ${s.dim('<TICKET-KEY>')} Same as above ${s.dim('(explicit alias)')}`,
40
40
  ` ${s.brand('ticketlens')} triage ${s.dim('[options]')} Scan your assigned tickets`,
41
+ ` ${s.brand('ticketlens')} review ${s.dim('[--base=BRANCH]')} Code-review context brief from current branch`,
41
42
  ` ${s.brand('ticketlens')} compliance ${s.dim('<TICKET-KEY>')} Check requirements coverage ${s.dim('[Pro/Free 3/mo]')}`,
42
43
  '',
43
44
  ` ${s.brand('ticketlens')} delete ${s.dim('<PROFILE-NAME>')} Remove a profile`,
@@ -539,3 +540,37 @@ export function printTriageHelp({ stream = process.stdout } = {}) {
539
540
  ];
540
541
  stream.write(lines.join('\n') + '\n');
541
542
  }
543
+
544
+ export function printReviewHelp({ stream = process.stdout } = {}) {
545
+ const s = createStyler({ isTTY: stream.isTTY });
546
+ const lines = [
547
+ '',
548
+ ` ${s.bold(s.brand('ticketlens'))} ${s.bold('review')} ${s.dim('[--base=BRANCH] [--profile=NAME]')}`,
549
+ '',
550
+ ` Assemble a code-review context brief from your current branch.`,
551
+ ` Extracts linked ticket keys from the branch name and commit messages,`,
552
+ ` fetches each ticket, and outputs a markdown brief for AI-assisted review.`,
553
+ '',
554
+ ` Requirements coverage analysis against the diff is available on ${s.dim('[Pro]')}.`,
555
+ '',
556
+ ` ${s.bold('OPTIONS')}`,
557
+ '',
558
+ ` ${s.brand('--base')}=${s.dim('BRANCH')} Compare against this branch ${s.dim('(default: auto-detect main/master/develop)')}`,
559
+ ` ${s.brand('--profile')}=${s.dim('NAME')} Use a specific tracker profile for ticket fetching`,
560
+ ` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
561
+ '',
562
+ ` ${s.bold('EXAMPLES')}`,
563
+ '',
564
+ ` ${s.dim('$')} ticketlens review`,
565
+ ` ${s.dim('$')} ticketlens review --base=main`,
566
+ ` ${s.dim('$')} ticketlens review --base=main --profile=myteam`,
567
+ ` ${s.dim('$')} ticketlens review --base=main | pbcopy ${s.dim('# copy brief to clipboard')}`,
568
+ '',
569
+ ` ${s.bold('OUTPUT SECTIONS')}`,
570
+ '',
571
+ ` Branch, Changed files, Ticket context`,
572
+ ` Requirements coverage ${s.dim('[Pro]')}, Review focus ${s.dim('[Pro]')}`,
573
+ '',
574
+ ];
575
+ stream.write(lines.join('\n') + '\n');
576
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * PR Review Context Assembler
3
+ * Given a git diff + pre-fetched tickets, produces a markdown brief
4
+ * suitable for AI-assisted code review.
5
+ */
6
+
7
+ import { analyzeDiff } from './diff-analyzer.mjs';
8
+ import { extractRequirements } from './requirement-extractor.mjs';
9
+
10
+ const TICKET_KEY_RE = /\b([A-Z][A-Z0-9]+-\d+)\b/g;
11
+
12
+ /**
13
+ * Extract all unique ticket keys from a string (branch name, commit messages, etc.).
14
+ * Returns a sorted, deduplicated array.
15
+ *
16
+ * @param {string} text
17
+ * @returns {string[]}
18
+ */
19
+ export function extractTicketKeys(text) {
20
+ if (!text || typeof text !== 'string') return [];
21
+ const found = new Set(text.match(TICKET_KEY_RE) ?? []);
22
+ return [...found].sort();
23
+ }
24
+
25
+ /**
26
+ * Build the "### Changed files" section from a unified diff string.
27
+ * Parses "+++ b/<path>" headers.
28
+ *
29
+ * @param {string|null} diff
30
+ * @returns {string} markdown section or empty string
31
+ */
32
+ function buildChangedFilesSection(diff) {
33
+ if (!diff) return '';
34
+ const files = [];
35
+ for (const line of diff.split('\n')) {
36
+ if (line.startsWith('+++ b/')) {
37
+ const path = line.slice(6).trim();
38
+ if (path && path !== '/dev/null') files.push(path);
39
+ }
40
+ }
41
+ if (files.length === 0) return '';
42
+ const lines = ['', `### Changed files (${files.length})`];
43
+ for (const f of files) lines.push(`- ${f}`);
44
+ return lines.join('\n');
45
+ }
46
+
47
+ /**
48
+ * Build the coverage + review-focus sections for all tickets (Pro-gated).
49
+ * Returns empty string when no requirements exist across all tickets.
50
+ *
51
+ * @param {object[]} tickets
52
+ * @param {string} diff
53
+ * @param {Function} extractRequirementsFn
54
+ * @param {Function} analyzeDiffFn
55
+ * @returns {string}
56
+ */
57
+ function buildCoverageSections(tickets, diff, extractRequirementsFn, analyzeDiffFn) {
58
+ const coverageEntries = [];
59
+ const focusEntries = [];
60
+
61
+ for (const ticket of tickets) {
62
+ const reqs = extractRequirementsFn(ticket.description ?? '');
63
+ if (reqs.length === 0) continue;
64
+
65
+ const { results, coveragePercent } = analyzeDiffFn(reqs, diff);
66
+ coverageEntries.push({ ticket, results, coveragePercent });
67
+
68
+ const uncovered = results
69
+ .filter(r => r.status === 'NOT_FOUND')
70
+ .map(r => r.requirement);
71
+ if (uncovered.length > 0) focusEntries.push({ ticket, uncovered });
72
+ }
73
+
74
+ if (coverageEntries.length === 0) return '';
75
+
76
+ const lines = ['', '### Requirements coverage'];
77
+ for (const { ticket, results, coveragePercent } of coverageEntries) {
78
+ lines.push('', `#### ${ticket.key} (${coveragePercent}%)`);
79
+ for (const r of results) {
80
+ if (r.status === 'NOT_FOUND') {
81
+ lines.push(`- ✖ ${r.requirement}`);
82
+ } else {
83
+ const loc = r.evidence ? ` — ${r.evidence}` : '';
84
+ const mark = r.status === 'FOUND' ? '✔' : '~';
85
+ lines.push(`- ${mark} ${r.requirement}${loc}`);
86
+ }
87
+ }
88
+ }
89
+
90
+ if (focusEntries.length > 0) {
91
+ lines.push('', '### Review focus');
92
+ for (const { ticket, uncovered } of focusEntries) {
93
+ lines.push('', `#### ${ticket.key} — uncovered requirements`);
94
+ for (const req of uncovered) lines.push(`- ${req}`);
95
+ }
96
+ }
97
+
98
+ return lines.join('\n');
99
+ }
100
+
101
+ /**
102
+ * Assemble a markdown PR code-review context brief.
103
+ *
104
+ * @param {object} opts
105
+ * @param {string|null} [opts.diff] - git diff string
106
+ * @param {object[]} [opts.tickets] - pre-fetched ticket objects
107
+ * @param {string} [opts.baseBranch] - e.g. 'main'
108
+ * @param {string|null} [opts.headBranch] - current branch name
109
+ * @param {Function} [opts.isLicensedFn] - fn(tier) → bool
110
+ * @param {Function} [opts.analyzeDiffFn] - injectable (tests)
111
+ * @param {Function} [opts.extractRequirementsFn] - injectable (tests)
112
+ * @returns {Promise<string>}
113
+ */
114
+ export async function assemblePrReview({
115
+ diff = null,
116
+ tickets = [],
117
+ baseBranch = 'main',
118
+ headBranch = null,
119
+ isLicensedFn = () => false,
120
+ analyzeDiffFn = analyzeDiff,
121
+ extractRequirementsFn = extractRequirements,
122
+ } = {}) {
123
+ const lines = ['## PR Review Context'];
124
+
125
+ // Branch
126
+ lines.push('', '### Branch');
127
+ if (headBranch) {
128
+ lines.push(`\`${headBranch}\` → \`${baseBranch}\``);
129
+ } else {
130
+ lines.push(`→ \`${baseBranch}\``);
131
+ }
132
+
133
+ // Changed files (from diff)
134
+ const filesSection = buildChangedFilesSection(diff);
135
+ if (filesSection) lines.push(filesSection);
136
+
137
+ if (tickets.length === 0) {
138
+ lines.push('', '_No linked tickets found in branch name or commits._');
139
+ } else {
140
+ // Ticket context
141
+ lines.push('', '### Ticket context');
142
+ for (const ticket of tickets) {
143
+ const reqs = extractRequirementsFn(ticket.description ?? '');
144
+ lines.push('', `#### ${ticket.key}: ${ticket.summary ?? ''}`);
145
+ if (reqs.length === 0) {
146
+ lines.push('_No requirements found._');
147
+ } else {
148
+ for (const req of reqs) lines.push(`- ${req}`);
149
+ }
150
+ }
151
+
152
+ // Requirements coverage (Pro-gated, requires diff)
153
+ if (isLicensedFn('pro') && diff) {
154
+ const coverageOut = buildCoverageSections(tickets, diff, extractRequirementsFn, analyzeDiffFn);
155
+ if (coverageOut) lines.push(coverageOut);
156
+ } else if (!isLicensedFn('pro')) {
157
+ lines.push('', '_Requirements coverage analysis requires a Pro license — `ticketlens activate <KEY>`_');
158
+ }
159
+ }
160
+
161
+ lines.push('', '---', `_Generated by TicketLens · \`ticketlens review --base=${baseBranch}\`_`);
162
+
163
+ return lines.join('\n');
164
+ }