ticketlens 0.10.2 → 0.10.4
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
package/skills/jtb/SKILL.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- jtb-skill-version: 0.10.
|
|
1
|
+
<!-- jtb-skill-version: 0.10.4 -->
|
|
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.
|
|
@@ -6,24 +6,43 @@
|
|
|
6
6
|
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import path from 'node:path';
|
|
9
|
-
import { buildAuthHeader } from './jira-client.mjs';
|
|
9
|
+
import { buildAuthHeader, isSafeRedirectUrl, validateResolvedHost, defaultLookupFor } from './jira-client.mjs';
|
|
10
10
|
import { DEFAULT_CONFIG_DIR } from './config.mjs';
|
|
11
11
|
const MAX_ATTACHMENTS = 20;
|
|
12
12
|
const MAX_FILE_BYTES = 10 * 1024 * 1024; // 10 MB
|
|
13
|
+
const MAX_REDIRECT_HOPS = 2;
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
15
|
+
/**
|
|
16
|
+
* DNS-validates the initial URL, then follows any redirect chain manually,
|
|
17
|
+
* re-validating (blocklist + DNS) EVERY hop — not just the first.
|
|
18
|
+
* `redirect:'follow'` on the first hop's target would let that host issue a
|
|
19
|
+
* second, unvalidated redirect straight through (audit 2026-07-07 §4.1
|
|
20
|
+
* code-review remediation). Auth headers are only ever sent on the initial
|
|
21
|
+
* request — every hop after that is auth-less, matching Jira Cloud's
|
|
22
|
+
* presigned-URL CDN redirect model.
|
|
23
|
+
*/
|
|
24
|
+
async function followValidatedRedirects(initialUrl, fetcher, lookup, headers) {
|
|
25
|
+
let url = initialUrl;
|
|
26
|
+
await validateResolvedHost(new URL(url).hostname, lookup);
|
|
27
|
+
let response = await fetcher(url, { headers, redirect: 'manual' });
|
|
28
|
+
let hops = 0;
|
|
29
|
+
while (response.status >= 300 && response.status < 400) {
|
|
30
|
+
hops++;
|
|
31
|
+
if (hops > MAX_REDIRECT_HOPS) {
|
|
32
|
+
throw new Error(`Too many redirects (>${MAX_REDIRECT_HOPS}) following ${initialUrl}`);
|
|
33
|
+
}
|
|
34
|
+
const location = response.headers?.get?.('location');
|
|
35
|
+
if (!location) throw new Error('Redirect without Location header');
|
|
36
|
+
const redirectUrl = new URL(location, url).href;
|
|
37
|
+
if (!isSafeRedirectUrl(redirectUrl)) {
|
|
38
|
+
throw new Error(`Redirect to blocked host (${new URL(redirectUrl).hostname}) rejected`);
|
|
39
|
+
}
|
|
40
|
+
await validateResolvedHost(new URL(redirectUrl).hostname, lookup);
|
|
41
|
+
url = redirectUrl;
|
|
42
|
+
// Safe CDN/S3 redirect — follow without auth (presigned URLs are self-authorizing)
|
|
43
|
+
response = await fetcher(url, { redirect: 'manual' });
|
|
44
|
+
}
|
|
45
|
+
return response;
|
|
27
46
|
}
|
|
28
47
|
|
|
29
48
|
/**
|
|
@@ -41,6 +60,7 @@ export async function downloadAttachments(ticket, opts = {}) {
|
|
|
41
60
|
const {
|
|
42
61
|
env = process.env,
|
|
43
62
|
fetcher = globalThis.fetch,
|
|
63
|
+
lookup = defaultLookupFor(fetcher),
|
|
44
64
|
configDir = DEFAULT_CONFIG_DIR,
|
|
45
65
|
noCache = false,
|
|
46
66
|
onProgress = null,
|
|
@@ -98,23 +118,7 @@ export async function downloadAttachments(ticket, opts = {}) {
|
|
|
98
118
|
onProgress?.(` download ${a.filename}`);
|
|
99
119
|
try {
|
|
100
120
|
const headers = buildAuthHeader(env);
|
|
101
|
-
|
|
102
|
-
// Validates the redirect target against BLOCKED_REDIRECT_PATTERNS so a
|
|
103
|
-
// Jira-origin URL that redirects to a private IP (e.g. 169.254.x.x) is rejected.
|
|
104
|
-
const probe = await fetcher(a.content, { headers, redirect: 'manual' });
|
|
105
|
-
let response;
|
|
106
|
-
if (probe.status >= 300 && probe.status < 400) {
|
|
107
|
-
const location = probe.headers?.get?.('location');
|
|
108
|
-
if (!location) throw new Error('Redirect without Location header');
|
|
109
|
-
const redirectUrl = new URL(location, a.content).href;
|
|
110
|
-
if (!isSafeRedirectUrl(redirectUrl)) {
|
|
111
|
-
throw new Error(`Redirect to blocked host (${new URL(redirectUrl).hostname}) rejected`);
|
|
112
|
-
}
|
|
113
|
-
// Safe CDN/S3 redirect — follow without auth (presigned URLs are self-authorizing)
|
|
114
|
-
response = await fetcher(redirectUrl, { redirect: 'follow' });
|
|
115
|
-
} else {
|
|
116
|
-
response = probe;
|
|
117
|
-
}
|
|
121
|
+
const response = await followValidatedRedirects(a.content, fetcher, lookup, headers);
|
|
118
122
|
if (!response.ok) throw new Error(`HTTP ${response.status} (${response.statusText})`);
|
|
119
123
|
const buffer = await response.arrayBuffer();
|
|
120
124
|
fs.writeFileSync(localPath, Buffer.from(buffer));
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { adfToText } from './adf-converter.mjs';
|
|
8
|
+
import { lookup as dnsLookup } from 'node:dns/promises';
|
|
8
9
|
|
|
9
10
|
function toText(value) {
|
|
10
11
|
if (value == null) return null;
|
|
@@ -100,21 +101,30 @@ export function normalizeTicket(raw) {
|
|
|
100
101
|
};
|
|
101
102
|
}
|
|
102
103
|
|
|
103
|
-
// RFC-1918 + link-local + loopback + localhost
|
|
104
|
+
// RFC-1918 + link-local + loopback + localhost + IPv6 ULA/link-local + CGN NAT
|
|
105
|
+
// blocked to prevent SSRF. Each pattern matches the FULL CIDR range, not a
|
|
106
|
+
// single literal value — a narrower match (e.g. exact "fc00:") lets addresses
|
|
107
|
+
// elsewhere in the same reserved block sail through (audit 2026-07-07 §4.1
|
|
108
|
+
// code-review remediation).
|
|
104
109
|
const BLOCKED_PATTERNS = [
|
|
105
|
-
/^127\./,
|
|
106
|
-
/^10\./,
|
|
107
|
-
/^172\.(1[6-9]|2\d|3[01])\./,
|
|
108
|
-
/^192\.168\./,
|
|
109
|
-
/^169\.254\./,
|
|
110
|
-
/^0
|
|
111
|
-
|
|
112
|
-
/^::
|
|
113
|
-
|
|
114
|
-
/^
|
|
110
|
+
/^127\./, // 127.0.0.0/8 loopback
|
|
111
|
+
/^10\./, // 10.0.0.0/8 RFC1918
|
|
112
|
+
/^172\.(1[6-9]|2\d|3[01])\./, // 172.16.0.0/12 RFC1918
|
|
113
|
+
/^192\.168\./, // 192.168.0.0/16 RFC1918
|
|
114
|
+
/^169\.254\./, // 169.254.0.0/16 link-local + cloud metadata
|
|
115
|
+
/^0\./, // 0.0.0.0/8 "this network"
|
|
116
|
+
/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./, // 100.64.0.0/10 Carrier-Grade NAT
|
|
117
|
+
/^::1$/, // IPv6 loopback
|
|
118
|
+
/^::ffff:/i, // IPv4-mapped IPv6
|
|
119
|
+
/^f[cd][0-9a-f]{2}:/i, // fc00::/7 Unique Local Address (covers real-world fd00::/8)
|
|
120
|
+
/^fe[89ab][0-9a-f]:/i, // fe80::/10 link-local
|
|
115
121
|
/^localhost$/i,
|
|
116
122
|
];
|
|
117
123
|
|
|
124
|
+
function normalizeHostname(hostname) {
|
|
125
|
+
return hostname.replace(/^\[|\]$/g, '').replace(/\.$/, '');
|
|
126
|
+
}
|
|
127
|
+
|
|
118
128
|
export function validateBaseUrl(url) {
|
|
119
129
|
let parsed;
|
|
120
130
|
try { parsed = new URL(url); } catch {
|
|
@@ -123,12 +133,112 @@ export function validateBaseUrl(url) {
|
|
|
123
133
|
if (parsed.protocol !== 'https:') {
|
|
124
134
|
throw new Error(`JIRA_BASE_URL must use HTTPS (got ${parsed.protocol})`);
|
|
125
135
|
}
|
|
126
|
-
const hostname = parsed.hostname
|
|
136
|
+
const hostname = normalizeHostname(parsed.hostname);
|
|
127
137
|
if (BLOCKED_PATTERNS.some(re => re.test(hostname))) {
|
|
128
138
|
throw new Error(`JIRA_BASE_URL hostname is blocked (${hostname})`);
|
|
129
139
|
}
|
|
130
140
|
}
|
|
131
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Validates a redirect target URL: HTTPS + hostname not in the private/internal
|
|
144
|
+
* blocklist. Shared by attachment-downloader (CDN redirect follow) — the API
|
|
145
|
+
* client itself never follows redirects (see guardedFetch).
|
|
146
|
+
*/
|
|
147
|
+
export function isSafeRedirectUrl(url) {
|
|
148
|
+
let parsed;
|
|
149
|
+
try { parsed = new URL(url); } catch { return false; }
|
|
150
|
+
if (parsed.protocol !== 'https:') return false;
|
|
151
|
+
const hostname = normalizeHostname(parsed.hostname);
|
|
152
|
+
return !BLOCKED_PATTERNS.some(re => re.test(hostname));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Shared default for the `lookup` opt across every call site: the real DNS
|
|
157
|
+
* resolver only when the caller is using the real fetch (production), null
|
|
158
|
+
* for mocked fetchers (tests — no socket opens, nothing to protect).
|
|
159
|
+
*/
|
|
160
|
+
export function defaultLookupFor(fetcher) {
|
|
161
|
+
return fetcher === globalThis.fetch ? dnsLookup : null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Per-lookup-fn DNS validation cache: WeakMap<lookupFn, Map<hostname, Promise<void>>>.
|
|
165
|
+
// Keying by the lookup function isolates test stubs from each other and from the
|
|
166
|
+
// real resolver, with zero reset hooks needed. Entries are deleted once their
|
|
167
|
+
// promise settles (see below) — this dedupes only genuinely concurrent lookups,
|
|
168
|
+
// never sequential ones.
|
|
169
|
+
const dnsValidationCaches = new WeakMap();
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* DNS-rebinding guard: resolves `hostname` and rejects if ANY returned address
|
|
173
|
+
* matches BLOCKED_PATTERNS. The upfront validateBaseUrl check only sees the
|
|
174
|
+
* hostname *string*; a passing hostname can still resolve to 127.0.0.1 or
|
|
175
|
+
* 169.254.169.254 at connect time. Fail-closed: resolver errors propagate.
|
|
176
|
+
*
|
|
177
|
+
* Cache entries are deleted as soon as their promise settles, so the cache
|
|
178
|
+
* only dedupes calls that are truly in flight at the same instant (e.g. a
|
|
179
|
+
* Promise.all fan-out over linked tickets) — it never lets a sequential call
|
|
180
|
+
* reuse a stale verdict. A cache with no expiry would hand a DNS-rebinding
|
|
181
|
+
* attacker the whole CLI run's wall-clock duration to flip the record after
|
|
182
|
+
* the first (validated) resolution; this way each sequential call re-resolves.
|
|
183
|
+
*
|
|
184
|
+
* Known residual: TOCTOU between this lookup and fetch's own resolution within
|
|
185
|
+
* a SINGLE call — a TTL-0 rebinder can still race that one connection. Full
|
|
186
|
+
* IP-pinning needs a custom undici Agent, which would break the zero-dependency
|
|
187
|
+
* constraint. This check raises the bar; it does not eliminate a single-call race.
|
|
188
|
+
*
|
|
189
|
+
* `lookup` is null when the fetcher is mocked (tests) — no socket opens, so
|
|
190
|
+
* there is nothing to protect. Production always uses globalThis.fetch, which
|
|
191
|
+
* always gets the real resolver (see defaultLookupFor).
|
|
192
|
+
*/
|
|
193
|
+
export async function validateResolvedHost(hostname, lookup) {
|
|
194
|
+
if (!lookup) return;
|
|
195
|
+
let cache = dnsValidationCaches.get(lookup);
|
|
196
|
+
if (!cache) {
|
|
197
|
+
cache = new Map();
|
|
198
|
+
dnsValidationCaches.set(lookup, cache);
|
|
199
|
+
}
|
|
200
|
+
if (!cache.has(hostname)) {
|
|
201
|
+
const validation = (async () => {
|
|
202
|
+
const addresses = await lookup(hostname, { all: true });
|
|
203
|
+
for (const { address } of addresses) {
|
|
204
|
+
if (BLOCKED_PATTERNS.some(re => re.test(address))) {
|
|
205
|
+
throw new Error(`Hostname ${hostname} resolves to a blocked address (${address}) — refusing to connect`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
})();
|
|
209
|
+
validation.catch(() => {}).finally(() => cache.delete(hostname));
|
|
210
|
+
cache.set(hostname, validation);
|
|
211
|
+
}
|
|
212
|
+
return cache.get(hostname);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Single enforcement point for all Jira API calls:
|
|
217
|
+
* 1. DNS-validate the target host (when a resolver is available).
|
|
218
|
+
* 2. Force redirect:'manual' — call sites cannot forget or override it.
|
|
219
|
+
* 3. Refuse ANY 3xx. A redirect on a REST endpoint means JIRA_BASE_URL is
|
|
220
|
+
* misconfigured, not normal Jira behavior. Refusing (instead of following)
|
|
221
|
+
* means credentials can never cross a redirect — this deliberately keeps
|
|
222
|
+
* fetch's own auth-strip protection irrelevant rather than overriding it.
|
|
223
|
+
* Error message carries the redirect target's HOSTNAME only — Location values
|
|
224
|
+
* can contain presigned tokens in path/query.
|
|
225
|
+
*/
|
|
226
|
+
export async function guardedFetch(url, fetchOpts, { fetcher, lookup }) {
|
|
227
|
+
await validateResolvedHost(new URL(url).hostname, lookup);
|
|
228
|
+
const response = await fetcher(url, { ...fetchOpts, redirect: 'manual' });
|
|
229
|
+
if (response.status >= 300 && response.status < 400) {
|
|
230
|
+
let target = 'unknown host';
|
|
231
|
+
const location = response.headers?.get?.('location');
|
|
232
|
+
if (location) {
|
|
233
|
+
try { target = new URL(location, url).hostname; } catch {}
|
|
234
|
+
}
|
|
235
|
+
const err = new Error(`Jira API redirected to ${target} — refusing to follow; check JIRA_BASE_URL`);
|
|
236
|
+
err.status = response.status;
|
|
237
|
+
throw err;
|
|
238
|
+
}
|
|
239
|
+
return response;
|
|
240
|
+
}
|
|
241
|
+
|
|
132
242
|
export function buildAuthHeader(env) {
|
|
133
243
|
if (env.JIRA_PAT) {
|
|
134
244
|
return { Authorization: `Bearer ${env.JIRA_PAT}` };
|
|
@@ -138,7 +248,7 @@ export function buildAuthHeader(env) {
|
|
|
138
248
|
}
|
|
139
249
|
|
|
140
250
|
export async function fetchCurrentUser(opts = {}) {
|
|
141
|
-
const { env = process.env, fetcher = globalThis.fetch, apiVersion = 2, timeoutMs = 10_000 } = opts;
|
|
251
|
+
const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000 } = opts;
|
|
142
252
|
validateBaseUrl(env.JIRA_BASE_URL);
|
|
143
253
|
const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
|
|
144
254
|
const headers = { ...buildAuthHeader(env), 'Content-Type': 'application/json' };
|
|
@@ -146,7 +256,7 @@ export async function fetchCurrentUser(opts = {}) {
|
|
|
146
256
|
const url = `${baseUrl}/rest/api/${apiVersion}/myself`;
|
|
147
257
|
const fetchOpts = { headers };
|
|
148
258
|
if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
|
|
149
|
-
const response = await
|
|
259
|
+
const response = await guardedFetch(url, fetchOpts, { fetcher, lookup });
|
|
150
260
|
|
|
151
261
|
if (!response.ok) {
|
|
152
262
|
const err = new Error(`Jira API error ${response.status} fetching current user`);
|
|
@@ -164,7 +274,7 @@ export async function fetchCurrentUser(opts = {}) {
|
|
|
164
274
|
}
|
|
165
275
|
|
|
166
276
|
export async function fetchStatuses(opts = {}) {
|
|
167
|
-
const { env = process.env, fetcher = globalThis.fetch, apiVersion = 2, timeoutMs = 10_000 } = opts;
|
|
277
|
+
const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000 } = opts;
|
|
168
278
|
validateBaseUrl(env.JIRA_BASE_URL);
|
|
169
279
|
const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
|
|
170
280
|
const headers = { ...buildAuthHeader(env), 'Content-Type': 'application/json' };
|
|
@@ -172,7 +282,7 @@ export async function fetchStatuses(opts = {}) {
|
|
|
172
282
|
const url = `${baseUrl}/rest/api/${apiVersion}/status`;
|
|
173
283
|
const fetchOpts = { headers };
|
|
174
284
|
if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
|
|
175
|
-
const response = await
|
|
285
|
+
const response = await guardedFetch(url, fetchOpts, { fetcher, lookup });
|
|
176
286
|
|
|
177
287
|
if (!response.ok) {
|
|
178
288
|
const err = new Error(`Jira API error ${response.status} fetching statuses`);
|
|
@@ -185,7 +295,7 @@ export async function fetchStatuses(opts = {}) {
|
|
|
185
295
|
}
|
|
186
296
|
|
|
187
297
|
export async function searchTickets(jql, opts = {}) {
|
|
188
|
-
const { env = process.env, fetcher = globalThis.fetch, maxResults = 50, apiVersion = 2, timeoutMs = 10_000, expandChangelog = false } = opts;
|
|
298
|
+
const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), maxResults = 50, apiVersion = 2, timeoutMs = 10_000, expandChangelog = false } = opts;
|
|
189
299
|
validateBaseUrl(env.JIRA_BASE_URL);
|
|
190
300
|
const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
|
|
191
301
|
const headers = { ...buildAuthHeader(env), 'Content-Type': 'application/json' };
|
|
@@ -197,7 +307,7 @@ export async function searchTickets(jql, opts = {}) {
|
|
|
197
307
|
const url = `${baseUrl}${endpoint}?${params}`;
|
|
198
308
|
const fetchOpts = { headers };
|
|
199
309
|
if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
|
|
200
|
-
const response = await
|
|
310
|
+
const response = await guardedFetch(url, fetchOpts, { fetcher, lookup });
|
|
201
311
|
|
|
202
312
|
if (!response.ok) {
|
|
203
313
|
let detail = '';
|
|
@@ -213,7 +323,7 @@ export async function searchTickets(jql, opts = {}) {
|
|
|
213
323
|
}
|
|
214
324
|
|
|
215
325
|
export async function fetchRemoteLinks(ticketKey, opts = {}) {
|
|
216
|
-
const { env = process.env, fetcher = globalThis.fetch, apiVersion = 2, timeoutMs = 10_000 } = opts;
|
|
326
|
+
const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000 } = opts;
|
|
217
327
|
validateBaseUrl(env.JIRA_BASE_URL);
|
|
218
328
|
const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
|
|
219
329
|
const url = `${baseUrl}/rest/api/${apiVersion}/issue/${encodeURIComponent(ticketKey)}/remotelink`;
|
|
@@ -221,7 +331,7 @@ export async function fetchRemoteLinks(ticketKey, opts = {}) {
|
|
|
221
331
|
const fetchOpts = { headers: { ...buildAuthHeader(env), 'Content-Type': 'application/json' } };
|
|
222
332
|
if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
|
|
223
333
|
|
|
224
|
-
const response = await
|
|
334
|
+
const response = await guardedFetch(url, fetchOpts, { fetcher, lookup });
|
|
225
335
|
if (!response.ok) return [];
|
|
226
336
|
|
|
227
337
|
const raw = await response.json();
|
|
@@ -231,7 +341,7 @@ export async function fetchRemoteLinks(ticketKey, opts = {}) {
|
|
|
231
341
|
}
|
|
232
342
|
|
|
233
343
|
export async function fetchTicket(ticketKey, opts = {}) {
|
|
234
|
-
const { env = process.env, fetcher = globalThis.fetch, depth = 1, apiVersion = 2, timeoutMs = 10_000, expandChangelog = false, _visited = new Set(), _currentDepth = 0 } = opts;
|
|
344
|
+
const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), depth = 1, apiVersion = 2, timeoutMs = 10_000, expandChangelog = false, _visited = new Set(), _currentDepth = 0 } = opts;
|
|
235
345
|
validateBaseUrl(env.JIRA_BASE_URL);
|
|
236
346
|
const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
|
|
237
347
|
const headers = { ...buildAuthHeader(env), 'Content-Type': 'application/json' };
|
|
@@ -240,7 +350,7 @@ export async function fetchTicket(ticketKey, opts = {}) {
|
|
|
240
350
|
const url = `${baseUrl}/rest/api/${apiVersion}/issue/${encodeURIComponent(ticketKey)}${expand}`;
|
|
241
351
|
const fetchOpts = { headers };
|
|
242
352
|
if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
|
|
243
|
-
const response = await
|
|
353
|
+
const response = await guardedFetch(url, fetchOpts, { fetcher, lookup });
|
|
244
354
|
|
|
245
355
|
if (!response.ok) {
|
|
246
356
|
const err = new Error(`Jira API error ${response.status} fetching ${ticketKey}`);
|