@mmerterden/multi-agent-pipeline 20.25.0 → 20.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/README.md +6 -7
- package/README.tr.md +6 -7
- package/docs/architecture.md +3 -3
- package/docs/ecosystem.md +5 -5
- package/docs/facts.json +4 -5
- package/install/catalog-history.json +1 -1
- package/manifest.json +36 -37
- package/package.json +1 -1
- package/pipeline/commands/multi-agent/channels/SKILL.md +4 -2
- package/pipeline/commands/multi-agent/sync/SKILL.md +3 -3
- package/pipeline/lib/_jira-auth.sh +3 -1
- package/pipeline/lib/jira-family-write.sh +339 -0
- package/pipeline/lib/jira-publish.sh +28 -1
- package/pipeline/multi-agent-refs/channels/jira.md +2 -0
- package/pipeline/multi-agent-refs/cross-cli-contract.md +4 -4
- package/pipeline/multi-agent-refs/features/jira-family.md +175 -0
- package/pipeline/multi-agent-refs/help/en.md +0 -1
- package/pipeline/multi-agent-refs/help/tr.md +0 -1
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +3 -1
- package/pipeline/multi-agent-refs/phases/phase-2-dev.md +0 -2
- package/pipeline/schemas/agent-state.schema.json +36 -5
- package/pipeline/schemas/phases.json +0 -4
- package/pipeline/schemas/prefs.schema.json +23 -23
- package/pipeline/scripts/jira-family.mjs +947 -0
- package/pipeline/scripts/jira-wiki-escape.mjs +3 -3
- package/pipeline/skills/.skill-manifest.json +5 -9
- package/pipeline/skills/.skills-index.json +1 -12
- package/pipeline/skills/shared/README.md +3 -4
- package/pipeline/skills/shared/core/multi-agent/SKILL.md +5 -0
- package/pipeline/skills/shared/core/multi-agent-channels/SKILL.md +7 -0
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +3 -3
- package/pipeline/skills/skills-index.md +1 -2
- package/pipeline/commands/multi-agent/deps/SKILL.md +0 -61
- package/pipeline/multi-agent-refs/features/dep-bump.md +0 -176
- package/pipeline/scripts/dep-bump.mjs +0 -1556
- package/pipeline/skills/shared/core/multi-agent-deps/SKILL.md +0 -60
|
@@ -0,0 +1,947 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* jira-family.mjs - the issue family of a run that started from a Jira link,
|
|
4
|
+
* and the writes the run owes it.
|
|
5
|
+
*
|
|
6
|
+
* A run can start from a development sub-task or from its parent story. Either
|
|
7
|
+
* way the work touches three issues: the story (whose description should be in
|
|
8
|
+
* the story format `/multi-agent:analysis-jira` writes), the development task
|
|
9
|
+
* (which gets the Phase 5 comment) and the test sub-task (which gets the test
|
|
10
|
+
* scenarios). This file is the deterministic half: it resolves the family from
|
|
11
|
+
* the issue JSON, checks and reformats the story description, builds the test
|
|
12
|
+
* comment and plans the writes. `lib/jira-family-write.sh` fetches and writes.
|
|
13
|
+
*
|
|
14
|
+
* A reformat never invents text. The existing lines are kept verbatim and only
|
|
15
|
+
* wrapped into panels and ordered: user story, acceptance criteria, the rest.
|
|
16
|
+
* A heading is added only to a block that had none, and `preserved` is false
|
|
17
|
+
* (and the write is dropped) if any original line would be lost. The author's
|
|
18
|
+
* lines are already Jira markup and pass through unescaped; only the text this
|
|
19
|
+
* file adds (macros, an added heading) is escaped, so the writer sends the body
|
|
20
|
+
* with `jira-publish.sh --raw`.
|
|
21
|
+
*
|
|
22
|
+
* Nothing here asks. An attended run shows every planned write in one grouped
|
|
23
|
+
* confirmation and drops the ones the person deselects (`--skip`); autopilot
|
|
24
|
+
* keeps them all and lists each choice it made in `decisions[]`. The one
|
|
25
|
+
* question left is which development sub-task, when a story has several.
|
|
26
|
+
*
|
|
27
|
+
* Every comment carries a `{anchor:...}` marker keyed on the run id. An anchor
|
|
28
|
+
* renders as nothing, and the writer skips a comment whose marker is already on
|
|
29
|
+
* the issue, so a re-run posts nothing twice.
|
|
30
|
+
*
|
|
31
|
+
* The settings this file consumes, by their full path:
|
|
32
|
+
* issueTree.familyWrites false keeps the single dev comment on the input issue
|
|
33
|
+
* issueTree.storyIssueTypes issue types that are stories (reformatted); empty = storyIssueType
|
|
34
|
+
* issueTree.storyIssueType the site's story type name, "Story" when unset
|
|
35
|
+
* issueTree.testSubtaskTypes sub-task type names that mean "test"; empty = discover
|
|
36
|
+
* issueTree.devSubtaskTypes sub-task type names that mean "development"; empty = match by name
|
|
37
|
+
* issueTree.storyPanels panel colours of the story format, or false for none
|
|
38
|
+
* global.outputLanguage tr or en: headings, questions and the confirmation line
|
|
39
|
+
* global.hosts.jira the host of the dev task link, when --host is not given
|
|
40
|
+
*
|
|
41
|
+
* Usage:
|
|
42
|
+
* jira-family.mjs resolve --input F [--story F] [--site-types F] [--prefs F] [--lang tr|en]
|
|
43
|
+
* jira-family.mjs plan --family F --dev-body F --run-id ID [--mode interactive|autopilot]
|
|
44
|
+
* [--pr-url URL] [--build TEXT] [--host HOST] [--skip id,id]
|
|
45
|
+
* [--dev-task KEY] [--test-task KEY] [--prefs F] [--lang tr|en]
|
|
46
|
+
* jira-family.mjs check --description F [--prefs F] exit 0 conforms, 1 does not
|
|
47
|
+
*
|
|
48
|
+
* Exit: 0 ok, 1 check: does not conform, 2 usage, 3 unreadable input.
|
|
49
|
+
*
|
|
50
|
+
* @module pipeline/scripts/jira-family
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
import { readFileSync } from "node:fs";
|
|
54
|
+
import { homedir } from "node:os";
|
|
55
|
+
import { join } from "node:path";
|
|
56
|
+
|
|
57
|
+
import { invokedDirectly } from "../lib/invoked-directly.mjs";
|
|
58
|
+
import { panelColours } from "./analysis-story-body.mjs";
|
|
59
|
+
import { escapeJiraEmoticons, wikiLink } from "./jira-wiki-escape.mjs";
|
|
60
|
+
|
|
61
|
+
const FAMILY_SETTINGS = [
|
|
62
|
+
"issueTree.familyWrites",
|
|
63
|
+
"issueTree.storyIssueTypes",
|
|
64
|
+
"issueTree.storyIssueType",
|
|
65
|
+
"issueTree.testSubtaskTypes",
|
|
66
|
+
"issueTree.devSubtaskTypes",
|
|
67
|
+
"issueTree.storyPanels",
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
const TEST_TYPE = /\btest\b/i;
|
|
71
|
+
const NOT_TEST_TYPE = /\b(unit|bug|defect|automation)\b/i;
|
|
72
|
+
const looksLikeTest = (name) => TEST_TYPE.test(name) && !NOT_TEST_TYPE.test(name);
|
|
73
|
+
const DEV_TYPE = /\bdev(elop(ment)?)?\b|geliştirme/i;
|
|
74
|
+
const CRITERIA_WORDS = /acceptance|criteria|kabul|kriter|\bAC\b/i;
|
|
75
|
+
const STORY_WORDS = /user story|hikaye|\bstory\b|açıklama|description|amaç|purpose|özet|summary/i;
|
|
76
|
+
const KIND_ORDER = { story: 0, criteria: 1, other: 2 };
|
|
77
|
+
const IMPACT_EXCERPT_MAX = 8;
|
|
78
|
+
const SUMMARY_EXCERPT_MAX = 5;
|
|
79
|
+
|
|
80
|
+
const TEXT = {
|
|
81
|
+
tr: {
|
|
82
|
+
storyHeading: "Kullanıcı Hikayesi",
|
|
83
|
+
otherHeading: "Diğer",
|
|
84
|
+
summary: "Geliştirme Özeti",
|
|
85
|
+
scenarios: "Test Senaryoları",
|
|
86
|
+
impact: "Etki Analizi",
|
|
87
|
+
refs: "Bağlantılar",
|
|
88
|
+
devTask: "Geliştirme görevi",
|
|
89
|
+
writes: "yazma",
|
|
90
|
+
labels: {
|
|
91
|
+
story: "story açıklaması",
|
|
92
|
+
dev: "dev yorumu",
|
|
93
|
+
test: "test yorumu",
|
|
94
|
+
"story-tests": "story'ye test yorumu",
|
|
95
|
+
},
|
|
96
|
+
devQ: "Story'nin birden fazla geliştirme alt görevi var. Geliştirme yorumu hangisine gitsin?",
|
|
97
|
+
testQ: "Story'nin birden fazla test alt görevi var. Test yorumu hangisine gitsin?",
|
|
98
|
+
},
|
|
99
|
+
en: {
|
|
100
|
+
storyHeading: "User story",
|
|
101
|
+
otherHeading: "Details",
|
|
102
|
+
summary: "Development Summary",
|
|
103
|
+
scenarios: "Test Scenarios",
|
|
104
|
+
impact: "Impact Analysis",
|
|
105
|
+
refs: "References",
|
|
106
|
+
devTask: "Development task",
|
|
107
|
+
writes: "writes",
|
|
108
|
+
labels: {
|
|
109
|
+
story: "story description",
|
|
110
|
+
dev: "dev comment",
|
|
111
|
+
test: "test comment",
|
|
112
|
+
"story-tests": "test comment on the story",
|
|
113
|
+
},
|
|
114
|
+
devQ: "The story has more than one development sub-task. Which one gets the development comment?",
|
|
115
|
+
testQ: "The story has more than one test sub-task. Which one gets the test comment?",
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const SUMMARY_HEADINGS = ["Geliştirme Özeti", "Development Summary"];
|
|
120
|
+
const SCENARIO_HEADINGS = ["Test Senaryoları", "Test Scenarios"];
|
|
121
|
+
const IMPACT_HEADINGS = ["Etki Analizi", "Impact Analysis"];
|
|
122
|
+
|
|
123
|
+
/** tr or en; anything else reads as en. */
|
|
124
|
+
export function language(value) {
|
|
125
|
+
return String(value || "").toLowerCase() === "tr" ? "tr" : "en";
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function prefsPath() {
|
|
129
|
+
return (
|
|
130
|
+
process.env.MULTI_AGENT_PREFS || join(homedir(), ".claude", "multi-agent-preferences.json")
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The family settings plus the output language, from a prefs file. Missing file: defaults. */
|
|
135
|
+
export function readFamilyPrefs(path = prefsPath()) {
|
|
136
|
+
let g;
|
|
137
|
+
try {
|
|
138
|
+
g = JSON.parse(readFileSync(path, "utf8"))?.global || {};
|
|
139
|
+
} catch {
|
|
140
|
+
g = {};
|
|
141
|
+
}
|
|
142
|
+
const out = {};
|
|
143
|
+
for (const p of FAMILY_SETTINGS) {
|
|
144
|
+
const leaf = p.split(".")[1];
|
|
145
|
+
const v = g.issueTree?.[leaf];
|
|
146
|
+
if (v !== undefined) out[leaf] = v;
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
familyWrites: out.familyWrites !== false,
|
|
150
|
+
storyIssueTypes:
|
|
151
|
+
Array.isArray(out.storyIssueTypes) && out.storyIssueTypes.length
|
|
152
|
+
? out.storyIssueTypes
|
|
153
|
+
: [
|
|
154
|
+
typeof out.storyIssueType === "string" && out.storyIssueType
|
|
155
|
+
? out.storyIssueType
|
|
156
|
+
: "Story",
|
|
157
|
+
],
|
|
158
|
+
testSubtaskTypes: Array.isArray(out.testSubtaskTypes) ? out.testSubtaskTypes : [],
|
|
159
|
+
devSubtaskTypes: Array.isArray(out.devSubtaskTypes) ? out.devSubtaskTypes : [],
|
|
160
|
+
storyPanels: out.storyPanels,
|
|
161
|
+
lang: language(g.outputLanguage),
|
|
162
|
+
host: typeof g.hosts?.jira === "string" ? g.hosts.jira : null,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const lower = (list) => list.map((s) => String(s).toLowerCase());
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The type names that mean "test": the configured list, else the site's own
|
|
170
|
+
* sub-task types (createmeta) whose name carries the word test.
|
|
171
|
+
*/
|
|
172
|
+
export function testTypeNames(siteSubtaskTypes, configured) {
|
|
173
|
+
if (Array.isArray(configured) && configured.length) return configured;
|
|
174
|
+
return (siteSubtaskTypes || []).filter(looksLikeTest);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** "test", "dev" or "other" for one sub-task type name. */
|
|
178
|
+
export function classifySubtask(typeName, { testTypes = [], devTypes = [] } = {}) {
|
|
179
|
+
const name = String(typeName || "");
|
|
180
|
+
const isTest = testTypes.length
|
|
181
|
+
? lower(testTypes).includes(name.toLowerCase())
|
|
182
|
+
: looksLikeTest(name);
|
|
183
|
+
if (isTest) return "test";
|
|
184
|
+
const isDev = devTypes.length
|
|
185
|
+
? lower(devTypes).includes(name.toLowerCase())
|
|
186
|
+
: DEV_TYPE.test(name);
|
|
187
|
+
return isDev ? "dev" : "other";
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// --- the story description -------------------------------------------------
|
|
191
|
+
|
|
192
|
+
const WIKI_HEADING = /^\s*h[1-6]\.\s+(.*\S)\s*$/;
|
|
193
|
+
const BOLD_HEADING = /^\s*\*([^*]{1,60})\*\s*:?\s*$/;
|
|
194
|
+
const LABEL_HEADING = /^\s*([\p{L}][\p{L} ]{2,40}):\s*$/u;
|
|
195
|
+
const WHOLE_PANEL = /^\s*\{panel(?::([^}]*))?\}\s*$/;
|
|
196
|
+
const LITERAL_TOKEN = /\{(code|noformat)(?::[^}]*)?\}/g;
|
|
197
|
+
const PAIRED_TOKEN = /\{(quote|color|panel)(:[^}]*)?\}/g;
|
|
198
|
+
|
|
199
|
+
const knownSection = (text) => CRITERIA_WORDS.test(text) || STORY_WORDS.test(text);
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The heading text of a line, or null: an `hN.` heading, or a bold-only or
|
|
203
|
+
* `Label:` line that names a known section (user story, acceptance criteria).
|
|
204
|
+
*/
|
|
205
|
+
function headingText(line) {
|
|
206
|
+
const w = line.match(WIKI_HEADING);
|
|
207
|
+
if (w) return w[1].trim();
|
|
208
|
+
const b = line.match(BOLD_HEADING) || line.match(LABEL_HEADING);
|
|
209
|
+
if (b && knownSection(b[1])) return b[1].trim();
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function kindOf(heading) {
|
|
214
|
+
if (CRITERIA_WORDS.test(heading)) return "criteria";
|
|
215
|
+
if (STORY_WORDS.test(heading)) return "story";
|
|
216
|
+
return "other";
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function borderColour(params) {
|
|
220
|
+
const m = String(params || "").match(/borderColor=(#[0-9a-fA-F]{6})/);
|
|
221
|
+
return m ? m[1].toLowerCase() : null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function panelTitle(params) {
|
|
225
|
+
const m = String(params || "").match(/(?:^|\|)\s*title=([^|]*)/);
|
|
226
|
+
return m && m[1].trim() ? m[1].trim() : null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The description as lines, with what the rest of the file needs to know
|
|
231
|
+
* about each: whether it is literal text (`{code}` / `{noformat}` body, any
|
|
232
|
+
* parameters), how deep it sits inside `{quote}` / `{color}` / inline `{panel}`
|
|
233
|
+
* pairs, and whether it is a `{panel}` macro alone on its line outside every
|
|
234
|
+
* such pair. Only those whole-line panels are containers this file may
|
|
235
|
+
* replace; everything else is the author's text.
|
|
236
|
+
*
|
|
237
|
+
* `ok` is false when the line endings mix, a pair is left open, or a
|
|
238
|
+
* whole-line panel opens inside another one: then nothing can be proven about
|
|
239
|
+
* the text and it must not be rewritten.
|
|
240
|
+
*/
|
|
241
|
+
export function scanDescription(description) {
|
|
242
|
+
const text = String(description ?? "");
|
|
243
|
+
const crlf = text.includes("\r\n");
|
|
244
|
+
const strayCr = (crlf ? text.replace(/\r\n/g, "") : text).includes("\r");
|
|
245
|
+
const lines = text.split(crlf ? "\r\n" : "\n");
|
|
246
|
+
const rows = [];
|
|
247
|
+
let literal = null;
|
|
248
|
+
let depth = 0;
|
|
249
|
+
const open = { quote: 0, color: 0, panel: 0 };
|
|
250
|
+
let wholePanel = false;
|
|
251
|
+
let ok = !strayCr;
|
|
252
|
+
for (const line of lines) {
|
|
253
|
+
const row = { line, literal: literal !== null, depth, panel: null };
|
|
254
|
+
if (literal === null && depth === 0) {
|
|
255
|
+
const pm = line.match(WHOLE_PANEL);
|
|
256
|
+
if (pm) {
|
|
257
|
+
const closes = wholePanel && pm[1] === undefined;
|
|
258
|
+
if (!closes && wholePanel) ok = false;
|
|
259
|
+
row.panel = closes ? { close: true } : { open: true, params: pm[1] ?? "" };
|
|
260
|
+
wholePanel = !closes;
|
|
261
|
+
rows.push(row);
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
let i = 0;
|
|
266
|
+
while (i < line.length) {
|
|
267
|
+
if (literal !== null) {
|
|
268
|
+
const close = line.indexOf(`{${literal}}`, i);
|
|
269
|
+
if (close < 0) break;
|
|
270
|
+
i = close + literal.length + 2;
|
|
271
|
+
literal = null;
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
LITERAL_TOKEN.lastIndex = i;
|
|
275
|
+
PAIRED_TOKEN.lastIndex = i;
|
|
276
|
+
const lit = LITERAL_TOKEN.exec(line);
|
|
277
|
+
const pair = PAIRED_TOKEN.exec(line);
|
|
278
|
+
if (!lit && !pair) break;
|
|
279
|
+
if (lit && (!pair || lit.index < pair.index)) {
|
|
280
|
+
literal = lit[1];
|
|
281
|
+
i = lit.index + lit[0].length;
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
const [, name, params] = pair;
|
|
285
|
+
if (name === "quote") {
|
|
286
|
+
open.quote = open.quote ? 0 : 1;
|
|
287
|
+
} else if (params || !open[name]) {
|
|
288
|
+
open[name]++;
|
|
289
|
+
} else {
|
|
290
|
+
open[name]--;
|
|
291
|
+
}
|
|
292
|
+
depth = open.quote + open.color + open.panel;
|
|
293
|
+
i = pair.index + pair[0].length;
|
|
294
|
+
}
|
|
295
|
+
row.endLiteral = literal !== null;
|
|
296
|
+
row.endDepth = depth;
|
|
297
|
+
rows.push(row);
|
|
298
|
+
}
|
|
299
|
+
if (literal !== null || depth !== 0 || wholePanel) ok = false;
|
|
300
|
+
return { rows, eol: crlf ? "\r\n" : "\n", ok };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Whether a scanned row can start a section: a heading outside every literal block and pair. */
|
|
304
|
+
function headingOf(row) {
|
|
305
|
+
return !row.literal && row.depth === 0 && !row.panel ? headingText(row.line) : null;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Whether a description is in the story format.
|
|
310
|
+
*
|
|
311
|
+
* With panels: every block sits in a whole-line panel whose border is one of
|
|
312
|
+
* the story colours, each panel opens with a heading, and the panels run user
|
|
313
|
+
* story, criteria, then the rest. A trailing `Sources:` line is allowed, as the
|
|
314
|
+
* analysis writer puts it there. Without panels (storyPanels false): no panel
|
|
315
|
+
* macros, and the text opens with a heading.
|
|
316
|
+
*
|
|
317
|
+
* @returns {{status: "conforms"|"nonconforming"|"empty", reasons: string[]}}
|
|
318
|
+
*/
|
|
319
|
+
export function checkStoryFormat(description, panelsSetting) {
|
|
320
|
+
const text = String(description ?? "");
|
|
321
|
+
if (!text.trim()) return { status: "empty", reasons: [] };
|
|
322
|
+
const colours = panelColours(panelsSetting);
|
|
323
|
+
const scan = scanDescription(text);
|
|
324
|
+
const reasons = [];
|
|
325
|
+
if (!scan.ok) reasons.push("unbalanced macros or mixed line endings");
|
|
326
|
+
const content = scan.rows.filter((r) => r.line.trim());
|
|
327
|
+
if (!colours) {
|
|
328
|
+
if (content.some((r) => r.panel)) reasons.push("panel macros present but panels are off");
|
|
329
|
+
if (!content.length || !headingOf(content[0])) reasons.push("content before the first heading");
|
|
330
|
+
const unique = [...new Set(reasons)];
|
|
331
|
+
return { status: unique.length ? "nonconforming" : "conforms", reasons: unique };
|
|
332
|
+
}
|
|
333
|
+
const kindByColour = {};
|
|
334
|
+
for (const k of ["other", "criteria", "story"]) kindByColour[colours[k].toLowerCase()] = k;
|
|
335
|
+
let inPanel = false;
|
|
336
|
+
let awaitingHeading = false;
|
|
337
|
+
let panels = 0;
|
|
338
|
+
let lastKind = -1;
|
|
339
|
+
for (const row of content) {
|
|
340
|
+
if (row.panel?.open) {
|
|
341
|
+
inPanel = true;
|
|
342
|
+
awaitingHeading = true;
|
|
343
|
+
panels++;
|
|
344
|
+
const kind = kindByColour[borderColour(row.panel.params)];
|
|
345
|
+
if (!kind) {
|
|
346
|
+
reasons.push("a panel colour that is not a story section colour");
|
|
347
|
+
} else {
|
|
348
|
+
if (KIND_ORDER[kind] < lastKind) reasons.push("sections out of order");
|
|
349
|
+
lastKind = Math.max(lastKind, KIND_ORDER[kind]);
|
|
350
|
+
}
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
if (row.panel?.close) {
|
|
354
|
+
if (awaitingHeading) reasons.push("an empty panel");
|
|
355
|
+
inPanel = false;
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
if (!inPanel) {
|
|
359
|
+
if (!/^Sources:/.test(row.line.trim())) reasons.push("text outside the panels");
|
|
360
|
+
} else if (awaitingHeading) {
|
|
361
|
+
if (!headingOf(row)) reasons.push("a panel without a heading");
|
|
362
|
+
awaitingHeading = false;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
if (!panels) reasons.push("no panels");
|
|
366
|
+
const unique = [...new Set(reasons)];
|
|
367
|
+
return { status: unique.length ? "nonconforming" : "conforms", reasons: unique };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* The description cut into sections at its own headings and at its
|
|
372
|
+
* whole-line panels. A heading inside a literal block or a pair never cuts.
|
|
373
|
+
* A panel's `title=` becomes the heading of its section; a section with no
|
|
374
|
+
* heading of its own gets one added (`added: true`).
|
|
375
|
+
*/
|
|
376
|
+
function sectionsOf(scan) {
|
|
377
|
+
const sections = [];
|
|
378
|
+
let current = null;
|
|
379
|
+
const start = (heading) => {
|
|
380
|
+
current = { heading, headingLine: null, lines: [] };
|
|
381
|
+
sections.push(current);
|
|
382
|
+
};
|
|
383
|
+
for (const row of scan.rows) {
|
|
384
|
+
if (row.panel?.open) {
|
|
385
|
+
start(panelTitle(row.panel.params));
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
if (row.panel?.close) {
|
|
389
|
+
current = null;
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
const h = headingOf(row);
|
|
393
|
+
if (h !== null) {
|
|
394
|
+
start(h);
|
|
395
|
+
current.headingLine = row.line;
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
if (!current) {
|
|
399
|
+
if (!row.line.trim()) continue;
|
|
400
|
+
start(null);
|
|
401
|
+
}
|
|
402
|
+
current.lines.push(row.line);
|
|
403
|
+
}
|
|
404
|
+
return sections
|
|
405
|
+
.map((s) => {
|
|
406
|
+
const lines = [...s.lines];
|
|
407
|
+
while (lines.length && !lines[0].trim()) lines.shift();
|
|
408
|
+
while (lines.length && !lines[lines.length - 1].trim()) lines.pop();
|
|
409
|
+
return { ...s, lines };
|
|
410
|
+
})
|
|
411
|
+
.filter((s) => s.heading !== null || s.lines.length);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** The non-blank lines of a description that are the author's text: everything but whole-line panels. */
|
|
415
|
+
function authorLines(description) {
|
|
416
|
+
return scanDescription(description)
|
|
417
|
+
.rows.filter((r) => !r.panel && r.line.trim())
|
|
418
|
+
.map((r) => r.line);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Whether every line of the author's text in `before` is in `after`, exactly
|
|
423
|
+
* and as many times as it was. Only whole-line panel macros may go.
|
|
424
|
+
*/
|
|
425
|
+
export function preservesContent(before, after) {
|
|
426
|
+
const have = new Map();
|
|
427
|
+
for (const l of String(after ?? "").split(/\r?\n/)) have.set(l, (have.get(l) || 0) + 1);
|
|
428
|
+
for (const l of authorLines(before)) {
|
|
429
|
+
const n = have.get(l) || 0;
|
|
430
|
+
if (!n) return false;
|
|
431
|
+
have.set(l, n - 1);
|
|
432
|
+
}
|
|
433
|
+
return true;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* The description in the story format, built from its own lines.
|
|
438
|
+
*
|
|
439
|
+
* Each line of the author's text is kept as it is, headings included at their
|
|
440
|
+
* own level; sections move as whole blocks into story, criteria, rest order.
|
|
441
|
+
* `preserved` is false, and the caller must not write, when the text cannot be
|
|
442
|
+
* proven to survive: unbalanced macros, mixed line endings, a lost line.
|
|
443
|
+
*
|
|
444
|
+
* @returns {{body: string, sections: {heading: string, kind: string, added: boolean}[], preserved: boolean}}
|
|
445
|
+
*/
|
|
446
|
+
export function reformatStory(description, { panels, lang = "en" } = {}) {
|
|
447
|
+
const T = TEXT[language(lang)];
|
|
448
|
+
const colours = panelColours(panels);
|
|
449
|
+
const scan = scanDescription(description);
|
|
450
|
+
const sections = sectionsOf(scan).map((s, i) => {
|
|
451
|
+
const added = s.heading === null;
|
|
452
|
+
const heading = added ? (i === 0 ? T.storyHeading : T.otherHeading) : s.heading;
|
|
453
|
+
const kind = added ? (i === 0 ? "story" : "other") : kindOf(heading);
|
|
454
|
+
const headingLine = s.headingLine ?? `h3. ${escapeJiraEmoticons(heading)}`;
|
|
455
|
+
return { ...s, heading, kind, added, headingLine };
|
|
456
|
+
});
|
|
457
|
+
const ordered = [...sections].sort((a, b) => KIND_ORDER[a.kind] - KIND_ORDER[b.kind]);
|
|
458
|
+
const out = [];
|
|
459
|
+
for (const s of ordered) {
|
|
460
|
+
const block = [s.headingLine, ...s.lines];
|
|
461
|
+
if (colours) out.push(`{panel:borderColor=${colours[s.kind]}}`, ...block, "{panel}", "");
|
|
462
|
+
else out.push(...block, "");
|
|
463
|
+
}
|
|
464
|
+
while (out.length && !out[out.length - 1]) out.pop();
|
|
465
|
+
const body = out.join(scan.eol);
|
|
466
|
+
return {
|
|
467
|
+
body,
|
|
468
|
+
sections: sections.map((s) => ({ heading: s.heading, kind: s.kind, added: s.added })),
|
|
469
|
+
preserved: scan.ok && preservesContent(description, body),
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// --- the family ------------------------------------------------------------
|
|
474
|
+
|
|
475
|
+
function subtaskRow(s) {
|
|
476
|
+
return {
|
|
477
|
+
key: s.key,
|
|
478
|
+
summary: s.fields?.summary ?? "",
|
|
479
|
+
type: s.fields?.issuetype?.name ?? "",
|
|
480
|
+
status: s.fields?.status?.name ?? "",
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* The family of the input issue.
|
|
486
|
+
*
|
|
487
|
+
* @param {object} input the input issue JSON (fields: issuetype, parent, subtasks, description)
|
|
488
|
+
* @param {object|null} story the parent's issue JSON when the input is a sub-task
|
|
489
|
+
* @param {{siteSubtaskTypes?: string[], prefs?: object}} [opts]
|
|
490
|
+
*/
|
|
491
|
+
export function resolveFamily(input, story, opts = {}) {
|
|
492
|
+
const prefs = opts.prefs || readFamilyPrefs("/nonexistent");
|
|
493
|
+
const f = input?.fields || {};
|
|
494
|
+
const isSub = f.issuetype?.subtask === true;
|
|
495
|
+
const storyIssue = isSub
|
|
496
|
+
? story || { key: f.parent?.key, fields: f.parent?.fields || {} }
|
|
497
|
+
: input;
|
|
498
|
+
const sf = storyIssue?.fields || {};
|
|
499
|
+
const testTypes = testTypeNames(opts.siteSubtaskTypes, prefs.testSubtaskTypes);
|
|
500
|
+
const types = { testTypes, devTypes: prefs.devSubtaskTypes };
|
|
501
|
+
const subtasks = (sf.subtasks || []).map(subtaskRow);
|
|
502
|
+
for (const s of subtasks) s.role = classifySubtask(s.type, types);
|
|
503
|
+
const tests = subtasks.filter((s) => s.role === "test");
|
|
504
|
+
const devs = subtasks.filter((s) => s.role === "dev");
|
|
505
|
+
|
|
506
|
+
let devTaskKey = null;
|
|
507
|
+
if (isSub) devTaskKey = input.key;
|
|
508
|
+
else if (devs.length === 1) devTaskKey = devs[0].key;
|
|
509
|
+
|
|
510
|
+
const known = typeof sf.description === "string" || sf.description === null;
|
|
511
|
+
const description = typeof sf.description === "string" ? sf.description : "";
|
|
512
|
+
const storyType = sf.issuetype?.name ?? "";
|
|
513
|
+
const storyTypes = lower(prefs.storyIssueTypes || ["Story"]);
|
|
514
|
+
let format;
|
|
515
|
+
if (storyType && !storyTypes.includes(storyType.toLowerCase())) {
|
|
516
|
+
format = { status: "not-a-story", reasons: [`${storyType} is not a story issue type`] };
|
|
517
|
+
} else if (known) {
|
|
518
|
+
format = checkStoryFormat(description, prefs.storyPanels);
|
|
519
|
+
} else {
|
|
520
|
+
format = { status: "unknown", reasons: ["the story was not fetched"] };
|
|
521
|
+
}
|
|
522
|
+
const reformat =
|
|
523
|
+
format.status === "nonconforming"
|
|
524
|
+
? reformatStory(description, { panels: prefs.storyPanels, lang: prefs.lang })
|
|
525
|
+
: null;
|
|
526
|
+
|
|
527
|
+
return {
|
|
528
|
+
inputKey: input?.key ?? null,
|
|
529
|
+
inputRole: isSub ? "subtask" : "story",
|
|
530
|
+
story: { key: storyIssue?.key ?? null, summary: sf.summary ?? "", description },
|
|
531
|
+
subtasks,
|
|
532
|
+
testTypes,
|
|
533
|
+
format: {
|
|
534
|
+
...format,
|
|
535
|
+
reformatted: reformat?.body ?? null,
|
|
536
|
+
preserved: reformat?.preserved ?? null,
|
|
537
|
+
},
|
|
538
|
+
stateEntry: {
|
|
539
|
+
inputKey: input?.key ?? null,
|
|
540
|
+
inputRole: isSub ? "subtask" : "story",
|
|
541
|
+
storyKey: storyIssue?.key ?? null,
|
|
542
|
+
devTaskKey,
|
|
543
|
+
devCandidates: devs.map((d) => d.key),
|
|
544
|
+
testTaskKey: tests.length === 1 ? tests[0].key : null,
|
|
545
|
+
testCandidates: tests.map((t) => t.key),
|
|
546
|
+
storyFormat: { status: format.status, reasons: format.reasons },
|
|
547
|
+
},
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// --- the comments ----------------------------------------------------------
|
|
552
|
+
|
|
553
|
+
const slug = (s) =>
|
|
554
|
+
String(s || "run")
|
|
555
|
+
.toLowerCase()
|
|
556
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
557
|
+
.replace(/^-+|-+$/g, "") || "run";
|
|
558
|
+
|
|
559
|
+
/** The hidden marker a comment of `role` carries for `runId`. */
|
|
560
|
+
export function marker(runId, role) {
|
|
561
|
+
return `{anchor:ma-family-${slug(role)}-${slug(runId)}}`;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Wiki markup uses `#` for list items, markdown for headings, so the dialect
|
|
565
|
+
// decides which lines are headings: a body with any `hN.` heading is wiki.
|
|
566
|
+
function headingPatterns(lines) {
|
|
567
|
+
const wiki = lines.some((l) => /^\s*h[1-6]\.\s/.test(l));
|
|
568
|
+
return wiki
|
|
569
|
+
? { h2: /^\s*h2\.\s+(.*\S)\s*$/, stop: /^\s*h[12]\.\s/ }
|
|
570
|
+
: { h2: /^\s*##\s+(.*\S)\s*$/, stop: /^\s*#{1,2}\s/ };
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** The lines under the first `##` / `h2.` heading named one of `names`, up to the next heading. */
|
|
574
|
+
export function sectionOf(body, names) {
|
|
575
|
+
const want = names.map((n) => n.toLowerCase());
|
|
576
|
+
const lines = String(body ?? "").split("\n");
|
|
577
|
+
const re = headingPatterns(lines);
|
|
578
|
+
const start = lines.findIndex((l) => {
|
|
579
|
+
const m = l.match(re.h2);
|
|
580
|
+
return Boolean(m && want.includes(m[1].toLowerCase()));
|
|
581
|
+
});
|
|
582
|
+
if (start < 0) return null;
|
|
583
|
+
const out = [];
|
|
584
|
+
for (const l of lines.slice(start + 1)) {
|
|
585
|
+
if (re.stop.test(l)) break;
|
|
586
|
+
out.push(l);
|
|
587
|
+
}
|
|
588
|
+
while (out.length && !out[0].trim()) out.shift();
|
|
589
|
+
while (out.length && !out[out.length - 1].trim()) out.pop();
|
|
590
|
+
return out;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/** Whether the scenarios section holds at least one step a tester can follow. */
|
|
594
|
+
export function hasScenarios(devBody) {
|
|
595
|
+
const s = sectionOf(devBody, SCENARIO_HEADINGS);
|
|
596
|
+
return Boolean(s && s.some((l) => /^\s*(#+|\d+\.)\s+\S/.test(l)));
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** The first paragraph of the dev comment's summary: what changed, in its own words. */
|
|
600
|
+
function summaryExcerpt(devBody) {
|
|
601
|
+
const s = sectionOf(devBody, SUMMARY_HEADINGS) || [];
|
|
602
|
+
const end = s.findIndex((l) => !l.trim());
|
|
603
|
+
return s.slice(0, end < 0 ? SUMMARY_EXCERPT_MAX : Math.min(end, SUMMARY_EXCERPT_MAX));
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** Part 3 of the impact section (where to test), else its first lines. */
|
|
607
|
+
function impactExcerpt(devBody) {
|
|
608
|
+
const s = sectionOf(devBody, IMPACT_HEADINGS);
|
|
609
|
+
if (!s) return [];
|
|
610
|
+
const part = (n) => (l) => new RegExp(`^\\s*\\*{0,2}${n}\\s*-`).test(l);
|
|
611
|
+
const from = s.findIndex(part(3));
|
|
612
|
+
if (from >= 0) {
|
|
613
|
+
const to = s.findIndex((l, i) => i > from && part(4)(l));
|
|
614
|
+
return s.slice(from, to < 0 ? undefined : to).filter((l, i, a) => l.trim() || i < a.length - 1);
|
|
615
|
+
}
|
|
616
|
+
return s.slice(0, IMPACT_EXCERPT_MAX);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* The test sub-task comment, in the dev comment's format: the same four
|
|
621
|
+
* sections in the same order. The summary's first paragraph (what changed),
|
|
622
|
+
* the test scenarios verbatim, part 3 of the impact analysis (where to look),
|
|
623
|
+
* and links to the development task, the PR and the build to test. Null
|
|
624
|
+
* without scenarios.
|
|
625
|
+
*/
|
|
626
|
+
export function buildTestComment({ devBody, lang, prUrl, build, devKey, host, runId, role }) {
|
|
627
|
+
if (!hasScenarios(devBody)) return null;
|
|
628
|
+
const T = TEXT[language(lang)];
|
|
629
|
+
const out = [];
|
|
630
|
+
const summary = summaryExcerpt(devBody);
|
|
631
|
+
if (summary.length) out.push(`h2. ${T.summary}`, ...summary, "");
|
|
632
|
+
out.push(`h2. ${T.scenarios}`, ...sectionOf(devBody, SCENARIO_HEADINGS), "");
|
|
633
|
+
const impact = impactExcerpt(devBody);
|
|
634
|
+
if (impact.length) out.push(`h2. ${T.impact}`, ...impact, "");
|
|
635
|
+
const refs = [];
|
|
636
|
+
if (devKey) {
|
|
637
|
+
const url = host
|
|
638
|
+
? `https://${String(host)
|
|
639
|
+
.replace(/^https?:\/\//, "")
|
|
640
|
+
.replace(/\/$/, "")}/browse/${devKey}`
|
|
641
|
+
: null;
|
|
642
|
+
refs.push(`* ${T.devTask}: ${url ? wikiLink(devKey, url) : devKey}`);
|
|
643
|
+
}
|
|
644
|
+
if (prUrl) refs.push(`* PR: ${wikiLink(prUrl, prUrl)}`);
|
|
645
|
+
if (build) refs.push(`* Build: ${String(build).replace(/\s+/g, " ").trim()}`);
|
|
646
|
+
if (refs.length) out.push(`h2. ${T.refs}`, ...refs, "");
|
|
647
|
+
out.push(marker(runId, role || "test"));
|
|
648
|
+
return out.join("\n");
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// --- the plan --------------------------------------------------------------
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* The writes the run owes the family, the questions an interactive run still
|
|
655
|
+
* has to ask before them, and the decisions an autopilot run took instead.
|
|
656
|
+
*
|
|
657
|
+
* @param {object} family resolveFamily output
|
|
658
|
+
* @param {{devBody: string, lang?: string, mode?: string, runId?: string, prUrl?: string,
|
|
659
|
+
* build?: string, host?: string, skip?: string[],
|
|
660
|
+
* devTask?: string, familyWrites?: boolean}} o
|
|
661
|
+
*/
|
|
662
|
+
export function planWrites(family, o) {
|
|
663
|
+
const lang = language(o.lang);
|
|
664
|
+
const T = TEXT[lang];
|
|
665
|
+
const autopilot = o.mode === "autopilot";
|
|
666
|
+
const st = family.stateEntry;
|
|
667
|
+
const writes = [];
|
|
668
|
+
const skipped = [];
|
|
669
|
+
const questions = [];
|
|
670
|
+
const decisions = [];
|
|
671
|
+
if (!o.runId) throw new Error("planWrites needs a run id: the comment markers are keyed on it");
|
|
672
|
+
const runId = o.runId;
|
|
673
|
+
const devBody = String(o.devBody ?? "").replace(/\s+$/, "");
|
|
674
|
+
const dev = (issue) => ({
|
|
675
|
+
id: "dev",
|
|
676
|
+
kind: "comment",
|
|
677
|
+
issue,
|
|
678
|
+
label: T.labels.dev,
|
|
679
|
+
marker: marker(runId, "dev"),
|
|
680
|
+
body: `${devBody}\n\n${marker(runId, "dev")}`,
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
if (o.familyWrites === false) {
|
|
684
|
+
writes.push(dev(st.inputKey));
|
|
685
|
+
return finish();
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
if (st.storyFormat.status === "not-a-story") {
|
|
689
|
+
skipped.push({ id: "story", reason: `not applicable: ${st.storyFormat.reasons.join("; ")}` });
|
|
690
|
+
if (autopilot) {
|
|
691
|
+
decisions.push({
|
|
692
|
+
writeId: null,
|
|
693
|
+
what: `Left the description of ${st.storyKey} as it is`,
|
|
694
|
+
why: `${st.storyFormat.reasons.join("; ")}; only a story is reformatted into the story format`,
|
|
695
|
+
costIfWrong:
|
|
696
|
+
"If this issue should follow the story format, it has to be reformatted by hand",
|
|
697
|
+
phase: 5,
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
if (st.storyFormat.status === "nonconforming") {
|
|
702
|
+
if (family.format.preserved !== true) {
|
|
703
|
+
skipped.push({
|
|
704
|
+
id: "story",
|
|
705
|
+
reason: "the reformat would not keep every line; left as it is",
|
|
706
|
+
});
|
|
707
|
+
} else {
|
|
708
|
+
writes.push({
|
|
709
|
+
id: "story",
|
|
710
|
+
kind: "description",
|
|
711
|
+
issue: st.storyKey,
|
|
712
|
+
label: T.labels.story,
|
|
713
|
+
body: family.format.reformatted,
|
|
714
|
+
original: family.story.description,
|
|
715
|
+
raw: true,
|
|
716
|
+
});
|
|
717
|
+
if (autopilot) {
|
|
718
|
+
decisions.push({
|
|
719
|
+
writeId: "story",
|
|
720
|
+
what: `Reformatted the description of ${st.storyKey} into the story format`,
|
|
721
|
+
why: `It did not follow the story format (${st.storyFormat.reasons.join("; ")}); the lines were kept verbatim and only wrapped into panels`,
|
|
722
|
+
costIfWrong:
|
|
723
|
+
"The story reads differently than its author left it; the previous description is backed up under jira-backups and can be put back",
|
|
724
|
+
phase: 5,
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
let devTarget = st.devTaskKey;
|
|
731
|
+
if (!devTarget && o.devTask && st.devCandidates.includes(o.devTask)) devTarget = o.devTask;
|
|
732
|
+
if (!devTarget && st.devCandidates.length > 1) {
|
|
733
|
+
if (autopilot) {
|
|
734
|
+
devTarget = st.inputKey;
|
|
735
|
+
decisions.push({
|
|
736
|
+
writeId: "dev",
|
|
737
|
+
what: `Posted the development comment on ${st.inputKey} instead of one of its development sub-tasks`,
|
|
738
|
+
why: `The story has ${st.devCandidates.length} development sub-tasks (${st.devCandidates.join(", ")}) and none is the run's own`,
|
|
739
|
+
costIfWrong:
|
|
740
|
+
"The developer looks for the comment on the sub-task and finds it on the story",
|
|
741
|
+
phase: 5,
|
|
742
|
+
});
|
|
743
|
+
} else {
|
|
744
|
+
questions.push({ id: "dev-task", question: T.devQ, options: st.devCandidates });
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
if (!devTarget && !questions.some((q) => q.id === "dev-task")) devTarget = st.inputKey;
|
|
748
|
+
if (devTarget) writes.push(dev(devTarget));
|
|
749
|
+
|
|
750
|
+
if (!hasScenarios(devBody)) {
|
|
751
|
+
skipped.push({ id: "test", reason: "no test scenarios" });
|
|
752
|
+
return finish();
|
|
753
|
+
}
|
|
754
|
+
const testArgs = {
|
|
755
|
+
devBody,
|
|
756
|
+
lang,
|
|
757
|
+
prUrl: o.prUrl,
|
|
758
|
+
build: o.build,
|
|
759
|
+
devKey: devTarget && devTarget !== st.storyKey ? devTarget : null,
|
|
760
|
+
host: o.host,
|
|
761
|
+
runId,
|
|
762
|
+
};
|
|
763
|
+
let testTarget = st.testTaskKey;
|
|
764
|
+
const testCandidates = st.testCandidates || [];
|
|
765
|
+
if (!testTarget && o.testTask && testCandidates.includes(o.testTask)) testTarget = o.testTask;
|
|
766
|
+
if (!testTarget && testCandidates.length > 1) {
|
|
767
|
+
if (!autopilot) {
|
|
768
|
+
questions.push({ id: "test-task", question: T.testQ, options: testCandidates });
|
|
769
|
+
return finish();
|
|
770
|
+
}
|
|
771
|
+
const exact = (family.subtasks || []).find(
|
|
772
|
+
(x) => testCandidates.includes(x.key) && String(x.type).toLowerCase() === "test",
|
|
773
|
+
);
|
|
774
|
+
testTarget = exact ? exact.key : testCandidates[0];
|
|
775
|
+
if (!exact) {
|
|
776
|
+
decisions.push({
|
|
777
|
+
writeId: "test",
|
|
778
|
+
what: `Posted the test comment on ${testTarget}, the first of ${testCandidates.length} test sub-tasks`,
|
|
779
|
+
why: `The story has ${testCandidates.length} test sub-tasks (${testCandidates.join(", ")}) and none is of the type Test`,
|
|
780
|
+
costIfWrong: "The tester looks on another test sub-task and does not find the scenarios",
|
|
781
|
+
phase: 5,
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
if (testTarget) {
|
|
786
|
+
writes.push({
|
|
787
|
+
id: "test",
|
|
788
|
+
kind: "comment",
|
|
789
|
+
issue: testTarget,
|
|
790
|
+
label: T.labels.test,
|
|
791
|
+
marker: marker(runId, "test"),
|
|
792
|
+
body: buildTestComment({ ...testArgs, role: "test" }),
|
|
793
|
+
});
|
|
794
|
+
return finish();
|
|
795
|
+
}
|
|
796
|
+
writes.push({
|
|
797
|
+
id: "story-tests",
|
|
798
|
+
kind: "comment",
|
|
799
|
+
issue: st.storyKey,
|
|
800
|
+
label: T.labels["story-tests"],
|
|
801
|
+
marker: marker(runId, "story-tests"),
|
|
802
|
+
body: buildTestComment({ ...testArgs, role: "story-tests" }),
|
|
803
|
+
});
|
|
804
|
+
if (autopilot) {
|
|
805
|
+
decisions.push({
|
|
806
|
+
writeId: "story-tests",
|
|
807
|
+
what: `Posted the test scenarios on ${st.storyKey}, which has no test sub-task`,
|
|
808
|
+
why: "Autopilot does not drop scenarios a tester needs, and the story is the issue a tester can find",
|
|
809
|
+
costIfWrong:
|
|
810
|
+
"The scenarios sit on the story until a test sub-task is opened and they are moved there",
|
|
811
|
+
phase: 5,
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
return finish();
|
|
815
|
+
|
|
816
|
+
function finish() {
|
|
817
|
+
const drop = new Set(o.skip || []);
|
|
818
|
+
for (const w of writes.filter((x) => drop.has(x.id))) {
|
|
819
|
+
skipped.push({ id: w.id, reason: "deselected" });
|
|
820
|
+
}
|
|
821
|
+
const kept = writes.filter((w) => !drop.has(w.id));
|
|
822
|
+
const keptDecisions = decisions.filter((d) => d.writeId === null || !drop.has(d.writeId));
|
|
823
|
+
const labels = kept.map((w) => w.label);
|
|
824
|
+
return {
|
|
825
|
+
version: 1,
|
|
826
|
+
lang,
|
|
827
|
+
mode: autopilot ? "autopilot" : "interactive",
|
|
828
|
+
runId,
|
|
829
|
+
storyPanels: o.storyPanels ?? null,
|
|
830
|
+
ready: questions.length === 0,
|
|
831
|
+
confirm: `${kept.length} ${T.writes}: ${labels.join(", ")}`,
|
|
832
|
+
writes: kept,
|
|
833
|
+
skipped,
|
|
834
|
+
questions,
|
|
835
|
+
decisions: keptDecisions,
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// --- CLI -------------------------------------------------------------------
|
|
841
|
+
|
|
842
|
+
class UsageError extends Error {}
|
|
843
|
+
|
|
844
|
+
function parseArgs(argv) {
|
|
845
|
+
const opts = {};
|
|
846
|
+
for (let i = 0; i < argv.length; i++) {
|
|
847
|
+
const a = argv[i];
|
|
848
|
+
if (!a.startsWith("--")) throw new UsageError(`unexpected argument ${a}`);
|
|
849
|
+
const v = argv[i + 1];
|
|
850
|
+
if (v === undefined || v.startsWith("--")) throw new UsageError(`${a} needs a value`);
|
|
851
|
+
opts[a.slice(2)] = v;
|
|
852
|
+
i++;
|
|
853
|
+
}
|
|
854
|
+
return opts;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function readJson(path) {
|
|
858
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function readLines(path) {
|
|
862
|
+
try {
|
|
863
|
+
return readFileSync(path, "utf8")
|
|
864
|
+
.split("\n")
|
|
865
|
+
.map((l) => l.trim())
|
|
866
|
+
.filter(Boolean);
|
|
867
|
+
} catch {
|
|
868
|
+
return [];
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
const ENUMS = {
|
|
873
|
+
mode: ["interactive", "autopilot"],
|
|
874
|
+
lang: ["tr", "en"],
|
|
875
|
+
};
|
|
876
|
+
|
|
877
|
+
const HELP = `usage:
|
|
878
|
+
jira-family.mjs resolve --input F [--story F] [--site-types F] [--prefs F] [--lang tr|en]
|
|
879
|
+
jira-family.mjs plan --family F --dev-body F --run-id ID [--mode interactive|autopilot]
|
|
880
|
+
[--pr-url URL] [--build TEXT] [--host HOST] [--skip id,id]
|
|
881
|
+
[--dev-task KEY] [--test-task KEY] [--prefs F] [--lang tr|en]
|
|
882
|
+
jira-family.mjs check --description F [--prefs F]
|
|
883
|
+
Contract: multi-agent-refs/features/jira-family.md`;
|
|
884
|
+
|
|
885
|
+
function main(argv) {
|
|
886
|
+
const cmd = argv[0];
|
|
887
|
+
if (!cmd || cmd === "help" || argv.includes("--help") || argv.includes("-h")) {
|
|
888
|
+
process.stdout.write(`${HELP}\n`);
|
|
889
|
+
return cmd ? 0 : 2;
|
|
890
|
+
}
|
|
891
|
+
const o = parseArgs(argv.slice(1));
|
|
892
|
+
for (const [k, allowed] of Object.entries(ENUMS)) {
|
|
893
|
+
if (o[k] !== undefined && !allowed.includes(o[k])) {
|
|
894
|
+
throw new UsageError(`--${k} must be one of ${allowed.join(", ")}`);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
const prefs = readFamilyPrefs(o.prefs || prefsPath());
|
|
898
|
+
if (o.lang) prefs.lang = o.lang;
|
|
899
|
+
if (cmd === "check") {
|
|
900
|
+
if (!o.description) throw new UsageError("check needs --description FILE");
|
|
901
|
+
const r = checkStoryFormat(readFileSync(o.description, "utf8"), prefs.storyPanels);
|
|
902
|
+
process.stdout.write(`${JSON.stringify(r)}\n`);
|
|
903
|
+
return r.status === "conforms" ? 0 : 1;
|
|
904
|
+
}
|
|
905
|
+
if (cmd === "resolve") {
|
|
906
|
+
if (!o.input) throw new UsageError("resolve needs --input FILE");
|
|
907
|
+
const input = readJson(o.input);
|
|
908
|
+
const story = o.story ? readJson(o.story) : null;
|
|
909
|
+
const fam = resolveFamily(input, story, {
|
|
910
|
+
siteSubtaskTypes: o["site-types"] ? readLines(o["site-types"]) : [],
|
|
911
|
+
prefs,
|
|
912
|
+
});
|
|
913
|
+
process.stdout.write(`${JSON.stringify({ ...fam, lang: prefs.lang }, null, 2)}\n`);
|
|
914
|
+
return 0;
|
|
915
|
+
}
|
|
916
|
+
if (cmd === "plan") {
|
|
917
|
+
if (!o.family || !o["dev-body"] || !o["run-id"]) {
|
|
918
|
+
throw new UsageError("plan needs --family FILE --dev-body FILE --run-id ID");
|
|
919
|
+
}
|
|
920
|
+
const plan = planWrites(readJson(o.family), {
|
|
921
|
+
devBody: readFileSync(o["dev-body"], "utf8"),
|
|
922
|
+
lang: prefs.lang,
|
|
923
|
+
mode: o.mode,
|
|
924
|
+
runId: o["run-id"],
|
|
925
|
+
prUrl: o["pr-url"],
|
|
926
|
+
build: o.build,
|
|
927
|
+
host: o.host || prefs.host,
|
|
928
|
+
skip: o.skip ? o.skip.split(",").map((x) => x.trim()) : [],
|
|
929
|
+
devTask: o["dev-task"],
|
|
930
|
+
testTask: o["test-task"],
|
|
931
|
+
storyPanels: prefs.storyPanels,
|
|
932
|
+
familyWrites: prefs.familyWrites,
|
|
933
|
+
});
|
|
934
|
+
process.stdout.write(`${JSON.stringify(plan, null, 2)}\n`);
|
|
935
|
+
return 0;
|
|
936
|
+
}
|
|
937
|
+
throw new UsageError("usage: jira-family.mjs resolve|plan|check ...");
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
if (invokedDirectly(import.meta.url)) {
|
|
941
|
+
try {
|
|
942
|
+
process.exitCode = main(process.argv.slice(2));
|
|
943
|
+
} catch (err) {
|
|
944
|
+
process.stderr.write(`jira-family: ${err.message}\n`);
|
|
945
|
+
process.exitCode = err instanceof UsageError ? 2 : 3;
|
|
946
|
+
}
|
|
947
|
+
}
|