ticketlens 0.9.7 → 0.9.9
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-cache.mjs +8 -1
- package/skills/jtb/scripts/lib/help.mjs +13 -10
- package/skills/jtb/scripts/lib/jira-client.mjs +34 -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));
|
|
@@ -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
|
/**
|
|
@@ -24,8 +24,11 @@ export function printHelp({ stream = process.stdout } = {}) {
|
|
|
24
24
|
'',
|
|
25
25
|
` ${s.bold('USAGE')}`,
|
|
26
26
|
'',
|
|
27
|
-
//
|
|
28
|
-
//
|
|
27
|
+
// Alignment targets (visible chars before description):
|
|
28
|
+
// Groups 1–2 (auth/setup): column 40 (command portion ≤ 24)
|
|
29
|
+
// Group 3 (daily use): column 42 (longest: "review [--branch=BRANCH]" = 39)
|
|
30
|
+
// Group 4 (account/maintenance): column 43 (longest: "schedule [--stop|--status]" = 41)
|
|
31
|
+
// cloud-keys [add|remove|list|test] is an outlier at column 50 — kept with 2-space minimum.
|
|
29
32
|
` ${s.brand('ticketlens')} login Connect CLI to your TicketLens account`,
|
|
30
33
|
` ${s.brand('ticketlens')} logout Remove stored credentials`,
|
|
31
34
|
` ${s.brand('ticketlens')} sync Pull tracker profiles from the console`,
|
|
@@ -35,9 +38,9 @@ export function printHelp({ stream = process.stdout } = {}) {
|
|
|
35
38
|
` ${s.brand('ticketlens')} config ${s.dim('[--profile=NAME]')} Edit profile settings`,
|
|
36
39
|
` ${s.brand('ticketlens')} profiles List all configured profiles ${s.dim('(alias: ls)')}`,
|
|
37
40
|
'',
|
|
38
|
-
` ${s.brand('ticketlens')} ${s.dim('<TICKET-KEY>')} ${s.dim('[options]')}
|
|
39
|
-
` ${s.brand('ticketlens')} get ${s.dim('<TICKET-KEY>')}
|
|
40
|
-
` ${s.brand('ticketlens')} triage ${s.dim('[options]')}
|
|
41
|
+
` ${s.brand('ticketlens')} ${s.dim('<TICKET-KEY>')} ${s.dim('[options]')} Fetch a ticket brief`,
|
|
42
|
+
` ${s.brand('ticketlens')} get ${s.dim('<TICKET-KEY>')} Same as above ${s.dim('(explicit alias)')}`,
|
|
43
|
+
` ${s.brand('ticketlens')} triage ${s.dim('[options]')} Scan your assigned tickets`,
|
|
41
44
|
` ${s.brand('ticketlens')} collisions ${s.dim('[--json]')} Show branch collisions with teammates ${s.dim('[Team]')}`,
|
|
42
45
|
` ${s.brand('ticketlens')} review ${s.dim('[--branch=BRANCH]')} Code-review context brief from current branch`,
|
|
43
46
|
` ${s.brand('ticketlens')} standup ${s.dim('[--since=N]')} Standup summary from git log ${s.dim('(last 24h by default)')}`,
|
|
@@ -45,11 +48,11 @@ export function printHelp({ stream = process.stdout } = {}) {
|
|
|
45
48
|
` ${s.brand('ticketlens')} history ${s.dim('<TICKET-KEY>')} Urgency timeline for a ticket ${s.dim('[Pro]')}`,
|
|
46
49
|
` ${s.brand('ticketlens')} stats ${s.dim('[options]')} Personal response-time metrics from local history`,
|
|
47
50
|
'',
|
|
48
|
-
` ${s.brand('ticketlens')} delete ${s.dim('<PROFILE-NAME>')}
|
|
49
|
-
` ${s.brand('ticketlens')} activate ${s.dim('<KEY>')}
|
|
50
|
-
` ${s.brand('ticketlens')} license
|
|
51
|
-
` ${s.brand('ticketlens')} cache ${s.dim('[size|clear]')}
|
|
52
|
-
` ${s.brand('ticketlens')} schedule ${s.dim('[--stop|--status]')}
|
|
51
|
+
` ${s.brand('ticketlens')} delete ${s.dim('<PROFILE-NAME>')} Remove a profile`,
|
|
52
|
+
` ${s.brand('ticketlens')} activate ${s.dim('<KEY>')} Activate a license key`,
|
|
53
|
+
` ${s.brand('ticketlens')} license Show license status`,
|
|
54
|
+
` ${s.brand('ticketlens')} cache ${s.dim('[size|clear]')} Manage attachment cache ${s.dim('(try cache --help)')}`,
|
|
55
|
+
` ${s.brand('ticketlens')} schedule ${s.dim('[--stop|--status]')} Manage digest schedule ${s.dim('[Pro]')}`,
|
|
53
56
|
` ${s.brand('ticketlens')} cloud-keys ${s.dim('[add|remove|list|test]')} Manage your encrypted AI provider keys ${s.dim('[Pro]')}`,
|
|
54
57
|
` ${s.brand('ticketlens')} update-skill ${s.dim('[--dry-run]')} Update /jtb skill in Claude Code and other AI assistants`,
|
|
55
58
|
'',
|
|
@@ -81,6 +81,35 @@ export function normalizeTicket(raw) {
|
|
|
81
81
|
};
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
// RFC-1918 + link-local + loopback + localhost blocked to prevent SSRF.
|
|
85
|
+
const BLOCKED_PATTERNS = [
|
|
86
|
+
/^127\./,
|
|
87
|
+
/^10\./,
|
|
88
|
+
/^172\.(1[6-9]|2\d|3[01])\./,
|
|
89
|
+
/^192\.168\./,
|
|
90
|
+
/^169\.254\./,
|
|
91
|
+
/^0\.0\.0\.0$/,
|
|
92
|
+
/^::1$/,
|
|
93
|
+
/^::ffff:/i,
|
|
94
|
+
/^fc00:/i,
|
|
95
|
+
/^fe80:/i,
|
|
96
|
+
/^localhost$/i,
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
export function validateBaseUrl(url) {
|
|
100
|
+
let parsed;
|
|
101
|
+
try { parsed = new URL(url); } catch {
|
|
102
|
+
throw new Error(`JIRA_BASE_URL is not a valid URL: ${url}`);
|
|
103
|
+
}
|
|
104
|
+
if (parsed.protocol !== 'https:') {
|
|
105
|
+
throw new Error(`JIRA_BASE_URL must use HTTPS (got ${parsed.protocol})`);
|
|
106
|
+
}
|
|
107
|
+
const hostname = parsed.hostname.replace(/^\[|\]$/g, '');
|
|
108
|
+
if (BLOCKED_PATTERNS.some(re => re.test(hostname))) {
|
|
109
|
+
throw new Error(`JIRA_BASE_URL hostname is blocked (${hostname})`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
84
113
|
export function buildAuthHeader(env) {
|
|
85
114
|
if (env.JIRA_PAT) {
|
|
86
115
|
return { Authorization: `Bearer ${env.JIRA_PAT}` };
|
|
@@ -91,6 +120,7 @@ export function buildAuthHeader(env) {
|
|
|
91
120
|
|
|
92
121
|
export async function fetchCurrentUser(opts = {}) {
|
|
93
122
|
const { env = process.env, fetcher = globalThis.fetch, apiVersion = 2, timeoutMs = 10_000 } = opts;
|
|
123
|
+
validateBaseUrl(env.JIRA_BASE_URL);
|
|
94
124
|
const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
|
|
95
125
|
const headers = { ...buildAuthHeader(env), 'Content-Type': 'application/json' };
|
|
96
126
|
|
|
@@ -116,6 +146,7 @@ export async function fetchCurrentUser(opts = {}) {
|
|
|
116
146
|
|
|
117
147
|
export async function fetchStatuses(opts = {}) {
|
|
118
148
|
const { env = process.env, fetcher = globalThis.fetch, apiVersion = 2, timeoutMs = 10_000 } = opts;
|
|
149
|
+
validateBaseUrl(env.JIRA_BASE_URL);
|
|
119
150
|
const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
|
|
120
151
|
const headers = { ...buildAuthHeader(env), 'Content-Type': 'application/json' };
|
|
121
152
|
|
|
@@ -136,6 +167,7 @@ export async function fetchStatuses(opts = {}) {
|
|
|
136
167
|
|
|
137
168
|
export async function searchTickets(jql, opts = {}) {
|
|
138
169
|
const { env = process.env, fetcher = globalThis.fetch, maxResults = 50, apiVersion = 2, timeoutMs = 10_000, expandChangelog = false } = opts;
|
|
170
|
+
validateBaseUrl(env.JIRA_BASE_URL);
|
|
139
171
|
const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
|
|
140
172
|
const headers = { ...buildAuthHeader(env), 'Content-Type': 'application/json' };
|
|
141
173
|
|
|
@@ -163,6 +195,7 @@ export async function searchTickets(jql, opts = {}) {
|
|
|
163
195
|
|
|
164
196
|
export async function fetchRemoteLinks(ticketKey, opts = {}) {
|
|
165
197
|
const { env = process.env, fetcher = globalThis.fetch, apiVersion = 2, timeoutMs = 10_000 } = opts;
|
|
198
|
+
validateBaseUrl(env.JIRA_BASE_URL);
|
|
166
199
|
const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
|
|
167
200
|
const url = `${baseUrl}/rest/api/${apiVersion}/issue/${encodeURIComponent(ticketKey)}/remotelink`;
|
|
168
201
|
|
|
@@ -180,6 +213,7 @@ export async function fetchRemoteLinks(ticketKey, opts = {}) {
|
|
|
180
213
|
|
|
181
214
|
export async function fetchTicket(ticketKey, opts = {}) {
|
|
182
215
|
const { env = process.env, fetcher = globalThis.fetch, depth = 1, apiVersion = 2, timeoutMs = 10_000, expandChangelog = false, _visited = new Set(), _currentDepth = 0 } = opts;
|
|
216
|
+
validateBaseUrl(env.JIRA_BASE_URL);
|
|
183
217
|
const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
|
|
184
218
|
const headers = { ...buildAuthHeader(env), 'Content-Type': 'application/json' };
|
|
185
219
|
|