@aholbreich/agent-skills 0.8.0 → 1.0.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 +20 -0
- package/README.md +64 -2
- package/SECURITY.md +4 -4
- package/bin/vendor.js +24 -0
- package/package.json +7 -3
- package/skills/bitbucket-browser-fetch/SKILL.md +66 -0
- package/skills/bitbucket-browser-fetch/references/distribution.md +25 -0
- package/skills/bitbucket-browser-fetch/references/usage.md +84 -0
- package/skills/bitbucket-browser-fetch/scripts/atlassian-browser.js +261 -0
- package/skills/bitbucket-browser-fetch/scripts/bitbucket-browser-fetch.js +163 -0
- package/skills/bitbucket-browser-fetch/scripts/lib.js +113 -0
- package/skills/confluence-browser-fetch/scripts/atlassian-browser.js +261 -0
- package/skills/confluence-browser-fetch/scripts/confluence-browser-fetch.js +15 -210
- package/skills/confluence-update/scripts/atlassian-browser.js +261 -0
- package/skills/confluence-update/scripts/confluence-update.js +31 -224
- package/skills/jira-browser-fetch/scripts/atlassian-browser.js +261 -0
- package/skills/jira-browser-fetch/scripts/jira-browser-fetch.js +27 -230
- package/skills/jira-update/SKILL.md +121 -0
- package/skills/jira-update/references/distribution.md +5 -0
- package/skills/jira-update/references/usage.md +75 -0
- package/skills/jira-update/scripts/atlassian-browser.js +261 -0
- package/skills/jira-update/scripts/jira-update.js +404 -0
- package/skills/jira-update/scripts/lib.js +283 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { spawn } = require('node:child_process');
|
|
6
|
+
|
|
7
|
+
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
8
|
+
|
|
9
|
+
function isExecutable(file) {
|
|
10
|
+
try { fs.accessSync(file, fs.constants.X_OK); return true; } catch { return false; }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function resolveBrowserCandidate(candidate) {
|
|
14
|
+
if (!candidate) return null;
|
|
15
|
+
if (candidate.includes(path.sep)) return isExecutable(candidate) ? candidate : null;
|
|
16
|
+
for (const dir of String(process.env.PATH || '').split(path.delimiter)) {
|
|
17
|
+
if (!dir) continue;
|
|
18
|
+
const full = path.join(dir, candidate);
|
|
19
|
+
if (isExecutable(full)) return full;
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function findBrowserExecutable() {
|
|
25
|
+
const candidates = [
|
|
26
|
+
process.env.CHROME,
|
|
27
|
+
process.env.CHROMIUM,
|
|
28
|
+
'google-chrome',
|
|
29
|
+
'google-chrome-stable',
|
|
30
|
+
'chromium',
|
|
31
|
+
'chromium-browser',
|
|
32
|
+
'brave-browser',
|
|
33
|
+
'brave',
|
|
34
|
+
'microsoft-edge',
|
|
35
|
+
'microsoft-edge-stable',
|
|
36
|
+
'vivaldi',
|
|
37
|
+
'vivaldi-stable',
|
|
38
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
39
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
40
|
+
'/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
|
|
41
|
+
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
|
42
|
+
'/Applications/Vivaldi.app/Contents/MacOS/Vivaldi',
|
|
43
|
+
];
|
|
44
|
+
for (const candidate of candidates) {
|
|
45
|
+
const resolved = resolveBrowserCandidate(candidate);
|
|
46
|
+
if (resolved) return resolved;
|
|
47
|
+
}
|
|
48
|
+
throw new Error('Could not find a Chromium-compatible browser. Install Chrome/Chromium/Brave/Edge/Vivaldi or set CHROME=/path/to/browser.');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function connectCdp(wsUrl) {
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
const ws = new WebSocket(wsUrl);
|
|
54
|
+
let id = 0;
|
|
55
|
+
const pending = new Map();
|
|
56
|
+
const failTimer = setTimeout(() => reject(new Error('CDP websocket timeout')), 10000);
|
|
57
|
+
|
|
58
|
+
ws.addEventListener('open', () => {
|
|
59
|
+
clearTimeout(failTimer);
|
|
60
|
+
resolve({
|
|
61
|
+
send(method, params = {}) {
|
|
62
|
+
return new Promise((res, rej) => {
|
|
63
|
+
const msgId = ++id;
|
|
64
|
+
pending.set(msgId, { res, rej });
|
|
65
|
+
ws.send(JSON.stringify({ id: msgId, method, params }));
|
|
66
|
+
});
|
|
67
|
+
},
|
|
68
|
+
close() { try { ws.close(); } catch {} },
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
ws.addEventListener('message', ev => {
|
|
73
|
+
let data = ev.data;
|
|
74
|
+
if (typeof data !== 'string') data = Buffer.from(data).toString('utf8');
|
|
75
|
+
const msg = JSON.parse(data);
|
|
76
|
+
if (!msg.id || !pending.has(msg.id)) return;
|
|
77
|
+
const { res, rej } = pending.get(msg.id);
|
|
78
|
+
pending.delete(msg.id);
|
|
79
|
+
if (msg.error) rej(new Error(`${msg.error.message || 'CDP error'} ${JSON.stringify(msg.error)}`));
|
|
80
|
+
else res(msg.result);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
ws.addEventListener('error', err => reject(err));
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function createBrowserSession({ port, profileDir, waitSec, serverHost, verifySession, cookieUrls, userAgent }) {
|
|
88
|
+
if (!serverHost) throw new Error('createBrowserSession requires serverHost');
|
|
89
|
+
if (typeof verifySession !== 'function') throw new Error('createBrowserSession requires verifySession callback');
|
|
90
|
+
const ua = userAgent || 'agent-skills/1.0';
|
|
91
|
+
|
|
92
|
+
async function endpoint(pathname) {
|
|
93
|
+
const res = await fetch(`http://127.0.0.1:${port}${pathname}`);
|
|
94
|
+
if (!res.ok) throw new Error(`DevTools HTTP ${res.status} for ${pathname}`);
|
|
95
|
+
return res.json();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function devtoolsReady() {
|
|
99
|
+
try { await endpoint('/json/version'); return true; } catch { return false; }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function waitDevtools() {
|
|
103
|
+
for (let i = 0; i < 80; i++) {
|
|
104
|
+
if (await devtoolsReady()) return;
|
|
105
|
+
await sleep(250);
|
|
106
|
+
}
|
|
107
|
+
throw new Error('Chrome DevTools endpoint did not start');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function openDevtoolsTab(url) {
|
|
111
|
+
if (!url) return false;
|
|
112
|
+
const endpointUrl = `http://127.0.0.1:${port}/json/new?${encodeURIComponent(url)}`;
|
|
113
|
+
for (const init of [{ method: 'PUT' }, {}]) {
|
|
114
|
+
try {
|
|
115
|
+
const res = await fetch(endpointUrl, init);
|
|
116
|
+
if (res.ok) { await sleep(500); return true; }
|
|
117
|
+
} catch {}
|
|
118
|
+
}
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function hasDevtoolsTabForHost(url, pathPrefix) {
|
|
123
|
+
if (!url) return false;
|
|
124
|
+
const host = new URL(url).host;
|
|
125
|
+
const list = await endpoint('/json/list');
|
|
126
|
+
return list.some(t => t.type === 'page' && t.url && (() => {
|
|
127
|
+
try {
|
|
128
|
+
const tabUrl = new URL(t.url);
|
|
129
|
+
if (tabUrl.host !== host) return false;
|
|
130
|
+
if (pathPrefix && !tabUrl.pathname.startsWith(pathPrefix)) return false;
|
|
131
|
+
return true;
|
|
132
|
+
} catch { return false; }
|
|
133
|
+
})());
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function launchChrome(url) {
|
|
137
|
+
const browser = findBrowserExecutable();
|
|
138
|
+
const args = [
|
|
139
|
+
`--remote-debugging-port=${port}`,
|
|
140
|
+
'--remote-debugging-address=127.0.0.1',
|
|
141
|
+
'--remote-allow-origins=*',
|
|
142
|
+
`--user-data-dir=${profileDir}`,
|
|
143
|
+
'--no-first-run',
|
|
144
|
+
'--no-default-browser-check',
|
|
145
|
+
url,
|
|
146
|
+
];
|
|
147
|
+
console.log(`Launching browser: ${browser}`);
|
|
148
|
+
const child = spawn(browser, args, { detached: true, stdio: 'ignore' });
|
|
149
|
+
child.on('error', err => console.error(`Failed to launch browser ${browser}: ${err.message}`));
|
|
150
|
+
child.unref();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function ensureBrowser(openUrl, { tabPathPrefix } = {}) {
|
|
154
|
+
if (!(await devtoolsReady())) {
|
|
155
|
+
console.log(`Opening Chromium-compatible browser with reusable profile: ${profileDir}`);
|
|
156
|
+
launchChrome(openUrl);
|
|
157
|
+
} else {
|
|
158
|
+
console.log(`Reusing Chrome DevTools on port ${port}`);
|
|
159
|
+
if (openUrl) {
|
|
160
|
+
const hasTab = await hasDevtoolsTabForHost(openUrl, tabPathPrefix);
|
|
161
|
+
if (hasTab) {
|
|
162
|
+
console.log(`Found existing tab for ${new URL(openUrl).host}; not opening another tab.`);
|
|
163
|
+
} else {
|
|
164
|
+
const opened = await openDevtoolsTab(openUrl);
|
|
165
|
+
if (opened) console.log(`Opened target URL in reused browser: ${openUrl}`);
|
|
166
|
+
else console.warn('Could not open target URL through DevTools; continuing with existing tabs.');
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
await waitDevtools();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function getPageWsUrl() {
|
|
174
|
+
const list = await endpoint('/json/list');
|
|
175
|
+
const pages = list.filter(t => t.type === 'page' && t.webSocketDebuggerUrl);
|
|
176
|
+
const preferred = pages.find(t => (t.url || '').includes(serverHost)) || pages[0];
|
|
177
|
+
return preferred && preferred.webSocketDebuggerUrl;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function getCookieHeader() {
|
|
181
|
+
const wsUrl = await getPageWsUrl();
|
|
182
|
+
if (!wsUrl) return '';
|
|
183
|
+
const cdp = await connectCdp(wsUrl);
|
|
184
|
+
try {
|
|
185
|
+
await cdp.send('Network.enable');
|
|
186
|
+
const urls = (cookieUrls && cookieUrls.length) ? cookieUrls : [`https://${serverHost}/`];
|
|
187
|
+
const result = await cdp.send('Network.getCookies', { urls });
|
|
188
|
+
const cookies = (result.cookies || [])
|
|
189
|
+
.filter(c => c.domain && (c.domain === serverHost || c.domain.endsWith(`.${serverHost}`)))
|
|
190
|
+
.map(c => `${c.name}=${c.value}`);
|
|
191
|
+
return cookies.join('; ');
|
|
192
|
+
} finally {
|
|
193
|
+
cdp.close();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function fetchText(url, cookie, options = {}) {
|
|
198
|
+
const method = options.method || 'GET';
|
|
199
|
+
const headers = {
|
|
200
|
+
Cookie: cookie,
|
|
201
|
+
Accept: options.accept || '*/*',
|
|
202
|
+
'User-Agent': ua,
|
|
203
|
+
};
|
|
204
|
+
if (options.body !== undefined && options.body !== null) headers['Content-Type'] = options.contentType || 'application/json';
|
|
205
|
+
const res = await fetch(url, { method, redirect: 'follow', headers, body: options.body ?? null });
|
|
206
|
+
return { status: res.status, contentType: res.headers.get('content-type') || '', text: await res.text() };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function fetchJson(url, cookie, options = {}) {
|
|
210
|
+
const result = await fetchText(url, cookie, { ...options, accept: options.accept || 'application/json' });
|
|
211
|
+
let json = null;
|
|
212
|
+
try { json = JSON.parse(result.text); } catch {}
|
|
213
|
+
return { ...result, json };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function getCookieWithWait(openUrl, { tabPathPrefix } = {}) {
|
|
217
|
+
await ensureBrowser(openUrl, { tabPathPrefix });
|
|
218
|
+
console.log(`If prompted in Chrome, complete SSO for: ${openUrl}`);
|
|
219
|
+
const deadline = Date.now() + waitSec * 1000;
|
|
220
|
+
let last = '';
|
|
221
|
+
while (Date.now() < deadline) {
|
|
222
|
+
try {
|
|
223
|
+
const cookie = await getCookieHeader();
|
|
224
|
+
const result = await verifySession(cookie);
|
|
225
|
+
if (result && result.ok) {
|
|
226
|
+
if (process.stdout.isTTY) process.stdout.write('\n');
|
|
227
|
+
console.log(`Authenticated session verified${result.url ? ` via ${result.url}` : ''}`);
|
|
228
|
+
return cookie;
|
|
229
|
+
}
|
|
230
|
+
last = (result && result.message) || 'session not yet verified';
|
|
231
|
+
} catch (e) { last = e.message; }
|
|
232
|
+
if (process.stdout.isTTY) {
|
|
233
|
+
process.stdout.write(`\r${new Date().toLocaleTimeString()} ${last.padEnd(120).slice(0, 120)}`);
|
|
234
|
+
}
|
|
235
|
+
await sleep(3000);
|
|
236
|
+
}
|
|
237
|
+
if (process.stdout.isTTY) process.stdout.write('\n');
|
|
238
|
+
throw new Error(`Could not verify authenticated session. Last result: ${last}`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
devtoolsReady,
|
|
243
|
+
waitDevtools,
|
|
244
|
+
openDevtoolsTab,
|
|
245
|
+
hasDevtoolsTabForHost,
|
|
246
|
+
launchChrome,
|
|
247
|
+
ensureBrowser,
|
|
248
|
+
getPageWsUrl,
|
|
249
|
+
getCookieHeader,
|
|
250
|
+
getCookieWithWait,
|
|
251
|
+
fetchText,
|
|
252
|
+
fetchJson,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
module.exports = {
|
|
257
|
+
createBrowserSession,
|
|
258
|
+
findBrowserExecutable,
|
|
259
|
+
resolveBrowserCandidate,
|
|
260
|
+
connectCdp,
|
|
261
|
+
};
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fsp = require('node:fs/promises');
|
|
5
|
+
const os = require('node:os');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
const { createBrowserSession } = require('./atlassian-browser');
|
|
8
|
+
const lib = require('./lib');
|
|
9
|
+
|
|
10
|
+
function topUsage() {
|
|
11
|
+
console.log(`Usage: jira-update <command> [options]
|
|
12
|
+
|
|
13
|
+
Commands:
|
|
14
|
+
create Create a new issue from a JSON manifest
|
|
15
|
+
comment ISSUE-KEY Add a comment
|
|
16
|
+
transition ISSUE-KEY Move through workflow
|
|
17
|
+
update-fields ISSUE-KEY Partial field update
|
|
18
|
+
link FROM-KEY Link two issues
|
|
19
|
+
|
|
20
|
+
Run "jira-update <command> --help" for command-specific options.
|
|
21
|
+
Dry-run is the default; --apply is required to write.
|
|
22
|
+
|
|
23
|
+
Common options:
|
|
24
|
+
--server URL Jira base URL (or JIRA_SERVER), e.g. https://example.atlassian.net
|
|
25
|
+
--raw-dir DIR Audit directory (default: ./raw)
|
|
26
|
+
--apply Actually write to Jira
|
|
27
|
+
--message TEXT Annotate the local audit record
|
|
28
|
+
--wait SEC Wait time for SSO/session (default: 900)
|
|
29
|
+
--port PORT Chrome DevTools port (default: 9225 or ATLASSIAN_CHROME_DEBUG_PORT)
|
|
30
|
+
--profile-dir DIR Chrome profile dir
|
|
31
|
+
`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const opts = {
|
|
35
|
+
command: '',
|
|
36
|
+
issueKey: '',
|
|
37
|
+
server: process.env.JIRA_SERVER || '',
|
|
38
|
+
rawDir: process.env.JIRA_UPDATE_RAW_DIR || process.env.JIRA_RAW_DIR || path.resolve(process.cwd(), 'raw'),
|
|
39
|
+
port: Number(process.env.JIRA_CHROME_DEBUG_PORT || process.env.ATLASSIAN_CHROME_DEBUG_PORT || 9225),
|
|
40
|
+
waitSec: Number(process.env.JIRA_UPDATE_WAIT_SEC || 900),
|
|
41
|
+
profileDir: process.env.JIRA_CHROME_PROFILE || process.env.ATLASSIAN_CHROME_PROFILE || path.join(os.homedir(), '.local/share/jira-browser-fetch-chrome'),
|
|
42
|
+
file: '',
|
|
43
|
+
representation: 'markdown',
|
|
44
|
+
apply: false,
|
|
45
|
+
message: '',
|
|
46
|
+
to: '',
|
|
47
|
+
toId: '',
|
|
48
|
+
commentFile: '',
|
|
49
|
+
fieldOverrides: {},
|
|
50
|
+
linkType: '',
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const args = process.argv.slice(2);
|
|
54
|
+
if (!args.length || args[0] === '-h' || args[0] === '--help') { topUsage(); process.exit(0); }
|
|
55
|
+
|
|
56
|
+
opts.command = args.shift();
|
|
57
|
+
if (!['create', 'comment', 'transition', 'update-fields', 'link'].includes(opts.command)) {
|
|
58
|
+
console.error(`Unknown command: ${opts.command}`);
|
|
59
|
+
topUsage();
|
|
60
|
+
process.exit(2);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (['comment', 'transition', 'update-fields', 'link'].includes(opts.command)) {
|
|
64
|
+
if (!args.length || args[0].startsWith('-')) {
|
|
65
|
+
console.error(`${opts.command} requires an issue key as the first argument.`);
|
|
66
|
+
process.exit(2);
|
|
67
|
+
}
|
|
68
|
+
opts.issueKey = args.shift();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
for (let i = 0; i < args.length; i++) {
|
|
72
|
+
const a = args[i];
|
|
73
|
+
if (a === '--server') opts.server = args[++i];
|
|
74
|
+
else if (a === '--raw-dir') opts.rawDir = args[++i];
|
|
75
|
+
else if (a === '--file') opts.file = args[++i];
|
|
76
|
+
else if (a === '--representation') opts.representation = args[++i];
|
|
77
|
+
else if (a === '--apply') opts.apply = true;
|
|
78
|
+
else if (a === '--message') opts.message = args[++i];
|
|
79
|
+
else if (a === '--wait') opts.waitSec = Number(args[++i]);
|
|
80
|
+
else if (a === '--port') opts.port = Number(args[++i]);
|
|
81
|
+
else if (a === '--profile-dir') opts.profileDir = args[++i];
|
|
82
|
+
else if (a === '--to') opts.to = args[++i];
|
|
83
|
+
else if (a === '--to-id') opts.toId = args[++i];
|
|
84
|
+
else if (a === '--comment-file') opts.commentFile = args[++i];
|
|
85
|
+
else if (a === '--field') {
|
|
86
|
+
const kv = args[++i] || '';
|
|
87
|
+
const eq = kv.indexOf('=');
|
|
88
|
+
if (eq === -1) { console.error(`--field expects key=value, got: ${kv}`); process.exit(2); }
|
|
89
|
+
opts.fieldOverrides[kv.slice(0, eq)] = kv.slice(eq + 1);
|
|
90
|
+
}
|
|
91
|
+
else if (a === '--type') opts.linkType = args[++i];
|
|
92
|
+
else { console.error(`Unknown argument: ${a}`); process.exit(2); }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
opts.server = opts.server.replace(/\/$/, '');
|
|
96
|
+
opts.rawDir = path.resolve(opts.rawDir);
|
|
97
|
+
|
|
98
|
+
if (!opts.server) {
|
|
99
|
+
console.error('Missing Jira server. Pass --server https://example.atlassian.net or set JIRA_SERVER.');
|
|
100
|
+
process.exit(2);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let session = null;
|
|
104
|
+
function getSession() {
|
|
105
|
+
if (session) return session;
|
|
106
|
+
session = createBrowserSession({
|
|
107
|
+
port: opts.port,
|
|
108
|
+
profileDir: opts.profileDir,
|
|
109
|
+
waitSec: opts.waitSec,
|
|
110
|
+
serverHost: new URL(opts.server).host,
|
|
111
|
+
cookieUrls: [`${opts.server}/`],
|
|
112
|
+
userAgent: 'jira-update/1.0',
|
|
113
|
+
verifySession: cookie => verifyJiraSession(cookie),
|
|
114
|
+
});
|
|
115
|
+
return session;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function verifyJiraSession(cookie) {
|
|
119
|
+
if (!cookie) return { ok: false, message: 'no Atlassian cookies yet' };
|
|
120
|
+
const probes = [`${opts.server}/rest/api/3/myself`, `${opts.server}/rest/api/2/myself`];
|
|
121
|
+
for (const url of probes) {
|
|
122
|
+
const result = await getSession().fetchJson(url, cookie, { accept: 'application/json' });
|
|
123
|
+
if (result.status === 200 && result.json && (result.json.accountId || result.json.name || result.json.displayName)) return { ok: true, url };
|
|
124
|
+
if (result.status === 401 || result.status === 403) return { ok: false, message: `not authenticated yet (${result.status} from ${url})` };
|
|
125
|
+
if (result.status === 302 || result.status === 303) return { ok: false, message: `still redirected to login (${result.status} from ${url})` };
|
|
126
|
+
if (result.status === 404) continue;
|
|
127
|
+
return { ok: false, message: `session probe HTTP ${result.status} from ${url}` };
|
|
128
|
+
}
|
|
129
|
+
return { ok: false, message: 'could not verify Jira session' };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function safeName(s) {
|
|
133
|
+
return String(s || 'item').replace(/[\\/\0]/g, '_').replace(/^\.+$/, '_').slice(0, 120);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function makeRunDir(label) {
|
|
137
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
138
|
+
const dir = path.join(opts.rawDir, 'jira-updates', `${safeName(label)}-${stamp}`);
|
|
139
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
140
|
+
return dir;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function writeAudit(dir, manifestRecord, files) {
|
|
144
|
+
for (const [name, content] of Object.entries(files)) await fsp.writeFile(path.join(dir, name), content);
|
|
145
|
+
await fsp.writeFile(path.join(dir, 'update-run.json'), JSON.stringify(manifestRecord, null, 2));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function postJson(url, cookie, body) {
|
|
149
|
+
return getSession().fetchJson(url, cookie, { method: 'POST', body: JSON.stringify(body) });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function putJson(url, cookie, body) {
|
|
153
|
+
return getSession().fetchJson(url, cookie, { method: 'PUT', body: JSON.stringify(body) });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function getJson(url, cookie) {
|
|
157
|
+
return getSession().fetchJson(url, cookie, { accept: 'application/json' });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function runCreate() {
|
|
161
|
+
if (!opts.file) { console.error('create requires --file FILE.'); process.exit(2); }
|
|
162
|
+
const raw = await fsp.readFile(path.resolve(opts.file), 'utf8');
|
|
163
|
+
const manifest = JSON.parse(raw);
|
|
164
|
+
const payload = lib.buildCreatePayload(manifest);
|
|
165
|
+
|
|
166
|
+
const dir = await makeRunDir(`create-${manifest.project || 'unknown'}`);
|
|
167
|
+
const record = {
|
|
168
|
+
command: 'create',
|
|
169
|
+
dryRun: !opts.apply,
|
|
170
|
+
server: opts.server,
|
|
171
|
+
project: manifest.project,
|
|
172
|
+
issueType: manifest.issueType,
|
|
173
|
+
summary: manifest.summary,
|
|
174
|
+
message: opts.message || undefined,
|
|
175
|
+
auditDir: dir,
|
|
176
|
+
};
|
|
177
|
+
const files = {
|
|
178
|
+
'proposed.payload.json': JSON.stringify(payload, null, 2),
|
|
179
|
+
};
|
|
180
|
+
if (payload.fields.description && payload.fields.description.type === 'doc') {
|
|
181
|
+
files['proposed.adf.json'] = JSON.stringify(payload.fields.description, null, 2);
|
|
182
|
+
}
|
|
183
|
+
await writeAudit(dir, record, files);
|
|
184
|
+
|
|
185
|
+
console.log(`${opts.apply ? 'Applying' : 'Dry-run'} create: ${manifest.project} / ${manifest.issueType} / "${manifest.summary}"`);
|
|
186
|
+
console.log(`Audit files: ${dir}`);
|
|
187
|
+
if (!opts.apply) {
|
|
188
|
+
console.log('Dry-run only. Re-run with --apply to write to Jira.');
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const browseUrl = `${opts.server}/issues/?jql=project=${encodeURIComponent(manifest.project)}`;
|
|
193
|
+
const cookie = await getSession().getCookieWithWait(browseUrl);
|
|
194
|
+
const result = await postJson(`${opts.server}/rest/api/3/issue`, cookie, payload);
|
|
195
|
+
if (result.status !== 201 || !result.json || !result.json.key) {
|
|
196
|
+
throw new Error(`Create failed HTTP ${result.status}: ${(result.text || '').slice(0, 500).replace(/\s+/g, ' ')}`);
|
|
197
|
+
}
|
|
198
|
+
await fsp.writeFile(path.join(dir, 'after.issue.json'), JSON.stringify(result.json, null, 2));
|
|
199
|
+
console.log(`Created issue ${result.json.key} (${result.json.id})`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function runComment() {
|
|
203
|
+
if (!opts.file) { console.error('comment requires --file FILE.'); process.exit(2); }
|
|
204
|
+
const raw = await fsp.readFile(path.resolve(opts.file), 'utf8');
|
|
205
|
+
const body = lib.renderDescription(raw, opts.representation);
|
|
206
|
+
const payload = { body };
|
|
207
|
+
|
|
208
|
+
const dir = await makeRunDir(`comment-${opts.issueKey}`);
|
|
209
|
+
const record = {
|
|
210
|
+
command: 'comment',
|
|
211
|
+
dryRun: !opts.apply,
|
|
212
|
+
server: opts.server,
|
|
213
|
+
issueKey: opts.issueKey,
|
|
214
|
+
representation: opts.representation,
|
|
215
|
+
message: opts.message || undefined,
|
|
216
|
+
auditDir: dir,
|
|
217
|
+
};
|
|
218
|
+
await writeAudit(dir, record, {
|
|
219
|
+
'proposed.payload.json': JSON.stringify(payload, null, 2),
|
|
220
|
+
'proposed.adf.json': JSON.stringify(body, null, 2),
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
console.log(`${opts.apply ? 'Applying' : 'Dry-run'} comment on ${opts.issueKey}`);
|
|
224
|
+
console.log(`Audit files: ${dir}`);
|
|
225
|
+
if (!opts.apply) {
|
|
226
|
+
console.log('Dry-run only. Re-run with --apply to write to Jira.');
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const browseUrl = `${opts.server}/browse/${opts.issueKey}`;
|
|
231
|
+
const cookie = await getSession().getCookieWithWait(browseUrl);
|
|
232
|
+
const result = await postJson(`${opts.server}/rest/api/3/issue/${opts.issueKey}/comment`, cookie, payload);
|
|
233
|
+
if (result.status !== 201 || !result.json || !result.json.id) {
|
|
234
|
+
throw new Error(`Comment failed HTTP ${result.status}: ${(result.text || '').slice(0, 500).replace(/\s+/g, ' ')}`);
|
|
235
|
+
}
|
|
236
|
+
await fsp.writeFile(path.join(dir, 'after.issue.json'), JSON.stringify(result.json, null, 2));
|
|
237
|
+
console.log(`Added comment ${result.json.id} on ${opts.issueKey}`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function runTransition() {
|
|
241
|
+
if (!opts.to && !opts.toId) { console.error('transition requires --to NAME or --to-id ID.'); process.exit(2); }
|
|
242
|
+
const browseUrl = `${opts.server}/browse/${opts.issueKey}`;
|
|
243
|
+
const cookie = await getSession().getCookieWithWait(browseUrl);
|
|
244
|
+
|
|
245
|
+
const transitionsResp = await getJson(`${opts.server}/rest/api/3/issue/${opts.issueKey}/transitions`, cookie);
|
|
246
|
+
if (transitionsResp.status !== 200 || !transitionsResp.json) {
|
|
247
|
+
throw new Error(`Could not list transitions for ${opts.issueKey}. HTTP ${transitionsResp.status}`);
|
|
248
|
+
}
|
|
249
|
+
const transition = lib.resolveTransition(transitionsResp.json, opts.toId ? { id: opts.toId } : { name: opts.to });
|
|
250
|
+
|
|
251
|
+
let commentBody = null;
|
|
252
|
+
if (opts.commentFile) {
|
|
253
|
+
const raw = await fsp.readFile(path.resolve(opts.commentFile), 'utf8');
|
|
254
|
+
commentBody = lib.renderDescription(raw, opts.representation);
|
|
255
|
+
}
|
|
256
|
+
const payload = lib.buildTransitionPayload({
|
|
257
|
+
transitionId: transition.id,
|
|
258
|
+
commentBody,
|
|
259
|
+
fields: opts.fieldOverrides,
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const before = await getJson(`${opts.server}/rest/api/3/issue/${opts.issueKey}?fields=status,summary`, cookie);
|
|
263
|
+
const dir = await makeRunDir(`transition-${opts.issueKey}`);
|
|
264
|
+
const record = {
|
|
265
|
+
command: 'transition',
|
|
266
|
+
dryRun: !opts.apply,
|
|
267
|
+
server: opts.server,
|
|
268
|
+
issueKey: opts.issueKey,
|
|
269
|
+
transition,
|
|
270
|
+
fieldOverrides: opts.fieldOverrides,
|
|
271
|
+
message: opts.message || undefined,
|
|
272
|
+
auditDir: dir,
|
|
273
|
+
};
|
|
274
|
+
await writeAudit(dir, record, {
|
|
275
|
+
'before.issue.json': JSON.stringify(before.json || {}, null, 2),
|
|
276
|
+
'transitions.json': JSON.stringify(transitionsResp.json, null, 2),
|
|
277
|
+
'proposed.payload.json': JSON.stringify(payload, null, 2),
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
console.log(`${opts.apply ? 'Applying' : 'Dry-run'} transition ${opts.issueKey} -> "${transition.name}" (id ${transition.id})`);
|
|
281
|
+
console.log(`Audit files: ${dir}`);
|
|
282
|
+
if (!opts.apply) {
|
|
283
|
+
console.log('Dry-run only. Re-run with --apply to write to Jira.');
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
const result = await postJson(`${opts.server}/rest/api/3/issue/${opts.issueKey}/transitions`, cookie, payload);
|
|
287
|
+
if (result.status !== 204) {
|
|
288
|
+
throw new Error(`Transition failed HTTP ${result.status}: ${(result.text || '').slice(0, 500).replace(/\s+/g, ' ')}`);
|
|
289
|
+
}
|
|
290
|
+
const after = await getJson(`${opts.server}/rest/api/3/issue/${opts.issueKey}?fields=status,summary`, cookie);
|
|
291
|
+
await fsp.writeFile(path.join(dir, 'after.issue.json'), JSON.stringify(after.json || {}, null, 2));
|
|
292
|
+
console.log(`Transitioned ${opts.issueKey} to "${transition.name}"`);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function runUpdateFields() {
|
|
296
|
+
if (!opts.file) { console.error('update-fields requires --file FILE.'); process.exit(2); }
|
|
297
|
+
const raw = await fsp.readFile(path.resolve(opts.file), 'utf8');
|
|
298
|
+
const manifest = JSON.parse(raw);
|
|
299
|
+
if (!manifest.fields || typeof manifest.fields !== 'object') {
|
|
300
|
+
console.error('update-fields manifest must have a "fields" object.');
|
|
301
|
+
process.exit(2);
|
|
302
|
+
}
|
|
303
|
+
const payload = { fields: manifest.fields };
|
|
304
|
+
|
|
305
|
+
const dir = await makeRunDir(`update-fields-${opts.issueKey}`);
|
|
306
|
+
const record = {
|
|
307
|
+
command: 'update-fields',
|
|
308
|
+
dryRun: !opts.apply,
|
|
309
|
+
server: opts.server,
|
|
310
|
+
issueKey: opts.issueKey,
|
|
311
|
+
fieldKeys: Object.keys(manifest.fields),
|
|
312
|
+
message: opts.message || undefined,
|
|
313
|
+
auditDir: dir,
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
if (!opts.apply) {
|
|
317
|
+
await writeAudit(dir, record, {
|
|
318
|
+
'proposed.payload.json': JSON.stringify(payload, null, 2),
|
|
319
|
+
});
|
|
320
|
+
console.log(`Dry-run update-fields on ${opts.issueKey}: ${Object.keys(manifest.fields).join(', ')}`);
|
|
321
|
+
console.log(`Audit files: ${dir}`);
|
|
322
|
+
console.log('Dry-run only. Re-run with --apply to write to Jira.');
|
|
323
|
+
console.log('Note: update-fields does NOT detect concurrent edits. Re-fetch with jira-browser-fetch first if drift matters.');
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const browseUrl = `${opts.server}/browse/${opts.issueKey}`;
|
|
328
|
+
const cookie = await getSession().getCookieWithWait(browseUrl);
|
|
329
|
+
const before = await getJson(`${opts.server}/rest/api/3/issue/${opts.issueKey}`, cookie);
|
|
330
|
+
await writeAudit(dir, record, {
|
|
331
|
+
'before.issue.json': JSON.stringify(before.json || {}, null, 2),
|
|
332
|
+
'proposed.payload.json': JSON.stringify(payload, null, 2),
|
|
333
|
+
});
|
|
334
|
+
console.log(`Applying update-fields on ${opts.issueKey}: ${Object.keys(manifest.fields).join(', ')}`);
|
|
335
|
+
console.log(`Audit files: ${dir}`);
|
|
336
|
+
|
|
337
|
+
const result = await putJson(`${opts.server}/rest/api/3/issue/${opts.issueKey}`, cookie, payload);
|
|
338
|
+
if (result.status !== 204) {
|
|
339
|
+
throw new Error(`update-fields failed HTTP ${result.status}: ${(result.text || '').slice(0, 500).replace(/\s+/g, ' ')}`);
|
|
340
|
+
}
|
|
341
|
+
const after = await getJson(`${opts.server}/rest/api/3/issue/${opts.issueKey}`, cookie);
|
|
342
|
+
await fsp.writeFile(path.join(dir, 'after.issue.json'), JSON.stringify(after.json || {}, null, 2));
|
|
343
|
+
console.log(`Updated ${opts.issueKey}`);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function runLink() {
|
|
347
|
+
if (!opts.to) { console.error('link requires --to ISSUE-KEY.'); process.exit(2); }
|
|
348
|
+
if (!opts.linkType) { console.error('link requires --type "blocks" (or any link type name/inward/outward).'); process.exit(2); }
|
|
349
|
+
const browseUrl = `${opts.server}/browse/${opts.issueKey}`;
|
|
350
|
+
const cookie = await getSession().getCookieWithWait(browseUrl);
|
|
351
|
+
|
|
352
|
+
const typesResp = await getJson(`${opts.server}/rest/api/3/issueLinkType`, cookie);
|
|
353
|
+
if (typesResp.status !== 200 || !typesResp.json) {
|
|
354
|
+
throw new Error(`Could not list link types. HTTP ${typesResp.status}`);
|
|
355
|
+
}
|
|
356
|
+
const linkType = lib.resolveLinkType(typesResp.json, opts.linkType);
|
|
357
|
+
const payload = lib.buildLinkPayload({ from: opts.issueKey, to: opts.to, linkType });
|
|
358
|
+
|
|
359
|
+
const dir = await makeRunDir(`link-${opts.issueKey}-${opts.to}`);
|
|
360
|
+
const record = {
|
|
361
|
+
command: 'link',
|
|
362
|
+
dryRun: !opts.apply,
|
|
363
|
+
server: opts.server,
|
|
364
|
+
fromKey: opts.issueKey,
|
|
365
|
+
toKey: opts.to,
|
|
366
|
+
linkType,
|
|
367
|
+
message: opts.message || undefined,
|
|
368
|
+
auditDir: dir,
|
|
369
|
+
};
|
|
370
|
+
await writeAudit(dir, record, {
|
|
371
|
+
'linktypes.json': JSON.stringify(typesResp.json, null, 2),
|
|
372
|
+
'proposed.payload.json': JSON.stringify(payload, null, 2),
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
console.log(`${opts.apply ? 'Applying' : 'Dry-run'} link ${opts.issueKey} ${linkType.outward} ${opts.to}`);
|
|
376
|
+
console.log(`Audit files: ${dir}`);
|
|
377
|
+
if (!opts.apply) {
|
|
378
|
+
console.log('Dry-run only. Re-run with --apply to write to Jira.');
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
const result = await postJson(`${opts.server}/rest/api/3/issueLink`, cookie, payload);
|
|
382
|
+
if (result.status !== 201 && result.status !== 200) {
|
|
383
|
+
throw new Error(`link failed HTTP ${result.status}: ${(result.text || '').slice(0, 500).replace(/\s+/g, ' ')}`);
|
|
384
|
+
}
|
|
385
|
+
console.log(`Linked ${opts.issueKey} ${linkType.outward} ${opts.to}`);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function main() {
|
|
389
|
+
await fsp.mkdir(opts.rawDir, { recursive: true });
|
|
390
|
+
switch (opts.command) {
|
|
391
|
+
case 'create': return runCreate();
|
|
392
|
+
case 'comment': return runComment();
|
|
393
|
+
case 'transition': return runTransition();
|
|
394
|
+
case 'update-fields': return runUpdateFields();
|
|
395
|
+
case 'link': return runLink();
|
|
396
|
+
default:
|
|
397
|
+
throw new Error(`Unhandled command: ${opts.command}`);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
main().catch(err => {
|
|
402
|
+
console.error(`\nERROR: ${err.stack || err.message}`);
|
|
403
|
+
process.exit(1);
|
|
404
|
+
});
|