ticketlens 0.1.13 → 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 +14 -9
- package/bin/ticketlens.mjs +9 -0
- package/package.json +1 -1
- package/skills/jtb/scripts/fetch-ticket.mjs +106 -0
- package/skills/jtb/scripts/lib/brief-assembler.mjs +9 -0
- package/skills/jtb/scripts/lib/confluence-client.mjs +101 -0
- package/skills/jtb/scripts/lib/help.mjs +35 -0
- package/skills/jtb/scripts/lib/jira-client.mjs +17 -0
- package/skills/jtb/scripts/lib/pr-review-assembler.mjs +164 -0
- package/skills/jtb/scripts/lib/styled-assembler.mjs +10 -0
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
|
|
|
@@ -42,7 +43,7 @@ Zero npm dependencies. Node.js built-ins only.
|
|
|
42
43
|
|
|
43
44
|
```bash
|
|
44
45
|
npm install -g ticketlens
|
|
45
|
-
ticketlens init # Guided setup: Jira
|
|
46
|
+
ticketlens init # Guided setup: Jira, GitHub Issues, or Linear — connection test included
|
|
46
47
|
ticketlens CNV1-2 # Fetch a ticket brief
|
|
47
48
|
ticketlens triage # Scan your assigned tickets
|
|
48
49
|
```
|
|
@@ -70,13 +71,13 @@ npx ticketlens CNV1-2
|
|
|
70
71
|
|
|
71
72
|
| Command | Description |
|
|
72
73
|
|---------|-------------|
|
|
73
|
-
| `ticketlens init` | Guided wizard — Jira
|
|
74
|
+
| `ticketlens init` | Guided wizard — Jira, GitHub Issues, or Linear — live connection test, optional settings |
|
|
74
75
|
| `ticketlens switch` | Arrow-key panel to switch between configured profiles |
|
|
75
|
-
| `ticketlens config [--profile=NAME]` | Edit any field on an existing profile |
|
|
76
|
+
| `ticketlens config [--profile=NAME]` | Edit any field on an existing profile — always re-validates the connection |
|
|
76
77
|
| `ticketlens profiles` | List all configured profiles (alias: `ticketlens ls`) |
|
|
77
78
|
| `ticketlens delete <NAME>` | Remove a profile and its credentials (prompts `y/N` in TTY; use `--yes` in scripts/CI) |
|
|
78
79
|
|
|
79
|
-
`init` collects: profile name,
|
|
80
|
+
`init` collects: profile name, tracker type (Jira / GitHub Issues / Linear), URL or workspace, credentials (masked), and optional ticket prefixes, project paths, and triage statuses. On connection failure, a retry menu lets you fix credentials, URL, or skip — all inputs pre-populated. `config` is tracker-aware and always re-validates the connection after edits.
|
|
80
81
|
|
|
81
82
|
`config` uses merge semantics: new ticket prefixes and triage statuses are added to existing lists, never replaced. Partial matching resolves `QA` to `QA Testing` if that's the status in your Jira.
|
|
82
83
|
|
|
@@ -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,11 +458,13 @@ npm test
|
|
|
455
458
|
See [ROADMAP.md](ROADMAP.md) for the full plan.
|
|
456
459
|
|
|
457
460
|
Recently shipped:
|
|
458
|
-
-
|
|
461
|
+
- **Confluence pages** — linked Confluence pages fetched automatically and included in the brief; origin-validated, non-fatal, capped at 10 pages
|
|
462
|
+
- **Linear support** — `ticketlens init` → Linear; connects via GraphQL API key, fetches tickets, triage, and statuses
|
|
463
|
+
- **GitHub Issues support** — `ticketlens init` → GitHub Issues; PAT-based, same normalized ticket shape
|
|
464
|
+
- **Tracker-aware config** — `ticketlens config` shows the right labels and skips irrelevant prompts per tracker type; always re-validates the connection
|
|
459
465
|
|
|
460
466
|
Coming up:
|
|
461
|
-
-
|
|
462
|
-
- GitHub Issues and Linear support
|
|
467
|
+
- "TicketLens for PRs" — PR context assembly with linked tickets and compliance (F46)
|
|
463
468
|
|
|
464
469
|
---
|
|
465
470
|
|
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
|
@@ -28,6 +28,8 @@ import { createStyler } from './lib/ansi.mjs';
|
|
|
28
28
|
import { isLicensed, showUpgradePrompt, readLicense } from './lib/license.mjs';
|
|
29
29
|
import { detectVcs } from './lib/vcs-detector.mjs';
|
|
30
30
|
import { runComplianceCheck } from './lib/compliance-checker.mjs';
|
|
31
|
+
import { fetchRemoteLinks, buildAuthHeader } from './lib/jira-client.mjs';
|
|
32
|
+
import { fetchConfluencePage } from './lib/confluence-client.mjs';
|
|
31
33
|
|
|
32
34
|
/**
|
|
33
35
|
* Get the local diff using spawn with explicit arg arrays (never shell interpolation).
|
|
@@ -340,6 +342,87 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
340
342
|
return;
|
|
341
343
|
}
|
|
342
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
|
+
|
|
343
426
|
const ticketKey = args.find(a => !a.startsWith('--'));
|
|
344
427
|
if (!ticketKey) {
|
|
345
428
|
printFetchHelp({ stream: process.stderr });
|
|
@@ -566,6 +649,29 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
566
649
|
session.connected();
|
|
567
650
|
process.stderr.write('\n');
|
|
568
651
|
|
|
652
|
+
// ── Confluence page fetching ───────────────────────────────────────────────
|
|
653
|
+
// Fetch pages referenced via Jira Remote Links (Confluence-only, non-blocking).
|
|
654
|
+
// Runs before cache write so cached briefs include the pages.
|
|
655
|
+
ticket.confluencePages = [];
|
|
656
|
+
if (adapter.type === 'jira' && !args.includes('--no-attachments')) {
|
|
657
|
+
try {
|
|
658
|
+
const jiraEnv = buildJiraEnv(conn);
|
|
659
|
+
const authHeader = buildAuthHeader(jiraEnv);
|
|
660
|
+
const apiVersion = conn.auth === 'cloud' ? 3 : 2;
|
|
661
|
+
const jiraOrigin = new URL(jiraEnv.JIRA_BASE_URL).origin;
|
|
662
|
+
const links = await fetchRemoteLinks(ticketKey, { env: jiraEnv, fetcher, apiVersion });
|
|
663
|
+
const MAX_CONFLUENCE_PAGES = 10;
|
|
664
|
+
const safeLinks = links
|
|
665
|
+
.filter(l => { try { return new URL(l.url).origin === jiraOrigin; } catch { return false; } })
|
|
666
|
+
.slice(0, MAX_CONFLUENCE_PAGES);
|
|
667
|
+
if (safeLinks.length > 0) {
|
|
668
|
+
const pages = await Promise.all(safeLinks.map(l => fetchConfluencePage(l.url, authHeader, { fetcher })));
|
|
669
|
+
ticket.confluencePages = pages.filter(Boolean);
|
|
670
|
+
}
|
|
671
|
+
} catch { /* non-fatal — a broken remote links endpoint must not fail the brief */ }
|
|
672
|
+
}
|
|
673
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
674
|
+
|
|
569
675
|
// Save to brief cache for future requests
|
|
570
676
|
if (!noCache) {
|
|
571
677
|
writeBriefCache(ticketKey, conn.profileName, depth, ticket, configDir);
|
|
@@ -43,6 +43,15 @@ export function assembleBrief(ticket, codeRefs = null) {
|
|
|
43
43
|
sections.push(`## Linked Tickets\n\n${linkedSections.join('\n\n---\n\n')}`);
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
if (ticket.confluencePages?.length > 0) {
|
|
47
|
+
const pageLines = ticket.confluencePages.map(p => {
|
|
48
|
+
const parts = [`### ${p.title ?? p.url}`];
|
|
49
|
+
if (p.text) parts.push(p.text);
|
|
50
|
+
return parts.join('\n\n');
|
|
51
|
+
});
|
|
52
|
+
sections.push(`## Confluence Pages\n\n${pageLines.join('\n\n---\n\n')}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
46
55
|
if (codeRefs) {
|
|
47
56
|
const categories = [
|
|
48
57
|
['File Paths', codeRefs.filePaths],
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Confluence REST API client.
|
|
3
|
+
* Fetches page content for Confluence pages referenced in Jira remote links.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Extracts the numeric page ID from a Confluence page URL.
|
|
8
|
+
* Supports Cloud (/wiki/spaces/{key}/pages/{id}) and Server (?pageId={id}).
|
|
9
|
+
* Returns null when the ID cannot be determined.
|
|
10
|
+
* @param {string} url
|
|
11
|
+
* @returns {string|null}
|
|
12
|
+
*/
|
|
13
|
+
export function extractConfluencePageId(url) {
|
|
14
|
+
if (!url) return null;
|
|
15
|
+
|
|
16
|
+
const qpMatch = url.match(/[?&]pageId=(\d+)/);
|
|
17
|
+
if (qpMatch) return qpMatch[1];
|
|
18
|
+
|
|
19
|
+
const pathMatch = url.match(/\/wiki\/spaces\/[^/]+\/pages\/(\d+)(?:\/|$)/);
|
|
20
|
+
if (pathMatch) return pathMatch[1];
|
|
21
|
+
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Converts a Confluence view-body HTML string to plain text.
|
|
27
|
+
* Uses simple regex-based transformation — no external dependencies.
|
|
28
|
+
* @param {string} html
|
|
29
|
+
* @returns {string}
|
|
30
|
+
*/
|
|
31
|
+
export function htmlToText(html) {
|
|
32
|
+
if (!html) return '';
|
|
33
|
+
return html
|
|
34
|
+
.replace(/<\/?(p|div|h[1-6]|li|tr|td|th|blockquote|pre)[^>]*>/gi, '\n')
|
|
35
|
+
.replace(/<br\s*\/?>/gi, '\n')
|
|
36
|
+
.replace(/<[^>]+>/g, '')
|
|
37
|
+
.replace(/&/g, '&')
|
|
38
|
+
.replace(/</g, '<')
|
|
39
|
+
.replace(/>/g, '>')
|
|
40
|
+
.replace(/"/g, '"')
|
|
41
|
+
.replace(/ | /g, ' ')
|
|
42
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
43
|
+
.trim();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Determines the Confluence REST API base path from a page URL.
|
|
48
|
+
* Cloud: {origin}/wiki/rest/api/content/{pageId}
|
|
49
|
+
* Server/DC without /wiki/: {origin}{/prefix}/rest/api/content/{pageId}
|
|
50
|
+
* @param {string} pageUrl
|
|
51
|
+
* @param {string} pageId
|
|
52
|
+
* @returns {string}
|
|
53
|
+
*/
|
|
54
|
+
function buildApiUrl(pageUrl, pageId) {
|
|
55
|
+
try {
|
|
56
|
+
const parsed = new URL(pageUrl);
|
|
57
|
+
if (parsed.pathname.includes('/wiki/')) {
|
|
58
|
+
return `${parsed.origin}/wiki/rest/api/content/${pageId}?expand=body.view`;
|
|
59
|
+
}
|
|
60
|
+
const confluencePrefix = parsed.pathname.match(/^(\/confluence)\//);
|
|
61
|
+
const prefix = confluencePrefix ? confluencePrefix[1] : '';
|
|
62
|
+
return `${parsed.origin}${prefix}/rest/api/content/${pageId}?expand=body.view`;
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Fetches a Confluence page and returns its plain-text body.
|
|
70
|
+
* Returns null on any error (non-OK response, network failure, unparseable URL).
|
|
71
|
+
* @param {string} url - The Confluence page URL from the Jira remote link
|
|
72
|
+
* @param {object} authHeader - Authorization header object, e.g. { Authorization: 'Basic ...' }
|
|
73
|
+
* @param {{ fetcher?: Function, timeoutMs?: number }} [opts]
|
|
74
|
+
* @returns {Promise<{url: string, title: string, text: string}|null>}
|
|
75
|
+
*/
|
|
76
|
+
export async function fetchConfluencePage(url, authHeader, opts = {}) {
|
|
77
|
+
const { fetcher = globalThis.fetch, timeoutMs = 10_000 } = opts;
|
|
78
|
+
|
|
79
|
+
const pageId = extractConfluencePageId(url);
|
|
80
|
+
if (!pageId) return null;
|
|
81
|
+
|
|
82
|
+
const apiUrl = buildApiUrl(url, pageId);
|
|
83
|
+
if (!apiUrl) return null;
|
|
84
|
+
|
|
85
|
+
const fetchOpts = { headers: { ...authHeader, 'Content-Type': 'application/json' } };
|
|
86
|
+
if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const response = await fetcher(apiUrl, fetchOpts);
|
|
90
|
+
if (!response.ok) return null;
|
|
91
|
+
|
|
92
|
+
const data = await response.json();
|
|
93
|
+
return {
|
|
94
|
+
url,
|
|
95
|
+
title: data.title ?? null,
|
|
96
|
+
text: htmlToText(data.body?.view?.value ?? ''),
|
|
97
|
+
};
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -131,6 +131,23 @@ export async function searchTickets(jql, opts = {}) {
|
|
|
131
131
|
return (raw.issues ?? []).map(normalizeTicket);
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
export async function fetchRemoteLinks(ticketKey, opts = {}) {
|
|
135
|
+
const { env = process.env, fetcher = globalThis.fetch, apiVersion = 2, timeoutMs = 10_000 } = opts;
|
|
136
|
+
const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
|
|
137
|
+
const url = `${baseUrl}/rest/api/${apiVersion}/issue/${encodeURIComponent(ticketKey)}/remotelink`;
|
|
138
|
+
|
|
139
|
+
const fetchOpts = { headers: { ...buildAuthHeader(env), 'Content-Type': 'application/json' } };
|
|
140
|
+
if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
|
|
141
|
+
|
|
142
|
+
const response = await fetcher(url, fetchOpts);
|
|
143
|
+
if (!response.ok) return [];
|
|
144
|
+
|
|
145
|
+
const raw = await response.json();
|
|
146
|
+
return (raw ?? [])
|
|
147
|
+
.filter(link => link.application?.type === 'com.atlassian.confluence')
|
|
148
|
+
.map(link => ({ url: link.object.url, title: link.object.title ?? null }));
|
|
149
|
+
}
|
|
150
|
+
|
|
134
151
|
export async function fetchTicket(ticketKey, opts = {}) {
|
|
135
152
|
const { env = process.env, fetcher = globalThis.fetch, depth = 1, apiVersion = 2, timeoutMs = 10_000, _visited = new Set(), _currentDepth = 0 } = opts;
|
|
136
153
|
const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
|
|
@@ -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
|
+
}
|
|
@@ -131,6 +131,16 @@ export function styleBrief(ticket, codeRefs = null, opts = {}) {
|
|
|
131
131
|
sections.push(`${s.bold(s.brand('Linked Tickets'))}\n${s.dim('─'.repeat(divWidth()))}\n${linkedSections.join(`\n\n${s.dim('─'.repeat(halfDivWidth()))}\n`)}`);
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
// Confluence pages
|
|
135
|
+
if (ticket.confluencePages?.length > 0) {
|
|
136
|
+
const pageLines = ticket.confluencePages.map(p => {
|
|
137
|
+
const parts = [s.brand(p.title ?? p.url)];
|
|
138
|
+
if (p.text) parts.push(p.text);
|
|
139
|
+
return parts.join('\n\n');
|
|
140
|
+
});
|
|
141
|
+
sections.push(`${s.bold(s.brand('Confluence Pages'))}\n${s.dim('─'.repeat(divWidth()))}\n${pageLines.join(`\n\n${s.dim('─'.repeat(halfDivWidth()))}\n`)}`);
|
|
142
|
+
}
|
|
143
|
+
|
|
134
144
|
// Code references
|
|
135
145
|
if (codeRefs) {
|
|
136
146
|
const categories = [
|