@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.
- package/README.md +12 -2
- package/bin/confluence +9 -0
- package/bin/confluence-fetch +9 -0
- package/bin/jira +9 -0
- package/dist/engineering-pa-core/knowledge/content-voice/banned-ai-tells-taxonomy.md +1 -1
- package/dist/engineering-pa-core/knowledge/knowledge-memory/staging-and-observations.md +3 -3
- package/dist/engineering-pa-core/knowledge/platform-tooling/workspace-extensions.md +62 -0
- package/dist/engineering-pa-core-extensions-atlassian/README.md +81 -16
- package/dist/engineering-pa-core-extensions-atlassian/bin/confluence +2 -0
- package/dist/engineering-pa-core-extensions-atlassian/bin/confluence-fetch +2 -0
- package/dist/engineering-pa-core-extensions-atlassian/bin/jira +2 -0
- package/dist/engineering-pa-core-extensions-atlassian/config/jira-workflow-chains.default.json +4 -0
- package/dist/engineering-pa-core-extensions-atlassian/index.mjs +17 -6
- package/dist/engineering-pa-core-extensions-atlassian/package.json +11 -4
- package/dist/engineering-pa-core-extensions-atlassian/src/common/invalidate-client.mjs +137 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/common/pa-name.mjs +58 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/common/secrets.mjs +158 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/cli.mjs +165 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/client.mjs +294 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/auth.mjs +59 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/comment.mjs +174 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/create.mjs +136 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/label.mjs +98 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/mine.mjs +105 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/mirror-folder.mjs +487 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/mirror-page.mjs +351 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/page.mjs +127 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/pages.mjs +107 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/recent.mjs +107 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/search.mjs +123 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/spaces.mjs +82 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/test.mjs +155 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/tree.mjs +103 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/update.mjs +134 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/format.mjs +260 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/mirror.mjs +353 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/storage.mjs +880 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence-fetch/cli.mjs +313 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence-fetch/fetch.mjs +327 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/adf.mjs +388 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/bug-fields.mjs +294 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/cli.mjs +197 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/client.mjs +350 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/advance.mjs +486 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/attach.mjs +129 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/auth.mjs +53 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/boards.mjs +88 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/bulk-transition.mjs +223 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/cancel.mjs +135 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/comment.mjs +169 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/create.mjs +376 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/issue.mjs +105 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/link.mjs +204 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/mine.mjs +97 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/projects.mjs +91 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/search.mjs +80 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/sprint.mjs +111 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/sync-statuses.mjs +313 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/test.mjs +142 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/transition.mjs +197 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/update.mjs +284 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/worklog.mjs +380 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/format.mjs +267 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/invalidate.mjs +72 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/metadata.mjs +225 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/required-fields.mjs +334 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/workflow-chains.mjs +405 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/workflow-map.mjs +107 -0
- package/dist/engineering-pa-scaffold/README.md +32 -1
- package/dist/engineering-pa-scaffold/src/cli.mjs +83 -6
- package/dist/engineering-pa-scaffold/src/commands/init-workspace-extension.mjs +234 -0
- package/dist/engineering-pa-scaffold/src/commands/init.mjs +6 -5
- package/dist/engineering-pa-scaffold/src/generator.mjs +240 -4
- package/dist/engineering-pa-scaffold/templates/CLAUDE.md.template +7 -7
- package/dist/engineering-pa-scaffold/templates/operator-README.md.template +4 -4
- package/dist/engineering-pa-scaffold/templates/root-CLAUDE.md.template +13 -0
- package/dist/engineering-pa-types-engineering/index.mjs +14 -6
- package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/create-a-small-app.md +81 -0
- package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/for-agents.md +25 -0
- package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/index.md +34 -0
- package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/reference.md +16 -0
- package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/wire-your-agent.md +17 -0
- package/dist/engineering-pa-types-engineering/package.json +2 -2
- package/dist/engineering-pa-types-engineering/roles/rcf-lite-engineer/definition.md +183 -0
- package/dist/engineering-pa-workspace/app/home.html +7 -7
- package/dist/engineering-pa-workspace/app/onboarding.html +4 -4
- package/dist/engineering-pa-workspace/src/api.mjs +1 -1
- package/dist/engineering-pa-workspace/src/server.mjs +1 -1
- package/docs/installing.md +78 -0
- package/docs/onboarding-solo.md +162 -0
- package/docs/what-it-can-do.md +85 -0
- 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
|
+
}
|