ticketlens 0.9.8 → 0.9.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.9.8",
3
+ "version": "0.9.10",
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": {
@@ -1,4 +1,4 @@
1
- <!-- jtb-skill-version: 0.9.7 -->
1
+ <!-- jtb-skill-version: 0.9.9 -->
2
2
  ---
3
3
  name: jtb
4
4
  description: Fetch a Jira ticket's full context (description, comments, linked issues, code references) and assemble a structured TicketBrief for implementation planning. Use when user types /jtb, mentions a Jira ticket key, or wants to plan work from a Jira ticket.
@@ -3,7 +3,7 @@
3
3
  * Centralised here to avoid triplicating the regex and warning logic.
4
4
  */
5
5
 
6
- export const DEFAULT_API_BASE = 'http://api.ticketlens.test';
6
+ export const DEFAULT_API_BASE = 'https://api.ticketlens.app';
7
7
 
8
8
  // Matches localhost, 127.0.0.1, and any hostname ending in .test or .local,
9
9
  // with an optional port — all treated as local-only addresses.
@@ -83,7 +83,8 @@ export async function downloadAttachments(ticket, opts = {}) {
83
83
  onProgress?.(` download ${a.filename}`);
84
84
  try {
85
85
  const headers = buildAuthHeader(env);
86
- const response = await fetcher(a.content, { headers });
86
+ // redirect:'error' prevents a Jira redirect from leaking auth headers to a different origin.
87
+ const response = await fetcher(a.content, { headers, redirect: 'error' });
87
88
  if (!response.ok) throw new Error(`HTTP ${response.status} (${response.statusText})`);
88
89
  const buffer = await response.arrayBuffer();
89
90
  fs.writeFileSync(localPath, Buffer.from(buffer));
@@ -12,6 +12,7 @@ export function assembleBrief(ticket, codeRefs = null, templateSections = null)
12
12
  sections.push(`# ${ticket.key}: ${ticket.summary}`);
13
13
 
14
14
  const meta = [`**Type:** ${ticket.type}`, `**Status:** ${ticket.status}`, `**Priority:** ${ticket.priority}`, `**Assignee:** ${ticket.assignee ?? 'Unassigned'}`, `**Reporter:** ${ticket.reporter ?? 'Unknown'}`];
15
+ if (ticket.sprint) meta.push(`**Sprint:** ${ticket.sprint}`);
15
16
  if (ticket.created) meta.push(`**Created:** ${ticket.created.split('T')[0]}`);
16
17
  if (ticket.updated) meta.push(`**Updated:** ${ticket.updated.split('T')[0]}`);
17
18
  sections.push(meta.join(' | '));
@@ -19,7 +19,14 @@ export const DEFAULT_BRIEF_TTL = '4h'; // human-readable default for display/con
19
19
  export function briefCachePath(ticketKey, profileName, configDir = DEFAULT_CONFIG_DIR) {
20
20
  const safeProfile = (profileName || '_default').replace(/[^a-zA-Z0-9_\-]/g, '_');
21
21
  const safeKey = ticketKey.replace(/[^a-zA-Z0-9_\-]/g, '_');
22
- return path.join(configDir, 'cache', safeProfile, safeKey, 'brief.json');
22
+ const resolvedDir = path.resolve(configDir);
23
+ const result = path.join(resolvedDir, 'cache', safeProfile, safeKey, 'brief.json');
24
+ // Defense-in-depth: ensure the final path cannot escape the config directory,
25
+ // even if configDir itself is manipulated or the sanitization above is weakened.
26
+ if (!result.startsWith(resolvedDir + path.sep)) {
27
+ throw new Error(`Cache path escapes config directory: ${result}`);
28
+ }
29
+ return result;
23
30
  }
24
31
 
25
32
  /**
@@ -12,6 +12,24 @@ function toText(value) {
12
12
  return adfToText(value);
13
13
  }
14
14
 
15
+ /**
16
+ * Extracts the active sprint name from customfield_10020.
17
+ * Cloud v3: array of sprint objects — prefers active, falls back to last.
18
+ * Server v2: serialized Java string — extracts via name= regex.
19
+ */
20
+ function parseSprint(value) {
21
+ if (!value) return null;
22
+ if (Array.isArray(value)) {
23
+ const sprint = value.find(s => s.state === 'active') ?? value[value.length - 1];
24
+ return sprint?.name ?? null;
25
+ }
26
+ if (typeof value === 'string') {
27
+ const m = value.match(/name=([^,\]]+)/);
28
+ return m ? m[1].trim() : null;
29
+ }
30
+ return null;
31
+ }
32
+
15
33
  /**
16
34
  * Returns the ISO date string when the ticket entered its current status,
17
35
  * derived from Jira's changelog history. Walks backwards (most recent first)
@@ -50,6 +68,7 @@ export function normalizeTicket(raw) {
50
68
  created: f.created ?? null,
51
69
  updated: f.updated ?? null,
52
70
  statusChangedAt,
71
+ sprint: parseSprint(f.customfield_10020),
53
72
  labels: f.labels ?? [],
54
73
  components: (f.components ?? []).map(c => c.name),
55
74
  comments: (f.comment?.comments ?? []).map(c => ({
@@ -81,6 +100,35 @@ export function normalizeTicket(raw) {
81
100
  };
82
101
  }
83
102
 
103
+ // RFC-1918 + link-local + loopback + localhost blocked to prevent SSRF.
104
+ const BLOCKED_PATTERNS = [
105
+ /^127\./,
106
+ /^10\./,
107
+ /^172\.(1[6-9]|2\d|3[01])\./,
108
+ /^192\.168\./,
109
+ /^169\.254\./,
110
+ /^0\.0\.0\.0$/,
111
+ /^::1$/,
112
+ /^::ffff:/i,
113
+ /^fc00:/i,
114
+ /^fe80:/i,
115
+ /^localhost$/i,
116
+ ];
117
+
118
+ export function validateBaseUrl(url) {
119
+ let parsed;
120
+ try { parsed = new URL(url); } catch {
121
+ throw new Error(`JIRA_BASE_URL is not a valid URL: ${url}`);
122
+ }
123
+ if (parsed.protocol !== 'https:') {
124
+ throw new Error(`JIRA_BASE_URL must use HTTPS (got ${parsed.protocol})`);
125
+ }
126
+ const hostname = parsed.hostname.replace(/^\[|\]$/g, '');
127
+ if (BLOCKED_PATTERNS.some(re => re.test(hostname))) {
128
+ throw new Error(`JIRA_BASE_URL hostname is blocked (${hostname})`);
129
+ }
130
+ }
131
+
84
132
  export function buildAuthHeader(env) {
85
133
  if (env.JIRA_PAT) {
86
134
  return { Authorization: `Bearer ${env.JIRA_PAT}` };
@@ -91,6 +139,7 @@ export function buildAuthHeader(env) {
91
139
 
92
140
  export async function fetchCurrentUser(opts = {}) {
93
141
  const { env = process.env, fetcher = globalThis.fetch, apiVersion = 2, timeoutMs = 10_000 } = opts;
142
+ validateBaseUrl(env.JIRA_BASE_URL);
94
143
  const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
95
144
  const headers = { ...buildAuthHeader(env), 'Content-Type': 'application/json' };
96
145
 
@@ -116,6 +165,7 @@ export async function fetchCurrentUser(opts = {}) {
116
165
 
117
166
  export async function fetchStatuses(opts = {}) {
118
167
  const { env = process.env, fetcher = globalThis.fetch, apiVersion = 2, timeoutMs = 10_000 } = opts;
168
+ validateBaseUrl(env.JIRA_BASE_URL);
119
169
  const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
120
170
  const headers = { ...buildAuthHeader(env), 'Content-Type': 'application/json' };
121
171
 
@@ -136,10 +186,11 @@ export async function fetchStatuses(opts = {}) {
136
186
 
137
187
  export async function searchTickets(jql, opts = {}) {
138
188
  const { env = process.env, fetcher = globalThis.fetch, maxResults = 50, apiVersion = 2, timeoutMs = 10_000, expandChangelog = false } = opts;
189
+ validateBaseUrl(env.JIRA_BASE_URL);
139
190
  const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
140
191
  const headers = { ...buildAuthHeader(env), 'Content-Type': 'application/json' };
141
192
 
142
- const fields = 'summary,status,assignee,priority,issuetype,comment,updated,statuscategorychangedate,created';
193
+ const fields = 'summary,status,assignee,priority,issuetype,comment,updated,statuscategorychangedate,created,customfield_10020';
143
194
  const params = new URLSearchParams({ jql, fields, maxResults: String(maxResults) });
144
195
  if (expandChangelog) params.set('expand', 'changelog');
145
196
  const endpoint = apiVersion >= 3 ? `/rest/api/3/search/jql` : `/rest/api/2/search`;
@@ -163,6 +214,7 @@ export async function searchTickets(jql, opts = {}) {
163
214
 
164
215
  export async function fetchRemoteLinks(ticketKey, opts = {}) {
165
216
  const { env = process.env, fetcher = globalThis.fetch, apiVersion = 2, timeoutMs = 10_000 } = opts;
217
+ validateBaseUrl(env.JIRA_BASE_URL);
166
218
  const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
167
219
  const url = `${baseUrl}/rest/api/${apiVersion}/issue/${encodeURIComponent(ticketKey)}/remotelink`;
168
220
 
@@ -180,6 +232,7 @@ export async function fetchRemoteLinks(ticketKey, opts = {}) {
180
232
 
181
233
  export async function fetchTicket(ticketKey, opts = {}) {
182
234
  const { env = process.env, fetcher = globalThis.fetch, depth = 1, apiVersion = 2, timeoutMs = 10_000, expandChangelog = false, _visited = new Set(), _currentDepth = 0 } = opts;
235
+ validateBaseUrl(env.JIRA_BASE_URL);
183
236
  const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
184
237
  const headers = { ...buildAuthHeader(env), 'Content-Type': 'application/json' };
185
238
 
@@ -7,7 +7,7 @@
7
7
  const RE_GWT = /^\s*(given|when|then)\s+(.+)/i;
8
8
  const RE_MUST_ITEM = /^\s*[-*•]\s+((?:must|should|shall|ensure|verify)\b.+|.+(?:must|should|shall|ensure|verify).+)/i;
9
9
  const RE_NUM_MUST = /^\s*\d+\.\s+((?:must|should|shall|ensure|verify)\b.+|.+(?:must|should|shall|ensure|verify).+)/i;
10
- const RE_AC_HEADER = /^\s*#+\s*acceptance criteria\s*$/i;
10
+ const RE_AC_HEADER = /^\s*(?:#+\s*|h[1-6]\.\s*)?acceptance criteria\s*:?\s*$/i;
11
11
  const RE_BULLET = /^\s*[-*•]\s+(.+)/;
12
12
  const RE_NUM_ITEM = /^\s*\d+\.\s+(.+)/;
13
13
 
@@ -114,6 +114,7 @@ export function styleBrief(ticket, codeRefs = null, opts = {}) {
114
114
  `${s.dim('Priority:')} ${ticket.priority}`,
115
115
  `${s.dim('Assignee:')} ${ticket.assignee ?? 'Unassigned'}`,
116
116
  ];
117
+ if (ticket.sprint) meta.push(`${s.dim('Sprint:')} ${ticket.sprint}`);
117
118
  if (ticket.created) meta.push(`${s.dim('Created:')} ${ticket.created.split('T')[0]}`);
118
119
  if (ticket.updated) meta.push(`${s.dim('Updated:')} ${ticket.updated.split('T')[0]}`);
119
120
  sections.push(meta.join(s.dim(' · ')));