@stravica-ai/digital-operator-pa 0.1.39 → 0.1.41

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.
Files changed (92) hide show
  1. package/README.md +12 -2
  2. package/bin/confluence +9 -0
  3. package/bin/confluence-fetch +9 -0
  4. package/bin/jira +9 -0
  5. package/dist/engineering-pa-core/knowledge/content-voice/banned-ai-tells-taxonomy.md +1 -1
  6. package/dist/engineering-pa-core/knowledge/knowledge-memory/staging-and-observations.md +3 -3
  7. package/dist/engineering-pa-core/knowledge/platform-tooling/workspace-extensions.md +62 -0
  8. package/dist/engineering-pa-core-extensions-atlassian/README.md +81 -16
  9. package/dist/engineering-pa-core-extensions-atlassian/bin/confluence +2 -0
  10. package/dist/engineering-pa-core-extensions-atlassian/bin/confluence-fetch +2 -0
  11. package/dist/engineering-pa-core-extensions-atlassian/bin/jira +2 -0
  12. package/dist/engineering-pa-core-extensions-atlassian/config/jira-workflow-chains.default.json +4 -0
  13. package/dist/engineering-pa-core-extensions-atlassian/index.mjs +17 -6
  14. package/dist/engineering-pa-core-extensions-atlassian/package.json +11 -4
  15. package/dist/engineering-pa-core-extensions-atlassian/src/common/invalidate-client.mjs +137 -0
  16. package/dist/engineering-pa-core-extensions-atlassian/src/common/pa-name.mjs +58 -0
  17. package/dist/engineering-pa-core-extensions-atlassian/src/common/secrets.mjs +158 -0
  18. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/cli.mjs +165 -0
  19. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/client.mjs +294 -0
  20. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/auth.mjs +59 -0
  21. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/comment.mjs +174 -0
  22. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/create.mjs +136 -0
  23. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/label.mjs +98 -0
  24. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/mine.mjs +105 -0
  25. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/mirror-folder.mjs +487 -0
  26. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/mirror-page.mjs +351 -0
  27. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/page.mjs +127 -0
  28. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/pages.mjs +107 -0
  29. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/recent.mjs +107 -0
  30. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/search.mjs +123 -0
  31. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/spaces.mjs +82 -0
  32. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/test.mjs +155 -0
  33. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/tree.mjs +103 -0
  34. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/update.mjs +134 -0
  35. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/format.mjs +260 -0
  36. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/mirror.mjs +353 -0
  37. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/storage.mjs +880 -0
  38. package/dist/engineering-pa-core-extensions-atlassian/src/confluence-fetch/cli.mjs +313 -0
  39. package/dist/engineering-pa-core-extensions-atlassian/src/confluence-fetch/fetch.mjs +327 -0
  40. package/dist/engineering-pa-core-extensions-atlassian/src/jira/adf.mjs +388 -0
  41. package/dist/engineering-pa-core-extensions-atlassian/src/jira/bug-fields.mjs +294 -0
  42. package/dist/engineering-pa-core-extensions-atlassian/src/jira/cli.mjs +197 -0
  43. package/dist/engineering-pa-core-extensions-atlassian/src/jira/client.mjs +350 -0
  44. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/advance.mjs +486 -0
  45. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/attach.mjs +129 -0
  46. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/auth.mjs +53 -0
  47. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/boards.mjs +88 -0
  48. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/bulk-transition.mjs +223 -0
  49. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/cancel.mjs +135 -0
  50. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/comment.mjs +169 -0
  51. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/create.mjs +376 -0
  52. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/issue.mjs +105 -0
  53. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/link.mjs +204 -0
  54. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/mine.mjs +97 -0
  55. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/projects.mjs +91 -0
  56. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/search.mjs +80 -0
  57. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/sprint.mjs +111 -0
  58. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/sync-statuses.mjs +313 -0
  59. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/test.mjs +142 -0
  60. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/transition.mjs +197 -0
  61. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/update.mjs +284 -0
  62. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/worklog.mjs +380 -0
  63. package/dist/engineering-pa-core-extensions-atlassian/src/jira/format.mjs +267 -0
  64. package/dist/engineering-pa-core-extensions-atlassian/src/jira/invalidate.mjs +72 -0
  65. package/dist/engineering-pa-core-extensions-atlassian/src/jira/metadata.mjs +225 -0
  66. package/dist/engineering-pa-core-extensions-atlassian/src/jira/required-fields.mjs +334 -0
  67. package/dist/engineering-pa-core-extensions-atlassian/src/jira/workflow-chains.mjs +405 -0
  68. package/dist/engineering-pa-core-extensions-atlassian/src/jira/workflow-map.mjs +107 -0
  69. package/dist/engineering-pa-scaffold/README.md +32 -1
  70. package/dist/engineering-pa-scaffold/src/cli.mjs +83 -6
  71. package/dist/engineering-pa-scaffold/src/commands/init-workspace-extension.mjs +234 -0
  72. package/dist/engineering-pa-scaffold/src/commands/init.mjs +6 -5
  73. package/dist/engineering-pa-scaffold/src/generator.mjs +240 -4
  74. package/dist/engineering-pa-scaffold/templates/CLAUDE.md.template +7 -7
  75. package/dist/engineering-pa-scaffold/templates/operator-README.md.template +4 -4
  76. package/dist/engineering-pa-scaffold/templates/root-CLAUDE.md.template +13 -0
  77. package/dist/engineering-pa-types-engineering/index.mjs +14 -6
  78. package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/create-a-small-app.md +81 -0
  79. package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/for-agents.md +25 -0
  80. package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/index.md +34 -0
  81. package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/reference.md +16 -0
  82. package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/wire-your-agent.md +17 -0
  83. package/dist/engineering-pa-types-engineering/package.json +2 -2
  84. package/dist/engineering-pa-types-engineering/roles/rcf-lite-engineer/definition.md +183 -0
  85. package/dist/engineering-pa-workspace/app/home.html +7 -7
  86. package/dist/engineering-pa-workspace/app/onboarding.html +4 -4
  87. package/dist/engineering-pa-workspace/src/api.mjs +1 -1
  88. package/dist/engineering-pa-workspace/src/server.mjs +1 -1
  89. package/docs/installing.md +78 -0
  90. package/docs/onboarding-solo.md +162 -0
  91. package/docs/what-it-can-do.md +85 -0
  92. package/package.json +6 -2
@@ -0,0 +1,388 @@
1
+ // adf.mjs - Atlassian Document Format (ADF) helpers.
2
+ //
3
+ // Jira REST API v3 requires ADF for description and comment fields.
4
+ // This module converts between plain text, markdown, and ADF structures.
5
+ //
6
+ // Zero external dependencies - pure data transformation.
7
+
8
+ // ── Text → ADF ───────────────────────────────────────────────────────
9
+
10
+ // Markdown block patterns.
11
+ const HEADING_RE = /^(#{1,6})\s+(.*)$/;
12
+ const BULLET_RE = /^[-*]\s+(.*)$/;
13
+ const ORDERED_RE = /^\d+\.\s+(.*)$/;
14
+
15
+ /**
16
+ * Convert text to an ADF document. Plain text by default; recognises a
17
+ * minimal markdown subset so descriptions render as proper Jira structure
18
+ * rather than literal markdown characters:
19
+ *
20
+ * # H1 ... ###### H6 → heading (levels 1-6)
21
+ * - item / * item → bulletList
22
+ * 1. item / 2. item → orderedList
23
+ * **text** → strong
24
+ * *text* → em
25
+ * `text` → code
26
+ * [label](https://…) → link
27
+ *
28
+ * Block separation is still double-newline (current contract). Within a
29
+ * block, lines either share a single block type (all-list or all-heading-line)
30
+ * or fall back to a paragraph with hardBreaks between lines.
31
+ *
32
+ * @param {string} text Plain text (or minimal-markdown) input
33
+ * @returns {object} ADF document
34
+ */
35
+ export function textToAdf(text) {
36
+ if (!text || typeof text !== 'string') {
37
+ return {
38
+ version: 1,
39
+ type: 'doc',
40
+ content: [{ type: 'paragraph', content: [] }],
41
+ };
42
+ }
43
+
44
+ const blocks = text.split(/\n{2,}/);
45
+ const content = [];
46
+
47
+ for (const block of blocks) {
48
+ const lines = block.split('\n');
49
+ const first = lines[0];
50
+
51
+ // Heading: single-line block starting with #+
52
+ const hm = HEADING_RE.exec(first);
53
+ if (hm && lines.length === 1) {
54
+ content.push({
55
+ type: 'heading',
56
+ attrs: { level: hm[1].length },
57
+ content: parseInline(hm[2]),
58
+ });
59
+ continue;
60
+ }
61
+
62
+ // Bullet list: every line matches bullet pattern
63
+ if (lines.length > 0 && lines.every((l) => BULLET_RE.test(l))) {
64
+ content.push({
65
+ type: 'bulletList',
66
+ content: lines.map((l) => ({
67
+ type: 'listItem',
68
+ content: [
69
+ { type: 'paragraph', content: parseInline(BULLET_RE.exec(l)[1]) },
70
+ ],
71
+ })),
72
+ });
73
+ continue;
74
+ }
75
+
76
+ // Ordered list: every line matches ordered pattern
77
+ if (lines.length > 0 && lines.every((l) => ORDERED_RE.test(l))) {
78
+ content.push({
79
+ type: 'orderedList',
80
+ content: lines.map((l) => ({
81
+ type: 'listItem',
82
+ content: [
83
+ { type: 'paragraph', content: parseInline(ORDERED_RE.exec(l)[1]) },
84
+ ],
85
+ })),
86
+ });
87
+ continue;
88
+ }
89
+
90
+ // Default: paragraph with hardBreaks between lines, inline marks parsed
91
+ const inline = [];
92
+ for (let i = 0; i < lines.length; i++) {
93
+ if (i > 0) inline.push({ type: 'hardBreak' });
94
+ if (lines[i]) inline.push(...parseInline(lines[i]));
95
+ }
96
+ content.push({
97
+ type: 'paragraph',
98
+ content: inline.length > 0 ? inline : [],
99
+ });
100
+ }
101
+
102
+ return { version: 1, type: 'doc', content };
103
+ }
104
+
105
+ /**
106
+ * Parse a single line for inline marks (strong, em, code, link). Returns
107
+ * an array of ADF text nodes. Markers nest only by left-to-right scan;
108
+ * unmatched markers are treated as literal text. Designed to be
109
+ * predictable rather than fully markdown-compliant.
110
+ *
111
+ * @param {string} line
112
+ * @returns {Array}
113
+ */
114
+ function parseInline(line) {
115
+ if (!line) return [];
116
+ const out = [];
117
+ let buf = '';
118
+ let i = 0;
119
+
120
+ const flush = () => {
121
+ if (buf) {
122
+ out.push({ type: 'text', text: buf });
123
+ buf = '';
124
+ }
125
+ };
126
+
127
+ while (i < line.length) {
128
+ const ch = line[i];
129
+
130
+ // Inline code: `...`
131
+ if (ch === '`') {
132
+ const close = line.indexOf('`', i + 1);
133
+ if (close > i) {
134
+ flush();
135
+ out.push({
136
+ type: 'text',
137
+ text: line.slice(i + 1, close),
138
+ marks: [{ type: 'code' }],
139
+ });
140
+ i = close + 1;
141
+ continue;
142
+ }
143
+ }
144
+
145
+ // Strong: **...**
146
+ if (ch === '*' && line[i + 1] === '*') {
147
+ const close = line.indexOf('**', i + 2);
148
+ if (close > i + 1) {
149
+ flush();
150
+ out.push({
151
+ type: 'text',
152
+ text: line.slice(i + 2, close),
153
+ marks: [{ type: 'strong' }],
154
+ });
155
+ i = close + 2;
156
+ continue;
157
+ }
158
+ }
159
+
160
+ // Em: *...* (single asterisk; must not look like the start of **)
161
+ if (ch === '*' && line[i + 1] !== '*' && line[i + 1] !== ' ' && line[i + 1] !== undefined) {
162
+ const close = line.indexOf('*', i + 1);
163
+ if (close > i && line[close + 1] !== '*') {
164
+ flush();
165
+ out.push({
166
+ type: 'text',
167
+ text: line.slice(i + 1, close),
168
+ marks: [{ type: 'em' }],
169
+ });
170
+ i = close + 1;
171
+ continue;
172
+ }
173
+ }
174
+
175
+ // Link: [label](url)
176
+ if (ch === '[') {
177
+ const closeBracket = line.indexOf(']', i + 1);
178
+ if (closeBracket > i && line[closeBracket + 1] === '(') {
179
+ const closeParen = line.indexOf(')', closeBracket + 2);
180
+ if (closeParen > closeBracket) {
181
+ flush();
182
+ out.push({
183
+ type: 'text',
184
+ text: line.slice(i + 1, closeBracket),
185
+ marks: [{ type: 'link', attrs: { href: line.slice(closeBracket + 2, closeParen) } }],
186
+ });
187
+ i = closeParen + 1;
188
+ continue;
189
+ }
190
+ }
191
+ }
192
+
193
+ buf += ch;
194
+ i++;
195
+ }
196
+
197
+ flush();
198
+ return out;
199
+ }
200
+
201
+ // ── ADF → Plain text ─────────────────────────────────────────────────
202
+
203
+ /**
204
+ * Extract plain text from an ADF document.
205
+ * Recursively walks the ADF tree and concatenates text content.
206
+ *
207
+ * @param {object} adfDoc ADF document or node
208
+ * @returns {string} Plain text
209
+ */
210
+ export function adfToText(adfDoc) {
211
+ if (!adfDoc) return '';
212
+ return extractText(adfDoc).trim();
213
+ }
214
+
215
+ /**
216
+ * Recursively extract text from an ADF node.
217
+ * @param {object} node ADF node
218
+ * @returns {string}
219
+ */
220
+ function extractText(node) {
221
+ if (!node) return '';
222
+
223
+ // Leaf text node
224
+ if (node.type === 'text') {
225
+ return node.text || '';
226
+ }
227
+
228
+ // Hard break
229
+ if (node.type === 'hardBreak') {
230
+ return '\n';
231
+ }
232
+
233
+ // Mention
234
+ if (node.type === 'mention') {
235
+ return node.attrs?.text || node.attrs?.id || '@unknown';
236
+ }
237
+
238
+ // Inline card (smart link)
239
+ if (node.type === 'inlineCard') {
240
+ return node.attrs?.url || '';
241
+ }
242
+
243
+ // No children - nothing to extract
244
+ if (!node.content || !Array.isArray(node.content)) {
245
+ return '';
246
+ }
247
+
248
+ const childText = node.content.map(extractText).join('');
249
+
250
+ // Block-level nodes get newline separation
251
+ switch (node.type) {
252
+ case 'doc':
253
+ return node.content.map(extractText).join('\n\n');
254
+ case 'paragraph':
255
+ return childText;
256
+ case 'heading':
257
+ return childText;
258
+ case 'bulletList':
259
+ case 'orderedList':
260
+ return node.content.map((item, i) => {
261
+ const prefix = node.type === 'orderedList' ? `${i + 1}. ` : '- ';
262
+ return prefix + extractText(item);
263
+ }).join('\n');
264
+ case 'listItem':
265
+ return childText;
266
+ case 'codeBlock':
267
+ return childText;
268
+ case 'blockquote':
269
+ return childText;
270
+ default:
271
+ return childText;
272
+ }
273
+ }
274
+
275
+ // ── ADF → Markdown ───────────────────────────────────────────────────
276
+
277
+ /**
278
+ * Convert an ADF document to markdown.
279
+ * Handles headings, lists, code blocks, blockquotes, and inline marks.
280
+ *
281
+ * @param {object} adfDoc ADF document
282
+ * @returns {string} Markdown text
283
+ */
284
+ export function adfToMarkdown(adfDoc) {
285
+ if (!adfDoc) return '';
286
+ return renderNode(adfDoc).trim();
287
+ }
288
+
289
+ /**
290
+ * Render an ADF node to markdown.
291
+ * @param {object} node ADF node
292
+ * @returns {string}
293
+ */
294
+ function renderNode(node) {
295
+ if (!node) return '';
296
+
297
+ // Leaf text node - apply marks
298
+ if (node.type === 'text') {
299
+ let text = node.text || '';
300
+ if (node.marks && Array.isArray(node.marks)) {
301
+ for (const mark of node.marks) {
302
+ switch (mark.type) {
303
+ case 'strong':
304
+ text = `**${text}**`;
305
+ break;
306
+ case 'em':
307
+ text = `*${text}*`;
308
+ break;
309
+ case 'code':
310
+ text = `\`${text}\``;
311
+ break;
312
+ case 'link':
313
+ text = `[${text}](${mark.attrs?.href || ''})`;
314
+ break;
315
+ case 'strike':
316
+ text = `~~${text}~~`;
317
+ break;
318
+ }
319
+ }
320
+ }
321
+ return text;
322
+ }
323
+
324
+ // Hard break
325
+ if (node.type === 'hardBreak') {
326
+ return '\n';
327
+ }
328
+
329
+ // Mention
330
+ if (node.type === 'mention') {
331
+ return node.attrs?.text || `@${node.attrs?.id || 'unknown'}`;
332
+ }
333
+
334
+ // Inline card
335
+ if (node.type === 'inlineCard') {
336
+ return node.attrs?.url || '';
337
+ }
338
+
339
+ // No children
340
+ if (!node.content || !Array.isArray(node.content)) {
341
+ return '';
342
+ }
343
+
344
+ switch (node.type) {
345
+ case 'doc':
346
+ return node.content.map(renderNode).join('\n\n');
347
+
348
+ case 'paragraph':
349
+ return node.content.map(renderNode).join('');
350
+
351
+ case 'heading': {
352
+ const level = node.attrs?.level || 1;
353
+ const prefix = '#'.repeat(level);
354
+ const text = node.content.map(renderNode).join('');
355
+ return `${prefix} ${text}`;
356
+ }
357
+
358
+ case 'bulletList':
359
+ return node.content.map((item) => {
360
+ const text = renderNode(item);
361
+ return `- ${text}`;
362
+ }).join('\n');
363
+
364
+ case 'orderedList':
365
+ return node.content.map((item, i) => {
366
+ const text = renderNode(item);
367
+ return `${i + 1}. ${text}`;
368
+ }).join('\n');
369
+
370
+ case 'listItem':
371
+ // List items contain paragraphs; render inline
372
+ return node.content.map(renderNode).join('\n');
373
+
374
+ case 'codeBlock': {
375
+ const lang = node.attrs?.language || '';
376
+ const code = node.content.map(renderNode).join('');
377
+ return `\`\`\`${lang}\n${code}\n\`\`\``;
378
+ }
379
+
380
+ case 'blockquote': {
381
+ const text = node.content.map(renderNode).join('\n');
382
+ return text.split('\n').map((line) => `> ${line}`).join('\n');
383
+ }
384
+
385
+ default:
386
+ return node.content.map(renderNode).join('');
387
+ }
388
+ }
@@ -0,0 +1,294 @@
1
+ // bug-fields.mjs - First-class support for creating Bugs with the common required-field set (severity/steps/repro-rate).
2
+ //
3
+ // Project AI (Jira) enforces four custom fields on Bug create; the API
4
+ // 400s with "Field 'customfield_XXXXX' cannot be set" when they are
5
+ // missing, and rejects wrongly-shaped select payloads. This helper:
6
+ //
7
+ // 1. Discovers the required fields and their live allowed values from
8
+ // the createmeta endpoint (`/rest/api/3/issue/createmeta/<PROJECT>
9
+ // /issuetypes/<ISSUE_TYPE_ID>`). No hard-coded field ids or option
10
+ // lists - the API is truth.
11
+ // 2. Exposes semantic flags - `--env-impacted`, `--expected`,
12
+ // `--actual`, `--steps` - that map onto those customfields with the
13
+ // correct payload shapes (multiselect = array of options; textarea
14
+ // = ADF doc).
15
+ // 3. Validates every `--env-impacted` value against the live allowed
16
+ // list (case-insensitive), echoing the exact API casing on match
17
+ // and printing the allowed set on miss.
18
+ // 4. Runs a preflight check that returns the missing required fields
19
+ // as an actionable error - so the CLI can fail fast with the exact
20
+ // flags to add, instead of relaying Jira's opaque 400.
21
+ //
22
+ // The module holds no state across CLI invocations. A per-process cache
23
+ // on the createmeta response keeps the field-list to a single API call
24
+ // per `jira create --bug` run.
25
+
26
+ import { JiraError } from './client.mjs';
27
+ import { textToAdf } from './adf.mjs';
28
+
29
+ // Semantic flag → the field's canonical Jira display name in createmeta.
30
+ // Resolution is by name (not id) so a field-id shuffle on Jira's side
31
+ // never silently breaks the CLI. Ids come out of createmeta at runtime.
32
+ export const BUG_FIELD_NAMES = Object.freeze({
33
+ envImpacted: 'Environment Impacted',
34
+ expected: 'Expected Behaviour',
35
+ actual: 'Actual Behaviour',
36
+ steps: 'Steps to Reproduce Issue',
37
+ });
38
+
39
+ // Human labels for the semantic flags - used in error messages.
40
+ export const BUG_FLAG_LABELS = Object.freeze({
41
+ envImpacted: '--env-impacted',
42
+ expected: '--expected',
43
+ actual: '--actual',
44
+ steps: '--steps',
45
+ });
46
+
47
+ // Per-process cache: `${projectKey}:${issueTypeName}` → resolved meta.
48
+ const metaCache = new Map();
49
+
50
+ /**
51
+ * Fetch the createmeta for a given project + issue type name (e.g.
52
+ * `AI` / `Bug`) and index it into a semantic shape the create command
53
+ * can consume. Returns:
54
+ *
55
+ * {
56
+ * issueTypeId: '10102',
57
+ * requiredFields: [ // every required field
58
+ * { id, name, schema, required, allowedValues, semantic|null }
59
+ * ],
60
+ * semantic: { // by semantic key
61
+ * envImpacted: { id, name, allowedValues: [...] } | null,
62
+ * expected: { id, name } | null,
63
+ * actual: { id, name } | null,
64
+ * steps: { id, name } | null,
65
+ * },
66
+ * }
67
+ *
68
+ * Throws JiraError(2) if the issue type does not exist for the project.
69
+ *
70
+ * @param {object} client
71
+ * @param {string} projectKey
72
+ * @param {string} issueTypeName
73
+ * @returns {Promise<object>}
74
+ */
75
+ export async function fetchBugCreateMeta(client, projectKey, issueTypeName = 'Bug') {
76
+ const cacheKey = `${projectKey.toUpperCase()}:${issueTypeName.toLowerCase()}`;
77
+ if (metaCache.has(cacheKey)) return metaCache.get(cacheKey);
78
+
79
+ // 1) Resolve the issue-type id via the createmeta issuetypes endpoint.
80
+ // The v3 shape uses `values`; older/legacy responses use `issueTypes`
81
+ // or return the bare array. Handle all three defensively.
82
+ const { data: typesResp } = await client.get(
83
+ `/rest/api/3/issue/createmeta/${encodeURIComponent(projectKey)}/issuetypes?maxResults=200`
84
+ );
85
+ const types = extractList(typesResp, ['values', 'issueTypes']);
86
+ const wanted = String(issueTypeName).toLowerCase();
87
+ const type = types.find((t) => t && t.name && String(t.name).toLowerCase() === wanted);
88
+ if (!type || !type.id) {
89
+ const listed = types.map((t) => t?.name).filter(Boolean).join(', ') || '(none)';
90
+ throw new JiraError(
91
+ `Issue type "${issueTypeName}" not found on project ${projectKey}. Available: ${listed}`,
92
+ 2,
93
+ );
94
+ }
95
+
96
+ // 2) Fetch the field list for that issue type.
97
+ const { data: fieldsResp } = await client.get(
98
+ `/rest/api/3/issue/createmeta/${encodeURIComponent(projectKey)}/issuetypes/${encodeURIComponent(type.id)}?maxResults=200`
99
+ );
100
+ const fields = extractList(fieldsResp, ['values', 'fields']);
101
+
102
+ // 3) Index required fields + pick out our four semantic ones by name.
103
+ const requiredFields = fields
104
+ .filter((f) => f && f.required)
105
+ .map((f) => normaliseField(f));
106
+
107
+ const semantic = {
108
+ envImpacted: findByName(fields, BUG_FIELD_NAMES.envImpacted),
109
+ expected: findByName(fields, BUG_FIELD_NAMES.expected),
110
+ actual: findByName(fields, BUG_FIELD_NAMES.actual),
111
+ steps: findByName(fields, BUG_FIELD_NAMES.steps),
112
+ };
113
+
114
+ const meta = { issueTypeId: type.id, requiredFields, semantic };
115
+ metaCache.set(cacheKey, meta);
116
+ return meta;
117
+ }
118
+
119
+ /**
120
+ * Given parsed CLI args and the createmeta, build the `fields` fragment
121
+ * the four semantic flags contribute (envImpacted / expected / actual /
122
+ * steps). Also returns a `preflight` array of required-field names that
123
+ * are STILL missing after the fragment is applied, so the caller can
124
+ * fail fast with a helpful error instead of relaying a Jira 400.
125
+ *
126
+ * A required field is considered satisfied when EITHER a semantic flag
127
+ * supplied a value, OR an existing `--field` / `--field-adf` pair
128
+ * already set the same customfield id.
129
+ *
130
+ * @param {object} args parseArgs output
131
+ * @param {object} meta fetchBugCreateMeta() output
132
+ * @param {object} existingFields fields already collected (via --field etc.)
133
+ * @returns {{ fields: object, preflight: string[] }}
134
+ */
135
+ export function buildBugFieldsPatch(args, meta, existingFields = {}) {
136
+ const fields = {};
137
+
138
+ // --env-impacted (repeatable + comma-separated) → multiselect array.
139
+ const envValues = flattenListArg(args['env-impacted']);
140
+ if (envValues.length > 0) {
141
+ if (!meta.semantic.envImpacted) {
142
+ throw new JiraError(
143
+ `Cannot map --env-impacted: field "${BUG_FIELD_NAMES.envImpacted}" not found on project's Bug createmeta.`,
144
+ 2,
145
+ );
146
+ }
147
+ const { id, allowedValues } = meta.semantic.envImpacted;
148
+ const allowedByLower = new Map(
149
+ (allowedValues || []).map((v) => [String(v).toLowerCase(), v]),
150
+ );
151
+ if (allowedByLower.size === 0) {
152
+ throw new JiraError(
153
+ `--env-impacted cannot be validated: createmeta returned no allowed values for "${BUG_FIELD_NAMES.envImpacted}".`,
154
+ 2,
155
+ );
156
+ }
157
+ const resolved = [];
158
+ for (const raw of envValues) {
159
+ const match = allowedByLower.get(raw.toLowerCase());
160
+ if (!match) {
161
+ const allowedList = Array.from(allowedByLower.values()).join(', ');
162
+ throw new JiraError(
163
+ `--env-impacted "${raw}" is not an allowed value. Allowed (from createmeta): ${allowedList}`,
164
+ 1,
165
+ );
166
+ }
167
+ // Multiselect is an ARRAY of option objects. Duplicates de-duped.
168
+ if (!resolved.some((r) => r.value === match)) {
169
+ resolved.push({ value: match });
170
+ }
171
+ }
172
+ fields[id] = resolved;
173
+ }
174
+
175
+ // Textarea customfields → ADF. Semantic flags simply wrap plain text.
176
+ const textareaFlags = [
177
+ ['expected', args.expected],
178
+ ['actual', args.actual],
179
+ ['steps', args.steps],
180
+ ];
181
+ for (const [key, raw] of textareaFlags) {
182
+ if (raw == null || raw === '' || raw === false) continue;
183
+ const semanticField = meta.semantic[key];
184
+ if (!semanticField) {
185
+ throw new JiraError(
186
+ `Cannot map ${BUG_FLAG_LABELS[key]}: field "${BUG_FIELD_NAMES[key]}" not found on project's Bug createmeta.`,
187
+ 2,
188
+ );
189
+ }
190
+ fields[semanticField.id] = textToAdf(String(raw));
191
+ }
192
+
193
+ // Preflight: any required field that neither the fragment nor the
194
+ // caller's existingFields cover. Skip fields we cannot possibly ask
195
+ // for from a CLI (project / issuetype / summary / reporter are set
196
+ // by the create pipeline itself).
197
+ const alwaysProvided = new Set(['project', 'issuetype', 'summary', 'reporter']);
198
+ const covered = new Set([...Object.keys(existingFields), ...Object.keys(fields)]);
199
+ const preflight = [];
200
+ for (const rf of meta.requiredFields) {
201
+ if (alwaysProvided.has(rf.id)) continue;
202
+ if (covered.has(rf.id)) continue;
203
+ preflight.push(rf);
204
+ }
205
+
206
+ return { fields, preflight };
207
+ }
208
+
209
+ /**
210
+ * Render the preflight list as an operator-facing error message that
211
+ * names each missing required field, its id, and - where we know one -
212
+ * the semantic flag that would supply it. Missing fields with no
213
+ * dedicated flag are pointed at `--field`/`--field-adf` as the fallback.
214
+ *
215
+ * @param {Array} preflight list of normalised required-field objects
216
+ * @returns {string}
217
+ */
218
+ export function formatBugPreflightError(preflight) {
219
+ const nameToFlag = new Map();
220
+ for (const [key, name] of Object.entries(BUG_FIELD_NAMES)) {
221
+ nameToFlag.set(name.toLowerCase(), BUG_FLAG_LABELS[key]);
222
+ }
223
+ const lines = ['Cannot create Bug - required fields missing:'];
224
+ for (const rf of preflight) {
225
+ const flag = nameToFlag.get(String(rf.name).toLowerCase());
226
+ if (flag) {
227
+ lines.push(` - ${rf.name} (${rf.id}) - pass ${flag} VALUE`);
228
+ } else {
229
+ const shape = describeFieldShape(rf);
230
+ lines.push(` - ${rf.name} (${rf.id}) - pass --field '${rf.id}=${shape}'`);
231
+ }
232
+ }
233
+ return lines.join('\n');
234
+ }
235
+
236
+ // Test-only cache reset.
237
+ export function _resetBugMetaCacheForTests() {
238
+ metaCache.clear();
239
+ }
240
+
241
+ // ── internals ────────────────────────────────────────────────────────
242
+
243
+ function extractList(resp, keys) {
244
+ if (Array.isArray(resp)) return resp;
245
+ if (!resp || typeof resp !== 'object') return [];
246
+ for (const k of keys) {
247
+ if (Array.isArray(resp[k])) return resp[k];
248
+ }
249
+ return [];
250
+ }
251
+
252
+ function normaliseField(f) {
253
+ const id = f.fieldId || f.key || f.id;
254
+ const allowedValues = Array.isArray(f.allowedValues)
255
+ ? f.allowedValues.map((v) => v.value || v.name || v.id).filter(Boolean)
256
+ : null;
257
+ return {
258
+ id,
259
+ name: f.name,
260
+ schema: f.schema || null,
261
+ required: !!f.required,
262
+ allowedValues,
263
+ };
264
+ }
265
+
266
+ function findByName(fields, name) {
267
+ const wanted = String(name).toLowerCase();
268
+ const found = fields.find(
269
+ (f) => f && f.name && String(f.name).toLowerCase() === wanted,
270
+ );
271
+ return found ? normaliseField(found) : null;
272
+ }
273
+
274
+ /** Coerce a repeatable+comma-separated flag to an array of trimmed strings. */
275
+ function flattenListArg(v) {
276
+ if (v == null || v === false) return [];
277
+ const raws = Array.isArray(v) ? v : [v];
278
+ const out = [];
279
+ for (const r of raws) {
280
+ for (const part of String(r).split(',')) {
281
+ const t = part.trim();
282
+ if (t) out.push(t);
283
+ }
284
+ }
285
+ return out;
286
+ }
287
+
288
+ function describeFieldShape(rf) {
289
+ const t = rf.schema && rf.schema.type;
290
+ if (t === 'array') return '[{"value":"…"}]';
291
+ if (t === 'option') return '{"value":"…"}';
292
+ if (t === 'user') return '{"accountId":"…"}';
293
+ return '…';
294
+ }