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
|
---
|
package/bin/ticketlens.mjs
CHANGED
|
@@ -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
|
@@ -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
|
+
}
|