ticketlens 0.1.13 → 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 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 URL, auth type, connection test
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 URL, auth, live connection test, optional settings |
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, Jira URL (bare hostnames accepted — HTTPS probed first), auth type (auto-detected from URL), 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.
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
- - Console queue dashboard — live at `/console/queue` for Team tier; push snapshots with `ticketlens triage --push`
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
- - Slack/Teams alerts for triage changes (Team)
462
- - GitHub Issues and Linear support
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
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": {
@@ -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],
@@ -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(/&amp;/g, '&')
38
+ .replace(/&lt;/g, '<')
39
+ .replace(/&gt;/g, '>')
40
+ .replace(/&quot;/g, '"')
41
+ .replace(/&#160;|&nbsp;/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 = [