@aholbreich/agent-skills 0.9.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.
@@ -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
+ });
@@ -0,0 +1,283 @@
1
+ 'use strict';
2
+
3
+ function adfDoc(content) {
4
+ return { type: 'doc', version: 1, content: content || [] };
5
+ }
6
+
7
+ function inlineNodes(text) {
8
+ const nodes = [];
9
+ let i = 0;
10
+ let plain = '';
11
+
12
+ function pushPlain() {
13
+ if (plain) {
14
+ nodes.push({ type: 'text', text: plain });
15
+ plain = '';
16
+ }
17
+ }
18
+
19
+ function pushMarked(t, marks) {
20
+ if (!t) return;
21
+ nodes.push({ type: 'text', text: t, marks });
22
+ }
23
+
24
+ while (i < text.length) {
25
+ const ch = text[i];
26
+
27
+ if (ch === '`') {
28
+ const end = text.indexOf('`', i + 1);
29
+ if (end !== -1) {
30
+ pushPlain();
31
+ pushMarked(text.slice(i + 1, end), [{ type: 'code' }]);
32
+ i = end + 1;
33
+ continue;
34
+ }
35
+ }
36
+
37
+ if (ch === '[') {
38
+ const close = text.indexOf(']', i + 1);
39
+ if (close !== -1 && text[close + 1] === '(') {
40
+ const urlEnd = text.indexOf(')', close + 2);
41
+ if (urlEnd !== -1) {
42
+ pushPlain();
43
+ pushMarked(text.slice(i + 1, close), [{ type: 'link', attrs: { href: text.slice(close + 2, urlEnd) } }]);
44
+ i = urlEnd + 1;
45
+ continue;
46
+ }
47
+ }
48
+ }
49
+
50
+ if (ch === '*' && text[i + 1] === '*') {
51
+ const end = text.indexOf('**', i + 2);
52
+ if (end !== -1) {
53
+ pushPlain();
54
+ pushMarked(text.slice(i + 2, end), [{ type: 'strong' }]);
55
+ i = end + 2;
56
+ continue;
57
+ }
58
+ }
59
+
60
+ if (ch === '*' && text[i + 1] !== '*') {
61
+ const end = text.indexOf('*', i + 1);
62
+ if (end !== -1) {
63
+ pushPlain();
64
+ pushMarked(text.slice(i + 1, end), [{ type: 'em' }]);
65
+ i = end + 1;
66
+ continue;
67
+ }
68
+ }
69
+
70
+ plain += ch;
71
+ i++;
72
+ }
73
+ pushPlain();
74
+ return nodes;
75
+ }
76
+
77
+ function paragraph(text) {
78
+ return { type: 'paragraph', content: inlineNodes(text) };
79
+ }
80
+
81
+ function listItem(text) {
82
+ return { type: 'listItem', content: [paragraph(text)] };
83
+ }
84
+
85
+ function markdownToAdf(input) {
86
+ const lines = String(input || '').replace(/\r\n/g, '\n').split('\n');
87
+ const blocks = [];
88
+ let paragraphLines = [];
89
+ let list = null;
90
+ let inCode = false;
91
+ let codeLanguage = '';
92
+ let codeLines = [];
93
+
94
+ function flushParagraph() {
95
+ if (!paragraphLines.length) return;
96
+ blocks.push(paragraph(paragraphLines.join(' ')));
97
+ paragraphLines = [];
98
+ }
99
+
100
+ function closeList() {
101
+ if (!list) return;
102
+ blocks.push({ type: list.type, content: list.items });
103
+ list = null;
104
+ }
105
+
106
+ function flushCode() {
107
+ blocks.push({
108
+ type: 'codeBlock',
109
+ attrs: codeLanguage ? { language: codeLanguage } : {},
110
+ content: codeLines.length ? [{ type: 'text', text: codeLines.join('\n') }] : [],
111
+ });
112
+ codeLines = [];
113
+ codeLanguage = '';
114
+ }
115
+
116
+ for (const rawLine of lines) {
117
+ const line = rawLine.replace(/\s+$/, '');
118
+
119
+ const fence = line.match(/^```(\w*)\s*$/);
120
+ if (fence) {
121
+ if (inCode) { inCode = false; flushCode(); }
122
+ else { flushParagraph(); closeList(); inCode = true; codeLanguage = fence[1] || ''; codeLines = []; }
123
+ continue;
124
+ }
125
+ if (inCode) { codeLines.push(rawLine); continue; }
126
+
127
+ if (!line.trim()) { flushParagraph(); closeList(); continue; }
128
+
129
+ const heading = line.match(/^(#{1,6})\s+(.+)$/);
130
+ if (heading) {
131
+ flushParagraph(); closeList();
132
+ blocks.push({ type: 'heading', attrs: { level: heading[1].length }, content: inlineNodes(heading[2].trim()) });
133
+ continue;
134
+ }
135
+
136
+ const bullet = line.match(/^[-*]\s+(.+)$/);
137
+ if (bullet) {
138
+ flushParagraph();
139
+ if (!list || list.type !== 'bulletList') { closeList(); list = { type: 'bulletList', items: [] }; }
140
+ list.items.push(listItem(bullet[1].trim()));
141
+ continue;
142
+ }
143
+
144
+ const ordered = line.match(/^\d+[.)]\s+(.+)$/);
145
+ if (ordered) {
146
+ flushParagraph();
147
+ if (!list || list.type !== 'orderedList') { closeList(); list = { type: 'orderedList', items: [] }; }
148
+ list.items.push(listItem(ordered[1].trim()));
149
+ continue;
150
+ }
151
+
152
+ closeList();
153
+ paragraphLines.push(line.trim());
154
+ }
155
+
156
+ if (inCode) flushCode();
157
+ flushParagraph();
158
+ closeList();
159
+ return adfDoc(blocks);
160
+ }
161
+
162
+ function renderDescription(input, representation) {
163
+ const rep = String(representation || 'markdown').toLowerCase();
164
+ if (rep === 'adf') {
165
+ if (!input || typeof input !== 'object') throw new Error('descriptionRepresentation: adf requires an ADF object');
166
+ return input;
167
+ }
168
+ if (rep === 'markdown' || rep === 'md') return markdownToAdf(String(input ?? ''));
169
+ throw new Error(`Unsupported representation: ${representation}`);
170
+ }
171
+
172
+ function parseAssignee(value) {
173
+ if (value === null || value === undefined || value === '') return null;
174
+ if (typeof value === 'object') return value;
175
+ const s = String(value);
176
+ if (s.startsWith('accountId:')) return { accountId: s.slice('accountId:'.length) };
177
+ return { name: s };
178
+ }
179
+
180
+ function buildCreatePayload(manifest) {
181
+ if (!manifest || typeof manifest !== 'object') throw new Error('create manifest must be an object');
182
+ if (!manifest.project) throw new Error('create manifest requires project (key)');
183
+ if (!manifest.issueType) throw new Error('create manifest requires issueType (name)');
184
+ if (!manifest.summary) throw new Error('create manifest requires summary');
185
+
186
+ const fields = {
187
+ project: { key: String(manifest.project) },
188
+ issuetype: { name: String(manifest.issueType) },
189
+ summary: String(manifest.summary),
190
+ };
191
+
192
+ if (manifest.description !== undefined && manifest.description !== null) {
193
+ fields.description = renderDescription(manifest.description, manifest.descriptionRepresentation);
194
+ }
195
+
196
+ if (Array.isArray(manifest.labels)) fields.labels = manifest.labels.map(String);
197
+ const assignee = parseAssignee(manifest.assignee);
198
+ if (assignee) fields.assignee = assignee;
199
+ if (manifest.priority) fields.priority = typeof manifest.priority === 'string' ? { name: manifest.priority } : manifest.priority;
200
+ if (manifest.parent) fields.parent = typeof manifest.parent === 'string' ? { key: manifest.parent } : manifest.parent;
201
+
202
+ if (manifest.fields && typeof manifest.fields === 'object') {
203
+ Object.assign(fields, manifest.fields);
204
+ }
205
+
206
+ return { fields };
207
+ }
208
+
209
+ function resolveTransition(transitionsResponse, query) {
210
+ const list = (transitionsResponse && transitionsResponse.transitions) || [];
211
+ if (!list.length) throw new Error('No transitions available');
212
+ if (query.id) {
213
+ const match = list.find(t => String(t.id) === String(query.id));
214
+ if (!match) throw new Error(`Transition not found: id=${query.id}. Available: ${list.map(t => `${t.id}:${t.name}`).join(', ')}`);
215
+ return match;
216
+ }
217
+ if (query.name) {
218
+ const want = String(query.name).toLowerCase();
219
+ const match = list.find(t => String(t.name).toLowerCase() === want);
220
+ if (!match) throw new Error(`Transition not found: "${query.name}". Available: ${list.map(t => t.name).join(', ')}`);
221
+ return match;
222
+ }
223
+ throw new Error('resolveTransition requires {name} or {id}');
224
+ }
225
+
226
+ function fieldValueFromCli(key, value) {
227
+ if (['resolution', 'priority', 'status'].includes(key)) return { name: value };
228
+ if (['labels', 'components', 'fixVersions'].includes(key)) {
229
+ const parts = String(value).split(',').map(s => s.trim()).filter(Boolean);
230
+ if (key === 'labels') return parts;
231
+ return parts.map(name => ({ name }));
232
+ }
233
+ return value;
234
+ }
235
+
236
+ function buildTransitionPayload({ transitionId, commentBody, fields }) {
237
+ if (!transitionId) throw new Error('buildTransitionPayload requires transitionId');
238
+ const payload = { transition: { id: String(transitionId) } };
239
+ if (commentBody) {
240
+ payload.update = { comment: [{ add: { body: commentBody } }] };
241
+ }
242
+ if (fields && Object.keys(fields).length) {
243
+ payload.fields = {};
244
+ for (const [k, v] of Object.entries(fields)) payload.fields[k] = fieldValueFromCli(k, v);
245
+ }
246
+ return payload;
247
+ }
248
+
249
+ function resolveLinkType(typesResponse, query) {
250
+ const list = (typesResponse && typesResponse.issueLinkTypes) || [];
251
+ if (!list.length) throw new Error('No issue link types available');
252
+ const want = String(query || '').toLowerCase();
253
+ const match = list.find(t =>
254
+ String(t.name).toLowerCase() === want
255
+ || String(t.inward).toLowerCase() === want
256
+ || String(t.outward).toLowerCase() === want
257
+ );
258
+ if (!match) throw new Error(`Link type not found: "${query}". Available: ${list.map(t => t.name).join(', ')}`);
259
+ return match;
260
+ }
261
+
262
+ function buildLinkPayload({ from, to, linkType }) {
263
+ if (!from || !to) throw new Error('buildLinkPayload requires from and to');
264
+ if (!linkType || !linkType.name) throw new Error('buildLinkPayload requires linkType.name');
265
+ return {
266
+ type: { name: linkType.name },
267
+ inwardIssue: { key: to },
268
+ outwardIssue: { key: from },
269
+ };
270
+ }
271
+
272
+ module.exports = {
273
+ adfDoc,
274
+ markdownToAdf,
275
+ renderDescription,
276
+ parseAssignee,
277
+ buildCreatePayload,
278
+ resolveTransition,
279
+ fieldValueFromCli,
280
+ buildTransitionPayload,
281
+ resolveLinkType,
282
+ buildLinkPayload,
283
+ };