ticketlens 0.1.12 → 0.1.14
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 +9 -7
- package/package.json +1 -1
- package/skills/jtb/scripts/fetch-ticket.mjs +25 -0
- package/skills/jtb/scripts/lib/brief-assembler.mjs +9 -0
- package/skills/jtb/scripts/lib/config-wizard.mjs +10 -19
- package/skills/jtb/scripts/lib/confluence-client.mjs +101 -0
- package/skills/jtb/scripts/lib/jira-client.mjs +17 -0
- package/skills/jtb/scripts/lib/styled-assembler.mjs +10 -0
package/README.md
CHANGED
|
@@ -42,7 +42,7 @@ Zero npm dependencies. Node.js built-ins only.
|
|
|
42
42
|
|
|
43
43
|
```bash
|
|
44
44
|
npm install -g ticketlens
|
|
45
|
-
ticketlens init # Guided setup: Jira
|
|
45
|
+
ticketlens init # Guided setup: Jira, GitHub Issues, or Linear — connection test included
|
|
46
46
|
ticketlens CNV1-2 # Fetch a ticket brief
|
|
47
47
|
ticketlens triage # Scan your assigned tickets
|
|
48
48
|
```
|
|
@@ -70,13 +70,13 @@ npx ticketlens CNV1-2
|
|
|
70
70
|
|
|
71
71
|
| Command | Description |
|
|
72
72
|
|---------|-------------|
|
|
73
|
-
| `ticketlens init` | Guided wizard — Jira
|
|
73
|
+
| `ticketlens init` | Guided wizard — Jira, GitHub Issues, or Linear — live connection test, optional settings |
|
|
74
74
|
| `ticketlens switch` | Arrow-key panel to switch between configured profiles |
|
|
75
|
-
| `ticketlens config [--profile=NAME]` | Edit any field on an existing profile |
|
|
75
|
+
| `ticketlens config [--profile=NAME]` | Edit any field on an existing profile — always re-validates the connection |
|
|
76
76
|
| `ticketlens profiles` | List all configured profiles (alias: `ticketlens ls`) |
|
|
77
77
|
| `ticketlens delete <NAME>` | Remove a profile and its credentials (prompts `y/N` in TTY; use `--yes` in scripts/CI) |
|
|
78
78
|
|
|
79
|
-
`init` collects: profile name,
|
|
79
|
+
`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
80
|
|
|
81
81
|
`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
82
|
|
|
@@ -455,11 +455,13 @@ npm test
|
|
|
455
455
|
See [ROADMAP.md](ROADMAP.md) for the full plan.
|
|
456
456
|
|
|
457
457
|
Recently shipped:
|
|
458
|
-
-
|
|
458
|
+
- **Linear support** — `ticketlens init` → Linear; connects via GraphQL API key, fetches tickets, triage, and statuses
|
|
459
|
+
- **GitHub Issues support** — `ticketlens init` → GitHub Issues; PAT-based, same normalized ticket shape
|
|
460
|
+
- **Tracker-aware config** — `ticketlens config` shows the right labels and skips irrelevant prompts per tracker type; always re-validates the connection
|
|
459
461
|
|
|
460
462
|
Coming up:
|
|
461
|
-
-
|
|
462
|
-
-
|
|
463
|
+
- Confluence/wiki page fetching (F45)
|
|
464
|
+
- "TicketLens for PRs" — PR context assembly with linked tickets and compliance (F46)
|
|
463
465
|
|
|
464
466
|
---
|
|
465
467
|
|
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).
|
|
@@ -566,6 +568,29 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
566
568
|
session.connected();
|
|
567
569
|
process.stderr.write('\n');
|
|
568
570
|
|
|
571
|
+
// ── Confluence page fetching ───────────────────────────────────────────────
|
|
572
|
+
// Fetch pages referenced via Jira Remote Links (Confluence-only, non-blocking).
|
|
573
|
+
// Runs before cache write so cached briefs include the pages.
|
|
574
|
+
ticket.confluencePages = [];
|
|
575
|
+
if (adapter.type === 'jira' && !args.includes('--no-attachments')) {
|
|
576
|
+
try {
|
|
577
|
+
const jiraEnv = buildJiraEnv(conn);
|
|
578
|
+
const authHeader = buildAuthHeader(jiraEnv);
|
|
579
|
+
const apiVersion = conn.auth === 'cloud' ? 3 : 2;
|
|
580
|
+
const jiraOrigin = new URL(jiraEnv.JIRA_BASE_URL).origin;
|
|
581
|
+
const links = await fetchRemoteLinks(ticketKey, { env: jiraEnv, fetcher, apiVersion });
|
|
582
|
+
const MAX_CONFLUENCE_PAGES = 10;
|
|
583
|
+
const safeLinks = links
|
|
584
|
+
.filter(l => { try { return new URL(l.url).origin === jiraOrigin; } catch { return false; } })
|
|
585
|
+
.slice(0, MAX_CONFLUENCE_PAGES);
|
|
586
|
+
if (safeLinks.length > 0) {
|
|
587
|
+
const pages = await Promise.all(safeLinks.map(l => fetchConfluencePage(l.url, authHeader, { fetcher })));
|
|
588
|
+
ticket.confluencePages = pages.filter(Boolean);
|
|
589
|
+
}
|
|
590
|
+
} catch { /* non-fatal — a broken remote links endpoint must not fail the brief */ }
|
|
591
|
+
}
|
|
592
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
593
|
+
|
|
569
594
|
// Save to brief cache for future requests
|
|
570
595
|
if (!noCache) {
|
|
571
596
|
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],
|
|
@@ -152,22 +152,14 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
|
|
|
152
152
|
const tokenHint = existingToken ? s.dim(' [keep existing]') : '';
|
|
153
153
|
const tokenLabel = s.dim(getTokenLabel(trackerType, auth)) + tokenHint + s.dim(':');
|
|
154
154
|
token = await promptSecret(tokenLabel, { stream, existingValue: existingToken });
|
|
155
|
-
const tokenChanged = token !== existingToken;
|
|
156
155
|
|
|
157
|
-
// ── Connection test (
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|| email !== (profile.email || '')
|
|
161
|
-
|| tokenChanged;
|
|
156
|
+
// ── Connection test (always) ──────────────────────────────────────────────
|
|
157
|
+
let connected = false;
|
|
158
|
+
let startFrom = 'test';
|
|
162
159
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
let startFrom = 'test';
|
|
167
|
-
|
|
168
|
-
setupLoop: while (true) {
|
|
169
|
-
// Re-prompt URL + auth (on retry)
|
|
170
|
-
if (startFrom === 'url') {
|
|
160
|
+
setupLoop: while (true) {
|
|
161
|
+
// Re-prompt URL + auth (on retry with 'url' option)
|
|
162
|
+
if (startFrom === 'url') {
|
|
171
163
|
stream.write('\n');
|
|
172
164
|
const reTyped = await promptText(
|
|
173
165
|
s.dim(urlLabel) + s.dim(` [current: ${url}]:`),
|
|
@@ -249,11 +241,10 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
|
|
|
249
241
|
startFrom = RETRY_OPTIONS[retryIdx].value;
|
|
250
242
|
}
|
|
251
243
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
}
|
|
244
|
+
if (!connected) {
|
|
245
|
+
stream.write(`\n ${s.yellow('○')} Connection changes discarded. ${s.dim('Run')} ${s.cyan('ticketlens config')} ${s.dim('again to retry.')}\n\n`);
|
|
246
|
+
process.exitCode = 1;
|
|
247
|
+
return;
|
|
257
248
|
}
|
|
258
249
|
|
|
259
250
|
// ── Optional settings ──────────────────────────────────────────────────────
|
|
@@ -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
|
+
}
|
|
@@ -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(/\/$/, '');
|
|
@@ -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 = [
|