@aholbreich/agent-skills 0.1.0
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/CHANGELOG.md +17 -0
- package/COMPATIBILITY.md +134 -0
- package/CONTRIBUTING.md +53 -0
- package/LICENSE +21 -0
- package/README.md +244 -0
- package/SECURITY.md +42 -0
- package/bin/agent-skills.js +157 -0
- package/package.json +63 -0
- package/skills/confluence-browser-fetch/SKILL.md +114 -0
- package/skills/confluence-browser-fetch/references/distribution.md +110 -0
- package/skills/confluence-browser-fetch/references/usage.md +161 -0
- package/skills/confluence-browser-fetch/scripts/confluence-browser-fetch.js +507 -0
- package/skills/confluence-browser-fetch/scripts/lib.js +83 -0
- package/skills/jira-browser-fetch/SKILL.md +109 -0
- package/skills/jira-browser-fetch/references/distribution.md +124 -0
- package/skills/jira-browser-fetch/references/usage.md +138 -0
- package/skills/jira-browser-fetch/scripts/jira-browser-fetch.js +505 -0
- package/skills/jira-browser-fetch/scripts/lib.js +50 -0
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const fsp = require('fs/promises');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { spawn } = require('child_process');
|
|
9
|
+
const { parseSize, formatBytes, safeName, issueKeysFromText } = require('./lib');
|
|
10
|
+
|
|
11
|
+
function usage() {
|
|
12
|
+
console.log(`Usage: jira-browser-fetch [ISSUE-KEY ...] [options]
|
|
13
|
+
|
|
14
|
+
Fetch Jira issue raw data via an already-authenticated browser session or by launching Chrome for SSO.
|
|
15
|
+
No Jira API token is required. Works well for Microsoft/Okta/SAML SSO setups.
|
|
16
|
+
|
|
17
|
+
Options:
|
|
18
|
+
--server URL Jira base URL (or set JIRA_SERVER), e.g. https://example.atlassian.net
|
|
19
|
+
--raw-dir DIR Output raw directory (default: JIRA_RAW_DIR or ./raw)
|
|
20
|
+
--connected Fetch connected/referenced tickets too
|
|
21
|
+
--depth N Connected fetch depth (default: 1 with --connected, otherwise 0)
|
|
22
|
+
--scan-text Include issue keys found anywhere in issue JSON/XML/HTML text
|
|
23
|
+
--jql JQL Search Jira with JQL and fetch all matching issues
|
|
24
|
+
--assignee-me Fetch all issues assigned to current Jira user
|
|
25
|
+
--max-search-results N Max issues to add per JQL search (default: 1000)
|
|
26
|
+
--max-attachment-size S Skip attachment downloads larger than S (default: 5mb; use unlimited to disable)
|
|
27
|
+
--prefix A,B,C Only fetch referenced keys with these project prefixes
|
|
28
|
+
--wait SEC Wait time for SSO/session per issue (default: 900)
|
|
29
|
+
--port PORT Chrome DevTools port (default: 9223)
|
|
30
|
+
--profile-dir DIR Chrome profile dir (default: ~/.local/share/jira-browser-fetch-chrome)
|
|
31
|
+
--no-attachments Do not download Jira attachments
|
|
32
|
+
--no-html Do not save browser HTML
|
|
33
|
+
--no-xml Do not save Jira XML issue view
|
|
34
|
+
--help Show this help
|
|
35
|
+
|
|
36
|
+
Examples:
|
|
37
|
+
jira-browser-fetch SWING-4770 --raw-dir /path/wiki/raw
|
|
38
|
+
jira-browser-fetch SWING-4770 --connected --prefix SWING,SSD,EC --raw-dir ./raw
|
|
39
|
+
jira-browser-fetch --assignee-me --raw-dir ./raw
|
|
40
|
+
JIRA_SERVER=https://example.atlassian.net jira-browser-fetch PROJ-123 --connected
|
|
41
|
+
`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const opts = {
|
|
45
|
+
server: process.env.JIRA_SERVER || '',
|
|
46
|
+
rawDir: process.env.JIRA_RAW_DIR || path.resolve(process.cwd(), 'raw'),
|
|
47
|
+
port: Number(process.env.JIRA_CHROME_DEBUG_PORT || 9223),
|
|
48
|
+
waitSec: Number(process.env.JIRA_FETCH_WAIT_SEC || 900),
|
|
49
|
+
profileDir: process.env.JIRA_CHROME_PROFILE || path.join(os.homedir(), '.local/share/jira-browser-fetch-chrome'),
|
|
50
|
+
connected: false,
|
|
51
|
+
depth: undefined,
|
|
52
|
+
scanText: false,
|
|
53
|
+
jqls: [],
|
|
54
|
+
assigneeMe: false,
|
|
55
|
+
maxSearchResults: Number(process.env.JIRA_MAX_SEARCH_RESULTS || 1000),
|
|
56
|
+
maxAttachmentBytes: parseSize(process.env.JIRA_MAX_ATTACHMENT_SIZE || process.env.JIRA_MAX_ATTACHMENT_BYTES || '5mb'),
|
|
57
|
+
prefixes: null,
|
|
58
|
+
attachments: true,
|
|
59
|
+
html: true,
|
|
60
|
+
xml: true,
|
|
61
|
+
};
|
|
62
|
+
const issues = [];
|
|
63
|
+
|
|
64
|
+
for (let i = 2; i < process.argv.length; i++) {
|
|
65
|
+
const a = process.argv[i];
|
|
66
|
+
if (a === '-h' || a === '--help') { usage(); process.exit(0); }
|
|
67
|
+
else if (a === '--server') opts.server = process.argv[++i];
|
|
68
|
+
else if (a === '--raw-dir') opts.rawDir = process.argv[++i];
|
|
69
|
+
else if (a === '--connected') opts.connected = true;
|
|
70
|
+
else if (a === '--depth') opts.depth = Number(process.argv[++i]);
|
|
71
|
+
else if (a === '--scan-text') opts.scanText = true;
|
|
72
|
+
else if (a === '--jql') opts.jqls.push(process.argv[++i]);
|
|
73
|
+
else if (a === '--assignee-me') opts.assigneeMe = true;
|
|
74
|
+
else if (a === '--max-search-results') opts.maxSearchResults = Number(process.argv[++i]);
|
|
75
|
+
else if (a === '--max-attachment-size') opts.maxAttachmentBytes = parseSize(process.argv[++i]);
|
|
76
|
+
else if (a === '--prefix') opts.prefixes = new Set(String(process.argv[++i] || '').split(',').map(s => s.trim().toUpperCase()).filter(Boolean));
|
|
77
|
+
else if (a === '--wait') opts.waitSec = Number(process.argv[++i]);
|
|
78
|
+
else if (a === '--port') opts.port = Number(process.argv[++i]);
|
|
79
|
+
else if (a === '--profile-dir') opts.profileDir = process.argv[++i];
|
|
80
|
+
else if (a === '--no-attachments') opts.attachments = false;
|
|
81
|
+
else if (a === '--no-html') opts.html = false;
|
|
82
|
+
else if (a === '--no-xml') opts.xml = false;
|
|
83
|
+
else if (!a.startsWith('-')) issues.push(a.toUpperCase());
|
|
84
|
+
else { console.error(`Unknown argument: ${a}`); process.exit(2); }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!issues.length && !opts.jqls.length && !opts.assigneeMe) { usage(); process.exit(2); }
|
|
88
|
+
if (opts.assigneeMe) opts.jqls.push('assignee = currentUser() ORDER BY updated DESC');
|
|
89
|
+
if (opts.depth === undefined) opts.depth = opts.connected ? 1 : 0;
|
|
90
|
+
if (opts.depth > 0) opts.connected = true;
|
|
91
|
+
opts.server = opts.server.replace(/\/$/, '');
|
|
92
|
+
if (!opts.server) {
|
|
93
|
+
console.error('Missing Jira server. Pass --server https://example.atlassian.net or set JIRA_SERVER.');
|
|
94
|
+
process.exit(2);
|
|
95
|
+
}
|
|
96
|
+
opts.rawDir = path.resolve(opts.rawDir);
|
|
97
|
+
|
|
98
|
+
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
99
|
+
const issueKeyRe = /\b([A-Z][A-Z0-9]+)-(\d+)\b/g;
|
|
100
|
+
|
|
101
|
+
async function endpoint(pathname) {
|
|
102
|
+
const res = await fetch(`http://127.0.0.1:${opts.port}${pathname}`);
|
|
103
|
+
if (!res.ok) throw new Error(`DevTools HTTP ${res.status} for ${pathname}`);
|
|
104
|
+
return res.json();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function devtoolsReady() {
|
|
108
|
+
try { await endpoint('/json/version'); return true; } catch { return false; }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function waitDevtools() {
|
|
112
|
+
for (let i = 0; i < 80; i++) {
|
|
113
|
+
if (await devtoolsReady()) return;
|
|
114
|
+
await sleep(250);
|
|
115
|
+
}
|
|
116
|
+
throw new Error('Chrome DevTools endpoint did not start');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function launchChrome(url) {
|
|
120
|
+
const chrome = process.env.CHROME || '/usr/bin/google-chrome';
|
|
121
|
+
const args = [
|
|
122
|
+
`--remote-debugging-port=${opts.port}`,
|
|
123
|
+
'--remote-debugging-address=127.0.0.1',
|
|
124
|
+
'--remote-allow-origins=*',
|
|
125
|
+
`--user-data-dir=${opts.profileDir}`,
|
|
126
|
+
'--no-first-run',
|
|
127
|
+
'--no-default-browser-check',
|
|
128
|
+
url,
|
|
129
|
+
];
|
|
130
|
+
const child = spawn(chrome, args, { detached: true, stdio: 'ignore' });
|
|
131
|
+
child.unref();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function getPageWsUrl() {
|
|
135
|
+
const list = await endpoint('/json/list');
|
|
136
|
+
const pages = list.filter(t => t.type === 'page' && t.webSocketDebuggerUrl);
|
|
137
|
+
const host = new URL(opts.server).host;
|
|
138
|
+
const preferred = pages.find(t => (t.url || '').includes(host)) || pages[0];
|
|
139
|
+
return preferred && preferred.webSocketDebuggerUrl;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function connectCdp(wsUrl) {
|
|
143
|
+
return new Promise((resolve, reject) => {
|
|
144
|
+
const ws = new WebSocket(wsUrl);
|
|
145
|
+
let id = 0;
|
|
146
|
+
const pending = new Map();
|
|
147
|
+
const failTimer = setTimeout(() => reject(new Error('CDP websocket timeout')), 10000);
|
|
148
|
+
|
|
149
|
+
ws.addEventListener('open', () => {
|
|
150
|
+
clearTimeout(failTimer);
|
|
151
|
+
resolve({
|
|
152
|
+
send(method, params = {}) {
|
|
153
|
+
return new Promise((res, rej) => {
|
|
154
|
+
const msgId = ++id;
|
|
155
|
+
pending.set(msgId, { res, rej });
|
|
156
|
+
ws.send(JSON.stringify({ id: msgId, method, params }));
|
|
157
|
+
});
|
|
158
|
+
},
|
|
159
|
+
close() { try { ws.close(); } catch {} },
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
ws.addEventListener('message', ev => {
|
|
164
|
+
let data = ev.data;
|
|
165
|
+
if (typeof data !== 'string') data = Buffer.from(data).toString('utf8');
|
|
166
|
+
const msg = JSON.parse(data);
|
|
167
|
+
if (!msg.id || !pending.has(msg.id)) return;
|
|
168
|
+
const { res, rej } = pending.get(msg.id);
|
|
169
|
+
pending.delete(msg.id);
|
|
170
|
+
if (msg.error) rej(new Error(`${msg.error.message || 'CDP error'} ${JSON.stringify(msg.error)}`));
|
|
171
|
+
else res(msg.result);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
ws.addEventListener('error', err => reject(err));
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function getCookieHeader() {
|
|
179
|
+
const wsUrl = await getPageWsUrl();
|
|
180
|
+
if (!wsUrl) return '';
|
|
181
|
+
const cdp = await connectCdp(wsUrl);
|
|
182
|
+
try {
|
|
183
|
+
await cdp.send('Network.enable');
|
|
184
|
+
const host = new URL(opts.server).host;
|
|
185
|
+
const result = await cdp.send('Network.getCookies', { urls: [`${opts.server}/`] });
|
|
186
|
+
const cookies = (result.cookies || [])
|
|
187
|
+
.filter(c => c.domain && (c.domain === host || c.domain.endsWith(`.${host}`)))
|
|
188
|
+
.map(c => `${c.name}=${c.value}`);
|
|
189
|
+
return cookies.join('; ');
|
|
190
|
+
} finally {
|
|
191
|
+
cdp.close();
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function fetchText(url, cookie, accept) {
|
|
196
|
+
const res = await fetch(url, {
|
|
197
|
+
redirect: 'follow',
|
|
198
|
+
headers: {
|
|
199
|
+
Cookie: cookie,
|
|
200
|
+
Accept: accept || '*/*',
|
|
201
|
+
'User-Agent': 'jira-browser-fetch/1.0',
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
return { status: res.status, contentType: res.headers.get('content-type') || '', text: await res.text() };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function fetchJson(url, cookie, accept) {
|
|
208
|
+
const result = await fetchText(url, cookie, accept || 'application/json');
|
|
209
|
+
let json = null;
|
|
210
|
+
try { json = JSON.parse(result.text); } catch {}
|
|
211
|
+
return { ...result, json };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function getCookieWithWait(openUrl) {
|
|
215
|
+
await ensureBrowser(openUrl || `${opts.server}/`);
|
|
216
|
+
const deadline = Date.now() + opts.waitSec * 1000;
|
|
217
|
+
let last = '';
|
|
218
|
+
while (Date.now() < deadline) {
|
|
219
|
+
try {
|
|
220
|
+
const cookie = await getCookieHeader();
|
|
221
|
+
if (cookie) return cookie;
|
|
222
|
+
last = 'no Jira cookies yet';
|
|
223
|
+
} catch (e) { last = e.message; }
|
|
224
|
+
process.stdout.write(`\r${new Date().toLocaleTimeString()} ${last.padEnd(120).slice(0, 120)}`);
|
|
225
|
+
await sleep(3000);
|
|
226
|
+
}
|
|
227
|
+
process.stdout.write('\n');
|
|
228
|
+
throw new Error(`Could not get Jira browser cookies. Last result: ${last}`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function searchJql(jql) {
|
|
232
|
+
const searchPageUrl = `${opts.server}/issues/?jql=${encodeURIComponent(jql)}`;
|
|
233
|
+
const cookie = await getCookieWithWait(searchPageUrl);
|
|
234
|
+
const found = [];
|
|
235
|
+
let startAt = 0;
|
|
236
|
+
const pageSize = Math.min(100, Math.max(1, opts.maxSearchResults || 1000));
|
|
237
|
+
|
|
238
|
+
while (found.length < opts.maxSearchResults) {
|
|
239
|
+
const limit = Math.min(pageSize, opts.maxSearchResults - found.length);
|
|
240
|
+
const url = `${opts.server}/rest/api/3/search?jql=${encodeURIComponent(jql)}&fields=key&maxResults=${limit}&startAt=${startAt}`;
|
|
241
|
+
let result = await fetchJson(url, cookie, 'application/json');
|
|
242
|
+
|
|
243
|
+
if (result.status === 410 || result.status === 404 || !result.json || !Array.isArray(result.json.issues)) {
|
|
244
|
+
const newUrl = `${opts.server}/rest/api/3/search/jql?jql=${encodeURIComponent(jql)}&fields=key&maxResults=${limit}`;
|
|
245
|
+
result = await fetchJson(newUrl, cookie, 'application/json');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (result.status !== 200 || !result.json || !Array.isArray(result.json.issues)) {
|
|
249
|
+
throw new Error(`JQL failed HTTP ${result.status}: ${(result.text || '').slice(0, 300)}`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
for (const issue of result.json.issues) if (issue.key) found.push(issue.key);
|
|
253
|
+
if (result.json.isLast === true) break;
|
|
254
|
+
if (typeof result.json.total === 'number' && startAt + result.json.issues.length >= result.json.total) break;
|
|
255
|
+
if (!result.json.issues.length) break;
|
|
256
|
+
startAt += result.json.issues.length;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return [...new Set(found)];
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function addKey(set, key) {
|
|
263
|
+
if (!key) return;
|
|
264
|
+
key = String(key).toUpperCase();
|
|
265
|
+
const m = /^([A-Z][A-Z0-9]+)-\d+$/.exec(key);
|
|
266
|
+
if (!m) return;
|
|
267
|
+
if (opts.prefixes && !opts.prefixes.has(m[1])) return;
|
|
268
|
+
set.add(key);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function scanIssueKeys(text, set) {
|
|
272
|
+
if (!text) return;
|
|
273
|
+
for (const key of issueKeysFromText(text)) addKey(set, key);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function extractConnectedKeys(issueJson, rawTexts) {
|
|
277
|
+
const found = new Set();
|
|
278
|
+
const fields = issueJson && issueJson.fields || {};
|
|
279
|
+
|
|
280
|
+
if (fields.parent) addKey(found, fields.parent.key);
|
|
281
|
+
for (const st of fields.subtasks || []) addKey(found, st.key);
|
|
282
|
+
for (const link of fields.issuelinks || []) {
|
|
283
|
+
if (link.inwardIssue) addKey(found, link.inwardIssue.key);
|
|
284
|
+
if (link.outwardIssue) addKey(found, link.outwardIssue.key);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Jira custom fields often contain Epic Link / parent keys as strings.
|
|
288
|
+
for (const v of Object.values(fields)) {
|
|
289
|
+
if (typeof v === 'string') addKey(found, v);
|
|
290
|
+
else if (v && typeof v === 'object' && typeof v.key === 'string') addKey(found, v.key);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (opts.scanText) {
|
|
294
|
+
for (const text of rawTexts) scanIssueKeys(text, found);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (issueJson && issueJson.key) found.delete(issueJson.key);
|
|
298
|
+
return [...found].sort();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function downloadAttachments(issueJson, cookie, outDir) {
|
|
302
|
+
const attachments = (((issueJson || {}).fields || {}).attachment) || [];
|
|
303
|
+
const attachDir = path.join(outDir, 'attachments');
|
|
304
|
+
await fsp.mkdir(attachDir, { recursive: true });
|
|
305
|
+
const manifest = [];
|
|
306
|
+
|
|
307
|
+
for (const att of attachments) {
|
|
308
|
+
if (!att.content) continue;
|
|
309
|
+
const filename = safeName(att.filename || `${att.id || 'attachment'}.bin`);
|
|
310
|
+
const size = typeof att.size === 'number' ? att.size : Number(att.size);
|
|
311
|
+
const baseEntry = {
|
|
312
|
+
id: att.id,
|
|
313
|
+
filename,
|
|
314
|
+
url: att.content,
|
|
315
|
+
mimeType: att.mimeType,
|
|
316
|
+
size: Number.isFinite(size) ? size : att.size,
|
|
317
|
+
author: att.author && (att.author.displayName || att.author.emailAddress || att.author.accountId),
|
|
318
|
+
created: att.created,
|
|
319
|
+
};
|
|
320
|
+
if (Number.isFinite(size) && size > opts.maxAttachmentBytes) {
|
|
321
|
+
console.log(`Attachment ${filename} ... skipped (${formatBytes(size)} > ${formatBytes(opts.maxAttachmentBytes)})`);
|
|
322
|
+
manifest.push({
|
|
323
|
+
...baseEntry,
|
|
324
|
+
skipped: true,
|
|
325
|
+
reason: 'larger-than-max-attachment-size',
|
|
326
|
+
maxAttachmentBytes: opts.maxAttachmentBytes,
|
|
327
|
+
});
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
const target = path.join(attachDir, filename);
|
|
331
|
+
process.stdout.write(`Attachment ${filename} ... `);
|
|
332
|
+
const res = await fetch(att.content, {
|
|
333
|
+
redirect: 'follow',
|
|
334
|
+
headers: { Cookie: cookie, 'User-Agent': 'jira-browser-fetch/1.0' },
|
|
335
|
+
});
|
|
336
|
+
if (!res.ok) {
|
|
337
|
+
console.log(`HTTP ${res.status}`);
|
|
338
|
+
manifest.push({ ...baseEntry, status: res.status });
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
342
|
+
await fsp.writeFile(target, buf);
|
|
343
|
+
console.log(`${buf.length} bytes`);
|
|
344
|
+
manifest.push({
|
|
345
|
+
...baseEntry,
|
|
346
|
+
path: path.relative(outDir, target),
|
|
347
|
+
downloadedBytes: buf.length,
|
|
348
|
+
status: res.status,
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
await fsp.writeFile(path.join(outDir, 'attachments.json'), JSON.stringify(manifest, null, 2));
|
|
353
|
+
return manifest.length;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function ensureBrowser(browseUrl) {
|
|
357
|
+
if (!(await devtoolsReady())) {
|
|
358
|
+
console.log(`Opening Chrome with reusable profile: ${opts.profileDir}`);
|
|
359
|
+
launchChrome(browseUrl);
|
|
360
|
+
} else {
|
|
361
|
+
console.log(`Reusing Chrome DevTools on port ${opts.port}`);
|
|
362
|
+
}
|
|
363
|
+
await waitDevtools();
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function fetchIssue(issue) {
|
|
367
|
+
const outDir = path.join(opts.rawDir, issue);
|
|
368
|
+
await fsp.mkdir(outDir, { recursive: true });
|
|
369
|
+
|
|
370
|
+
const browseUrl = `${opts.server}/browse/${issue}`;
|
|
371
|
+
const restUrl = `${opts.server}/rest/api/3/issue/${issue}?expand=renderedFields,names,schema,changelog`;
|
|
372
|
+
const remoteLinksUrl = `${opts.server}/rest/api/3/issue/${issue}/remotelink`;
|
|
373
|
+
const xmlUrl = `${opts.server}/si/jira.issueviews:issue-xml/${issue}/${issue}.xml`;
|
|
374
|
+
|
|
375
|
+
await ensureBrowser(browseUrl);
|
|
376
|
+
console.log(`If prompted in Chrome, complete SSO for: ${browseUrl}`);
|
|
377
|
+
console.log(`Waiting up to ${opts.waitSec}s for Jira session...`);
|
|
378
|
+
|
|
379
|
+
const deadline = Date.now() + opts.waitSec * 1000;
|
|
380
|
+
let last = '';
|
|
381
|
+
let cookie = '';
|
|
382
|
+
let rest = null;
|
|
383
|
+
|
|
384
|
+
while (Date.now() < deadline) {
|
|
385
|
+
try {
|
|
386
|
+
cookie = await getCookieHeader();
|
|
387
|
+
if (cookie) {
|
|
388
|
+
rest = await fetchText(restUrl, cookie, 'application/json');
|
|
389
|
+
const body = rest.text || '';
|
|
390
|
+
if (rest.status === 200 && body.includes(`"key":"${issue}"`)) break;
|
|
391
|
+
last = `HTTP ${rest.status} ${body.slice(0, 110).replace(/\s+/g, ' ')}`;
|
|
392
|
+
} else last = 'no Jira cookies yet';
|
|
393
|
+
} catch (e) { last = e.message; }
|
|
394
|
+
process.stdout.write(`\r${new Date().toLocaleTimeString()} ${last.padEnd(120).slice(0, 120)}`);
|
|
395
|
+
await sleep(3000);
|
|
396
|
+
}
|
|
397
|
+
process.stdout.write('\n');
|
|
398
|
+
|
|
399
|
+
if (!rest || rest.status !== 200 || !rest.text.includes(`"key":"${issue}"`)) {
|
|
400
|
+
throw new Error(`Could not fetch ${issue}. Last result: ${last}`);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
await fsp.writeFile(path.join(outDir, 'issue.json'), rest.text);
|
|
404
|
+
console.log(`Saved ${path.join(outDir, 'issue.json')}`);
|
|
405
|
+
|
|
406
|
+
let html = { status: 0, text: '' };
|
|
407
|
+
if (opts.html) {
|
|
408
|
+
html = await fetchText(browseUrl, cookie, 'text/html');
|
|
409
|
+
await fsp.writeFile(path.join(outDir, 'issue.html'), html.text);
|
|
410
|
+
console.log(`Saved ${path.join(outDir, 'issue.html')} (HTTP ${html.status})`);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
let xml = { status: 0, text: '' };
|
|
414
|
+
if (opts.xml) {
|
|
415
|
+
xml = await fetchText(xmlUrl, cookie, 'application/xml,text/xml,text/html');
|
|
416
|
+
await fsp.writeFile(path.join(outDir, 'issue.xml'), xml.text);
|
|
417
|
+
console.log(`Saved ${path.join(outDir, 'issue.xml')} (HTTP ${xml.status})`);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const remoteLinks = await fetchText(remoteLinksUrl, cookie, 'application/json');
|
|
421
|
+
await fsp.writeFile(path.join(outDir, 'remotelinks.json'), remoteLinks.text);
|
|
422
|
+
console.log(`Saved ${path.join(outDir, 'remotelinks.json')} (HTTP ${remoteLinks.status})`);
|
|
423
|
+
|
|
424
|
+
let parsed = null;
|
|
425
|
+
try { parsed = JSON.parse(rest.text); } catch {}
|
|
426
|
+
|
|
427
|
+
const rawTexts = [rest.text, html.text, xml.text, remoteLinks.text];
|
|
428
|
+
const connected = extractConnectedKeys(parsed, rawTexts);
|
|
429
|
+
await fsp.writeFile(path.join(outDir, 'connected-keys.json'), JSON.stringify(connected, null, 2));
|
|
430
|
+
|
|
431
|
+
const meta = {
|
|
432
|
+
fetchedAt: new Date().toISOString(),
|
|
433
|
+
issue,
|
|
434
|
+
source: browseUrl,
|
|
435
|
+
restUrl,
|
|
436
|
+
htmlStatus: html.status,
|
|
437
|
+
xmlStatus: xml.status,
|
|
438
|
+
remoteLinksStatus: remoteLinks.status,
|
|
439
|
+
connectedKeys: connected,
|
|
440
|
+
};
|
|
441
|
+
await fsp.writeFile(path.join(outDir, 'metadata.json'), JSON.stringify(meta, null, 2));
|
|
442
|
+
|
|
443
|
+
let attachmentCount = 0;
|
|
444
|
+
if (opts.attachments) attachmentCount = await downloadAttachments(parsed, cookie, outDir);
|
|
445
|
+
|
|
446
|
+
console.log(`Connected keys: ${connected.join(' ') || '(none)'}`);
|
|
447
|
+
console.log(`Attachments processed: ${attachmentCount}`);
|
|
448
|
+
console.log(`Done: ${outDir}`);
|
|
449
|
+
return connected;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async function main() {
|
|
453
|
+
await fsp.mkdir(opts.rawDir, { recursive: true });
|
|
454
|
+
const queue = issues.map(k => ({ key: k, depth: 0, from: null }));
|
|
455
|
+
const seen = new Set();
|
|
456
|
+
const failed = [];
|
|
457
|
+
const searches = [];
|
|
458
|
+
|
|
459
|
+
for (const jql of opts.jqls) {
|
|
460
|
+
console.log(`\n===== Searching JQL: ${jql} =====`);
|
|
461
|
+
try {
|
|
462
|
+
const keys = await searchJql(jql);
|
|
463
|
+
searches.push({ jql, keys });
|
|
464
|
+
console.log(`JQL matched ${keys.length} issue(s): ${keys.join(' ') || '(none)'}`);
|
|
465
|
+
for (const key of keys) {
|
|
466
|
+
if (!queue.some(q => q.key === key)) queue.push({ key, depth: 0, from: `JQL: ${jql}` });
|
|
467
|
+
}
|
|
468
|
+
} catch (e) {
|
|
469
|
+
failed.push({ key: `JQL: ${jql}`, error: e.message });
|
|
470
|
+
console.error(`JQL FAILED: ${e.message}`);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
for (let idx = 0; idx < queue.length; idx++) {
|
|
475
|
+
const item = queue[idx];
|
|
476
|
+
if (seen.has(item.key)) continue;
|
|
477
|
+
seen.add(item.key);
|
|
478
|
+
console.log(`\n===== Fetching ${item.key}${item.from ? ` (referenced by ${item.from})` : ''} =====`);
|
|
479
|
+
try {
|
|
480
|
+
const connected = await fetchIssue(item.key);
|
|
481
|
+
if (opts.connected && item.depth < opts.depth) {
|
|
482
|
+
for (const key of connected) {
|
|
483
|
+
if (!seen.has(key) && !queue.some(q => q.key === key)) queue.push({ key, depth: item.depth + 1, from: item.key });
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
} catch (e) {
|
|
487
|
+
failed.push({ key: item.key, error: e.message });
|
|
488
|
+
console.error(`SKIPPED/FAILED ${item.key}: ${e.message}`);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const runMeta = { fetchedAt: new Date().toISOString(), server: opts.server, rawDir: opts.rawDir, requested: issues, searches, fetched: [...seen].filter(k => !failed.some(f => f.key === k)), failed };
|
|
493
|
+
await fsp.writeFile(path.join(opts.rawDir, 'jira-browser-fetch-run.json'), JSON.stringify(runMeta, null, 2));
|
|
494
|
+
|
|
495
|
+
if (failed.length) {
|
|
496
|
+
console.error(`\nCompleted with ${failed.length} failed issue(s). See ${path.join(opts.rawDir, 'jira-browser-fetch-run.json')}`);
|
|
497
|
+
} else {
|
|
498
|
+
console.log(`\nCompleted successfully. See ${path.join(opts.rawDir, 'jira-browser-fetch-run.json')}`);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
main().catch(err => {
|
|
503
|
+
console.error(`\nERROR: ${err.stack || err.message}`);
|
|
504
|
+
process.exit(1);
|
|
505
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024;
|
|
4
|
+
|
|
5
|
+
function parseSize(value) {
|
|
6
|
+
if (value === undefined || value === null || value === '') return DEFAULT_MAX_ATTACHMENT_BYTES;
|
|
7
|
+
const s = String(value).trim().toLowerCase();
|
|
8
|
+
if (['unlimited', 'infinite', 'inf', 'none', 'no-limit'].includes(s)) return Infinity;
|
|
9
|
+
const m = s.match(/^([0-9]+(?:\.[0-9]+)?)\s*(b|bytes?|k|kb|kib|m|mb|mib|g|gb|gib)?$/);
|
|
10
|
+
if (!m) throw new Error(`Invalid size: ${value}`);
|
|
11
|
+
const n = Number(m[1]);
|
|
12
|
+
const unit = m[2] || 'b';
|
|
13
|
+
const factor = unit.startsWith('g') ? 1024 ** 3 : unit.startsWith('m') ? 1024 ** 2 : unit.startsWith('k') ? 1024 : 1;
|
|
14
|
+
return Math.floor(n * factor);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function formatBytes(n) {
|
|
18
|
+
if (n === Infinity) return 'unlimited';
|
|
19
|
+
if (!Number.isFinite(n)) return String(n);
|
|
20
|
+
if (n >= 1024 ** 3) return `${(n / 1024 ** 3).toFixed(1)} GiB`;
|
|
21
|
+
if (n >= 1024 ** 2) return `${(n / 1024 ** 2).toFixed(1)} MiB`;
|
|
22
|
+
if (n >= 1024) return `${(n / 1024).toFixed(1)} KiB`;
|
|
23
|
+
return `${n} B`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function safeName(name) {
|
|
27
|
+
return String(name || 'attachment').replace(/[\\/\0]/g, '_').replace(/^\.+$/, '_');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function issueKeysFromText(text) {
|
|
31
|
+
if (!text) return [];
|
|
32
|
+
const found = [];
|
|
33
|
+
const re = /\b([A-Z][A-Z0-9]+)-(\d+)\b/g;
|
|
34
|
+
for (const m of String(text).matchAll(re)) found.push(`${m[1]}-${m[2]}`);
|
|
35
|
+
return [...new Set(found)];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function shouldSkipAttachment(size, maxAttachmentBytes) {
|
|
39
|
+
const n = typeof size === 'number' ? size : Number(size);
|
|
40
|
+
return Number.isFinite(n) && n > maxAttachmentBytes;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = {
|
|
44
|
+
DEFAULT_MAX_ATTACHMENT_BYTES,
|
|
45
|
+
parseSize,
|
|
46
|
+
formatBytes,
|
|
47
|
+
safeName,
|
|
48
|
+
issueKeysFromText,
|
|
49
|
+
shouldSkipAttachment,
|
|
50
|
+
};
|