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 +1 -1
- package/skills/jtb/SKILL.md +1 -1
- package/skills/jtb/scripts/lib/api-utils.mjs +1 -1
- package/skills/jtb/scripts/lib/attachment-downloader.mjs +2 -1
- package/skills/jtb/scripts/lib/brief-assembler.mjs +1 -0
- package/skills/jtb/scripts/lib/brief-cache.mjs +8 -1
- package/skills/jtb/scripts/lib/jira-client.mjs +54 -1
- package/skills/jtb/scripts/lib/requirement-extractor.mjs +1 -1
- package/skills/jtb/scripts/lib/styled-assembler.mjs +1 -0
package/package.json
CHANGED
package/skills/jtb/SKILL.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- jtb-skill-version: 0.9.
|
|
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 = '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(' · ')));
|