@peter.prib/renderengine 0.2.11 → 0.5.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/README.md +22 -6
- package/REFERENCE.md +132 -2
- package/dist/renderengine-workspace.umd.js +3346 -1245
- package/dist/renderengine.umd.js +1509 -158
- package/package.json +1 -1
- package/src/charting/ModelCheckDialog.js +10 -3
- package/src/charting/ModelComparisonDialog.js +10 -3
- package/src/charting/ModelFileFormat.js +4 -1
- package/src/charting/archimate/ArchimateCanvasEditor.js +14 -2
- package/src/charting/archimate/ArchimateElementTemplate.js +23 -0
- package/src/charting/arrangement/layouts.js +9 -4
- package/src/charting/datamodel/DataModel.js +18 -71
- package/src/charting/datamodel/DataModelCompared.js +1 -31
- package/src/charting/diagramModel/ListCompared.js +35 -0
- package/src/charting/model/Benchmark.js +59 -0
- package/src/charting/model/EditableModel.js +104 -0
- package/src/charting/{datamodel/DataModelEditCommand.js → model/ModelEditCommand.js} +47 -47
- package/src/charting/requirements/AzureDevOpsApi.js +55 -0
- package/src/charting/requirements/GitLabApi.js +43 -0
- package/src/charting/requirements/JiraApi.js +69 -0
- package/src/charting/requirements/RequirementsChecker.js +76 -0
- package/src/charting/requirements/RequirementsCompared.js +33 -0
- package/src/charting/requirements/RequirementsFileFormat.js +32 -0
- package/src/charting/requirements/RequirementsImportFormats.js +48 -0
- package/src/charting/requirements/RequirementsModel.js +305 -0
- package/src/charting/requirements/Storyboard.js +94 -0
- package/src/charting/requirements/TrackerApi.js +142 -0
- package/src/charting/requirements/TrackerExport.js +274 -0
- package/src/charting/requirements/index.js +12 -0
- package/src/charting/requirements/trackerApis.js +21 -0
- package/src/index.js +7 -0
- package/src/workspace/Workspace.js +329 -321
- package/src/workspace/index.js +2 -0
- package/src/workspace/parts/ArchimateCanvasPart.js +31 -1
- package/src/workspace/parts/DataModelPart.js +668 -735
- package/src/workspace/parts/EditorPart.js +1 -1
- package/src/workspace/parts/ModelFile.js +3 -1
- package/src/workspace/parts/ModelHosted.js +176 -0
- package/src/workspace/parts/ModelsPart.js +146 -7
- package/src/workspace/parts/RequirementsPart.js +465 -0
- package/src/workspace/storage/ModelLibrary.js +8 -2
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { EditableModel } from '../model/EditableModel.js';
|
|
2
|
+
import { EpicsCompared, ReleasesCompared, StoriesCompared } from './RequirementsCompared.js';
|
|
3
|
+
import { RequirementsChecker } from './RequirementsChecker.js';
|
|
4
|
+
import { Benchmark } from '../model/Benchmark.js';
|
|
5
|
+
|
|
6
|
+
/** A plain copy of JSON-shaped data. */
|
|
7
|
+
const copy = (value) => JSON.parse(JSON.stringify(value));
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* What a story can trace to, and what its trace is called: ArchiMate's
|
|
11
|
+
* motivation and implementation elements, any other element of an
|
|
12
|
+
* architecture, and a data model's concepts, entities and tables — so a trace
|
|
13
|
+
* runs epic → story → requirement → component → entity → table. A trace's
|
|
14
|
+
* `kind` is any string; these are the ones offered.
|
|
15
|
+
*/
|
|
16
|
+
const TRACE_KINDS = Object.freeze([
|
|
17
|
+
['requirement', 'Requirement (ArchiMate)'],
|
|
18
|
+
['work-package', 'Work package (ArchiMate)'],
|
|
19
|
+
['element', 'Architecture element'],
|
|
20
|
+
['concept', 'Data concept'],
|
|
21
|
+
['entity', 'Data entity'],
|
|
22
|
+
['table', 'Table'],
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
/** Where a story is in its life, in order; a status is any string, these are offered. */
|
|
26
|
+
const STATUSES = Object.freeze(['To do', 'In progress', 'Done']);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* RequirementsModel — what a system is to do, as an agile team writes it:
|
|
30
|
+
* **epics** (large outcomes), the **stories** that deliver them ("As a … I
|
|
31
|
+
* want … so that …"), each with its **acceptance criteria**, sliced into
|
|
32
|
+
* **releases**; and **traces** from a story to what realises it in the other
|
|
33
|
+
* models (TRACE_KINDS: an ArchiMate requirement or work package, an element,
|
|
34
|
+
* a data concept, entity or table — `{kind, ref, name?, model?}`, `ref` that
|
|
35
|
+
* model's id).
|
|
36
|
+
*
|
|
37
|
+
* Read as a **story map** (`storyMap()`): epics across in order, releases
|
|
38
|
+
* down, each cell the stories of that epic in that release; stories with no
|
|
39
|
+
* epic or no release have a column and a row of their own. `coverage()` and
|
|
40
|
+
* the checker (RequirementsChecker) say what is missing — a story with no
|
|
41
|
+
* acceptance criteria, nothing realising it — the numbers a bench measures.
|
|
42
|
+
*
|
|
43
|
+
* Spec (its toJSON): `{ name?, epics: [{id, name, description?}], releases:
|
|
44
|
+
* [{id, name}], stories: [{id, name, epic?, release?, as?, want?, soThat?,
|
|
45
|
+
* description?, criteria: [{id, text}], status?, priority?, points?, labels?,
|
|
46
|
+
* source?: {system, key, url?}, traces: [{kind, ref, name?, model?}]}] }`.
|
|
47
|
+
* Ids are made from names when not given. Every edit is a command (one undo
|
|
48
|
+
* step); compare and merge, and changed-since-saved, are EditableModel's.
|
|
49
|
+
*/
|
|
50
|
+
export class RequirementsModel extends EditableModel {
|
|
51
|
+
static STATE = Object.freeze(['name', 'epics', 'releases', 'stories']);
|
|
52
|
+
|
|
53
|
+
static TRACE_KINDS = TRACE_KINDS;
|
|
54
|
+
|
|
55
|
+
static STATUSES = STATUSES;
|
|
56
|
+
|
|
57
|
+
/** A story map's column for stories with no epic, and its row for those in no release. */
|
|
58
|
+
static NO_EPIC = '';
|
|
59
|
+
static NO_RELEASE = '';
|
|
60
|
+
|
|
61
|
+
constructor(spec = {}) {
|
|
62
|
+
super(spec);
|
|
63
|
+
this.epics = [];
|
|
64
|
+
this.releases = [];
|
|
65
|
+
this.stories = [];
|
|
66
|
+
for (const e of spec.epics ?? []) this.epics.push(this._epic(e));
|
|
67
|
+
for (const r of spec.releases ?? []) this.releases.push(this._release(r));
|
|
68
|
+
for (const s of spec.stories ?? []) this.stories.push(this._story(s));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ===== Reading =====
|
|
72
|
+
|
|
73
|
+
epic(id) { return this.epics.find(e => e.id === id) ?? null; }
|
|
74
|
+
release(id) { return this.releases.find(r => r.id === id) ?? null; }
|
|
75
|
+
story(id) { return this.stories.find(s => s.id === id) ?? null; }
|
|
76
|
+
|
|
77
|
+
/** The stories of an epic (NO_EPIC: of none), in order; with `release`, only those in it (NO_RELEASE: in none). */
|
|
78
|
+
storiesOf(epicId, release) {
|
|
79
|
+
return this.stories.filter(s => (s.epic || '') === (epicId || '')
|
|
80
|
+
&& (release === undefined || (s.release || '') === (release || '')));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The story's sentence: "As a … I want … so that …", from what it has. */
|
|
84
|
+
static narrative(story) {
|
|
85
|
+
return [story.as && `As ${/^(a|an|the)\b/i.test(story.as) ? '' : 'a '}${story.as}`, story.want && `I want ${story.want}`, story.soThat && `so that ${story.soThat}`]
|
|
86
|
+
.filter(Boolean).join(', ');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The story map: `columns` the epics in order (then NO_EPIC, if any story
|
|
91
|
+
* has none), `rows` the releases in order (then NO_RELEASE, if any story is
|
|
92
|
+
* in none, or there are no releases), and `cell(epicId, releaseId)` the
|
|
93
|
+
* stories there, in order.
|
|
94
|
+
*/
|
|
95
|
+
storyMap() {
|
|
96
|
+
const columns = [...this.epics.map(e => ({ id: e.id, name: e.name, epic: e }))];
|
|
97
|
+
if (this.stories.some(s => !this.epic(s.epic))) columns.push({ id: RequirementsModel.NO_EPIC, name: 'No epic', epic: null });
|
|
98
|
+
const rows = this.releases.map(r => ({ id: r.id, name: r.name, release: r }));
|
|
99
|
+
if (!rows.length || this.stories.some(s => !this.release(s.release))) rows.push({ id: RequirementsModel.NO_RELEASE, name: this.releases.length ? 'Not planned' : 'Stories', release: null });
|
|
100
|
+
const epicOf = (s) => (this.epic(s.epic) ? s.epic : RequirementsModel.NO_EPIC);
|
|
101
|
+
const releaseOf = (s) => (this.release(s.release) ? s.release : RequirementsModel.NO_RELEASE);
|
|
102
|
+
return {
|
|
103
|
+
columns, rows,
|
|
104
|
+
cell: (epicId, releaseId) => this.stories.filter(s => epicOf(s) === epicId && releaseOf(s) === releaseId),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The stories that trace to `ref` (with `kind`, only traces of that kind): what a change to it touches. */
|
|
109
|
+
tracingTo(ref, kind) {
|
|
110
|
+
return this.stories.filter(s => s.traces.some(t => t.ref === ref && (!kind || t.kind === kind)));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* How complete it is, as numbers to measure by: stories, how many have
|
|
115
|
+
* acceptance criteria, how many trace to something, epics with no stories,
|
|
116
|
+
* and each as a share (0..1; 1 with nothing to count).
|
|
117
|
+
*/
|
|
118
|
+
coverage() {
|
|
119
|
+
const stories = this.stories.length;
|
|
120
|
+
const withCriteria = this.stories.filter(s => s.criteria.length).length;
|
|
121
|
+
const traced = this.stories.filter(s => s.traces.length).length;
|
|
122
|
+
const emptyEpics = this.epics.filter(e => !this.storiesOf(e.id).length).length;
|
|
123
|
+
const share = (n, of) => (of ? n / of : 1);
|
|
124
|
+
return {
|
|
125
|
+
stories, withCriteria, traced, epics: this.epics.length, emptyEpics,
|
|
126
|
+
criteriaShare: share(withCriteria, stories), traceShare: share(traced, stories),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** What it is measured by (Benchmark): its check's findings, and how many stories say when they are done and trace to something. */
|
|
131
|
+
benchmark() {
|
|
132
|
+
const c = this.coverage();
|
|
133
|
+
return [
|
|
134
|
+
...Benchmark.findings(this),
|
|
135
|
+
{ key: 'criteria', label: 'Stories with acceptance criteria', value: c.criteriaShare, better: 'higher', format: 'share' },
|
|
136
|
+
{ key: 'traced', label: 'Stories traced', value: c.traceShare, better: 'higher', format: 'share' },
|
|
137
|
+
];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Its checks (RequirementsChecker). */
|
|
141
|
+
checker(options) {
|
|
142
|
+
return new RequirementsChecker(this, options);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
comparedKinds() {
|
|
146
|
+
return [new EpicsCompared(), new ReleasesCompared(), new StoriesCompared()];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ===== Editing (each an editCommand: one undo step) =====
|
|
150
|
+
|
|
151
|
+
/** Add an epic (`id` null) or change one. The command's result is the epic. */
|
|
152
|
+
saveEpicCommand(id, spec) {
|
|
153
|
+
return this.editCommand(id ? `Edit epic ${spec.name ?? id}` : `New epic ${spec.name ?? ''}`.trim(), (model) => {
|
|
154
|
+
if (!id) { const epic = model._epic(spec); model.epics.push(epic); return epic; }
|
|
155
|
+
const epic = model.epic(id);
|
|
156
|
+
if (epic) Object.assign(epic, model._epic({ ...epic, ...spec, id }));
|
|
157
|
+
return epic;
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Remove an epic; its stories stay, with no epic. */
|
|
162
|
+
removeEpicCommand(id) {
|
|
163
|
+
return this.editCommand(`Remove epic ${this.epic(id)?.name ?? id}`, (model) => {
|
|
164
|
+
model.epics = model.epics.filter(e => e.id !== id);
|
|
165
|
+
for (const s of model.stories) if (s.epic === id) s.epic = '';
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Add a release (`id` null) or rename one. */
|
|
170
|
+
saveReleaseCommand(id, spec) {
|
|
171
|
+
return this.editCommand(id ? `Edit release ${spec.name ?? id}` : `New release ${spec.name ?? ''}`.trim(), (model) => {
|
|
172
|
+
if (!id) { const release = model._release(spec); model.releases.push(release); return release; }
|
|
173
|
+
const release = model.release(id);
|
|
174
|
+
if (release) Object.assign(release, model._release({ ...release, ...spec, id }));
|
|
175
|
+
return release;
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Remove a release; its stories stay, planned in none. */
|
|
180
|
+
removeReleaseCommand(id) {
|
|
181
|
+
return this.editCommand(`Remove release ${this.release(id)?.name ?? id}`, (model) => {
|
|
182
|
+
model.releases = model.releases.filter(r => r.id !== id);
|
|
183
|
+
for (const s of model.stories) if (s.release === id) s.release = '';
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Add a story (`id` null) or change one — what the spec leaves out kept. The command's result is the story. */
|
|
188
|
+
saveStoryCommand(id, spec) {
|
|
189
|
+
return this.editCommand(id ? `Edit ${spec.name ?? this.story(id)?.name ?? id}` : `New story ${spec.name ?? ''}`.trim(), (model) => {
|
|
190
|
+
if (!id) { const story = model._story(spec); model.stories.push(story); return story; }
|
|
191
|
+
const at = model.stories.findIndex(s => s.id === id);
|
|
192
|
+
if (at < 0) return null;
|
|
193
|
+
model.stories[at] = model._story({ ...model.stories[at], ...spec, id });
|
|
194
|
+
return model.stories[at];
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
removeStoryCommand(id) {
|
|
199
|
+
return this.editCommand(`Remove ${this.story(id)?.name ?? id}`, (model) => {
|
|
200
|
+
model.stories = model.stories.filter(s => s.id !== id);
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Move a story on the map: to an epic and a release (NO_EPIC, NO_RELEASE:
|
|
206
|
+
* none), before another story there (`before`; none: last).
|
|
207
|
+
*/
|
|
208
|
+
moveStoryCommand(id, { epic, release, before = null }) {
|
|
209
|
+
return this.editCommand(`Move ${this.story(id)?.name ?? id}`, (model) => {
|
|
210
|
+
const story = model.story(id);
|
|
211
|
+
if (!story || before === id) return;
|
|
212
|
+
if (epic !== undefined) story.epic = epic || '';
|
|
213
|
+
if (release !== undefined) story.release = release || '';
|
|
214
|
+
model.stories.splice(model.stories.indexOf(story), 1);
|
|
215
|
+
const at = before ? model.stories.findIndex(s => s.id === before) : -1;
|
|
216
|
+
model.stories.splice(at < 0 ? model.stories.length : at, 0, story);
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Trace a story to something that realises it (`{kind, ref, name?, model?}`); once per kind and ref. */
|
|
221
|
+
traceCommand(storyId, trace) {
|
|
222
|
+
return this.editCommand(`Trace ${this.story(storyId)?.name ?? storyId} to ${trace.name || trace.ref}`, (model) => {
|
|
223
|
+
const story = model.story(storyId);
|
|
224
|
+
if (!story || story.traces.some(t => t.kind === trace.kind && t.ref === trace.ref)) return;
|
|
225
|
+
story.traces.push(RequirementsModel._trace(trace));
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Remove a story's trace to `ref` (of `kind`, if given). */
|
|
230
|
+
untraceCommand(storyId, ref, kind) {
|
|
231
|
+
return this.editCommand(`Remove the trace of ${this.story(storyId)?.name ?? storyId}`, (model) => {
|
|
232
|
+
const story = model.story(storyId);
|
|
233
|
+
if (story) story.traces = story.traces.filter(t => !(t.ref === ref && (!kind || t.kind === kind)));
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Bring epics, releases and stories in (an import): one undo step. One with
|
|
239
|
+
* the id of one there replaces it; the rest are added.
|
|
240
|
+
*/
|
|
241
|
+
bringCommand({ epics = [], releases = [], stories = [] }, label = 'Import') {
|
|
242
|
+
return this.editCommand(label, (model) => {
|
|
243
|
+
const put = (list, item) => {
|
|
244
|
+
const at = list.findIndex(x => x.id === item.id);
|
|
245
|
+
if (at < 0) list.push(item); else list[at] = item;
|
|
246
|
+
};
|
|
247
|
+
for (const e of epics) put(model.epics, model._epic(e));
|
|
248
|
+
for (const r of releases) put(model.releases, model._release(r));
|
|
249
|
+
for (const s of stories) put(model.stories, model._story(s));
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ===== Its parts, read from a spec =====
|
|
254
|
+
|
|
255
|
+
/** @private */
|
|
256
|
+
_epic({ id, name = '', description = '', source } = {}) {
|
|
257
|
+
return {
|
|
258
|
+
id: id ?? this._freshId(name || 'epic', this.epics), name, description,
|
|
259
|
+
...(source && { source: copy(source) }),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** @private */
|
|
264
|
+
_release({ id, name = '' } = {}) {
|
|
265
|
+
return { id: id ?? this._freshId(name || 'release', this.releases), name };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** @private */
|
|
269
|
+
_story(spec = {}) {
|
|
270
|
+
const { id, name = '', epic = '', release = '', as = '', want = '', soThat = '', description = '', status = '', priority = '', points = null, labels = [], source, criteria = [], traces = [] } = spec;
|
|
271
|
+
const story = {
|
|
272
|
+
id: id ?? this._freshId(name || 'story', this.stories), name, epic: epic ?? '', release: release ?? '',
|
|
273
|
+
as, want, soThat, description, status, priority,
|
|
274
|
+
points: Number.isFinite(Number(points)) && points !== null && points !== '' ? Number(points) : null,
|
|
275
|
+
labels: [...labels],
|
|
276
|
+
criteria: [],
|
|
277
|
+
traces: traces.map(t => RequirementsModel._trace(t)),
|
|
278
|
+
...(source && { source: copy(source) }),
|
|
279
|
+
};
|
|
280
|
+
// A new criterion is numbered past every id the story's criteria have, so none is given twice.
|
|
281
|
+
const given = new Set(criteria.map(c => (typeof c === 'object' && c?.id) || null).filter(Boolean));
|
|
282
|
+
let n = 0;
|
|
283
|
+
const fresh = () => { let id; do id = `${story.id}-ac${++n}`; while (given.has(id)); given.add(id); return id; };
|
|
284
|
+
for (const c of criteria) {
|
|
285
|
+
const text = typeof c === 'string' ? c : c?.text;
|
|
286
|
+
if (!String(text ?? '').trim()) continue;
|
|
287
|
+
story.criteria.push({ id: (typeof c === 'object' && c.id) || fresh(), text: String(text).trim() });
|
|
288
|
+
}
|
|
289
|
+
return story;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** @private */
|
|
293
|
+
static _trace({ kind = 'element', ref, name = '', model = '' } = {}) {
|
|
294
|
+
return { kind, ref: String(ref ?? ''), ...(name && { name }), ...(model && { model }) };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
toJSON() {
|
|
298
|
+
return {
|
|
299
|
+
name: this.name,
|
|
300
|
+
epics: copy(this.epics),
|
|
301
|
+
releases: copy(this.releases),
|
|
302
|
+
stories: copy(this.stories),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { criteriaFrom, narrativeFrom, withoutNarrative } from './TrackerExport.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Storyboard — epics and stories from a storyboard or requirements document
|
|
5
|
+
* written in Markdown (or plain text with Markdown headings), as teams keep
|
|
6
|
+
* them beside or before a tracker:
|
|
7
|
+
*
|
|
8
|
+
* - a heading that says "Epic" (`## Epic: Checkout`) is an epic, one that says
|
|
9
|
+
* "Story" (`### Story: Pay by card`) a story; otherwise the document's one
|
|
10
|
+
* title (`#`) is its name, the next level down are epics and the level
|
|
11
|
+
* below that stories;
|
|
12
|
+
* - under an epic, a list item that reads "As a … I want …" is a story too;
|
|
13
|
+
* - under a story, "As a … I want … so that …" is its narrative, the list
|
|
14
|
+
* under "Acceptance criteria" (or its Given/When/Then lines) its criteria,
|
|
15
|
+
* and the rest its description — a picture (``) kept there
|
|
16
|
+
* by its caption.
|
|
17
|
+
*
|
|
18
|
+
* `read(text)` gives a RequirementsModel spec (`{name, epics, stories}`); ids
|
|
19
|
+
* are made from names (prefix: `prefix`, so two storyboards read into one
|
|
20
|
+
* model stay apart).
|
|
21
|
+
*/
|
|
22
|
+
export class Storyboard {
|
|
23
|
+
/** @param {{prefix?: string}} [options] */
|
|
24
|
+
constructor({ prefix = '' } = {}) {
|
|
25
|
+
this.prefix = prefix;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
read(text) {
|
|
29
|
+
const lines = String(text ?? '').replace(/\r\n?/g, '\n').split('\n');
|
|
30
|
+
const headings = lines.map((line, at) => ({ at, m: /^(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line) })).filter(h => h.m)
|
|
31
|
+
.map(({ at, m }) => ({ at, level: m[1].length, text: m[2].trim() }));
|
|
32
|
+
const kindOf = (h) => (/^epic\b/i.test(h.text) ? 'epic' : /^(user\s+)?story\b/i.test(h.text) || /\bas an?\s+\S/i.test(h.text) ? 'story' : null);
|
|
33
|
+
const top = Math.min(...headings.map(h => h.level));
|
|
34
|
+
const tops = headings.filter(h => h.level === top);
|
|
35
|
+
// One top heading, a title (#), names the document; its children are the epics.
|
|
36
|
+
const named = top === 1 && tops.length === 1 && !kindOf(tops[0]) && headings.length > 1;
|
|
37
|
+
const epicLevel = named ? top + 1 : top;
|
|
38
|
+
const strip = (t) => t.replace(/^(epic|(user\s+)?story)\s*[:\-–—]?\s*/i, '').trim();
|
|
39
|
+
|
|
40
|
+
const epics = [], stories = [];
|
|
41
|
+
const ids = new Set();
|
|
42
|
+
const idFor = (name, kind) => {
|
|
43
|
+
const base = `${this.prefix}${String(name).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || kind}`;
|
|
44
|
+
let id = base;
|
|
45
|
+
for (let n = 2; ids.has(id); n++) id = `${base}-${n}`;
|
|
46
|
+
ids.add(id);
|
|
47
|
+
return id;
|
|
48
|
+
};
|
|
49
|
+
const bodyOf = (h, i) => lines.slice(h.at + 1, headings[i + 1]?.at ?? lines.length).join('\n');
|
|
50
|
+
let epic = null;
|
|
51
|
+
headings.forEach((h, i) => {
|
|
52
|
+
if (named && h === tops[0]) return;
|
|
53
|
+
const kind = kindOf(h) ?? (h.level === epicLevel ? 'epic' : h.level === epicLevel + 1 ? 'story' : null);
|
|
54
|
+
const body = bodyOf(h, i);
|
|
55
|
+
if (kind === 'epic') {
|
|
56
|
+
epic = { id: idFor(strip(h.text), 'epic'), name: strip(h.text), description: '' };
|
|
57
|
+
epics.push(epic);
|
|
58
|
+
const listed = this._listedStories(body);
|
|
59
|
+
epic.description = listed.rest;
|
|
60
|
+
for (const s of listed.stories) stories.push({ ...s, id: idFor(s.name, 'story'), epic: epic.id });
|
|
61
|
+
} else if (kind === 'story') {
|
|
62
|
+
stories.push({ ...this._story(strip(h.text), body), id: idFor(strip(h.text), 'story'), epic: epic?.id ?? '' });
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
if (!headings.length || (!epics.length && !stories.length)) {
|
|
66
|
+
for (const s of this._listedStories(lines.join('\n')).stories) stories.push({ ...s, id: idFor(s.name, 'story') });
|
|
67
|
+
}
|
|
68
|
+
return { name: named ? tops[0].text : '', epics, stories };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A story from its heading and what is under it. @private */
|
|
72
|
+
_story(name, body) {
|
|
73
|
+
const pictures = [...body.matchAll(/!\[([^\]]*)\]\([^)]*\)/g)].map(m => m[1]).filter(Boolean);
|
|
74
|
+
const text = body.replace(/!\[([^\]]*)\]\([^)]*\)/g, '').trim();
|
|
75
|
+
const said = narrativeFrom(text);
|
|
76
|
+
const { as, want, soThat } = said.want ? said : narrativeFrom(name);
|
|
77
|
+
const { criteria, rest } = criteriaFrom(text);
|
|
78
|
+
const description = [withoutNarrative(rest), ...pictures.map(c => `Picture: ${c}`)].filter(Boolean).join('\n');
|
|
79
|
+
return { name, as, want, soThat, description, criteria };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** "As a … I want …" list items: stories; the rest of the text, kept. @private */
|
|
83
|
+
_listedStories(body) {
|
|
84
|
+
const stories = [];
|
|
85
|
+
const rest = [];
|
|
86
|
+
for (const line of body.split('\n')) {
|
|
87
|
+
const item = /^\s*(?:[-*•+]|\d+[.)])\s+(.*)$/.exec(line);
|
|
88
|
+
const said = item && narrativeFrom(item[1]);
|
|
89
|
+
if (said?.want) stories.push({ name: said.want.replace(/^to\s+/i, '').replace(/^./, c => c.toUpperCase()), ...said, description: '', criteria: [] });
|
|
90
|
+
else rest.push(line);
|
|
91
|
+
}
|
|
92
|
+
return { stories, rest: rest.join('\n').trim() };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { TrackerExport, TRACKERS } from './TrackerExport.js';
|
|
2
|
+
|
|
3
|
+
/** Text as base64, whatever its characters (btoa takes only Latin-1). */
|
|
4
|
+
function base64(text) {
|
|
5
|
+
const bytes = new TextEncoder().encode(String(text));
|
|
6
|
+
let binary = '';
|
|
7
|
+
for (const b of bytes) binary += String.fromCharCode(b);
|
|
8
|
+
return btoa(binary);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* TrackerApi — a tracker read through its API rather than from an export: the
|
|
13
|
+
* issues a connection names, page by page, as the JSON `TrackerExport` reads
|
|
14
|
+
* (so an API's answer and a file saved from it make the same model). A
|
|
15
|
+
* subclass per tracker (`JiraApi`, `AzureDevOpsApi`, `GitLabApi`) says where
|
|
16
|
+
* its issues are, how it pages and how it is signed in to; what a connection
|
|
17
|
+
* asks for is `fields`, which a host builds its form from.
|
|
18
|
+
*
|
|
19
|
+
* `fetch` is given (a server's, or a test's): a tracker's token belongs where
|
|
20
|
+
* the request is made, which is seldom a browser. Nothing here keeps or logs
|
|
21
|
+
* the token; an error says what the tracker answered, not what was sent.
|
|
22
|
+
*/
|
|
23
|
+
export class TrackerApi {
|
|
24
|
+
/** The tracker's key in TRACKERS ('jira'). */
|
|
25
|
+
static tracker = null;
|
|
26
|
+
|
|
27
|
+
/** Most issues read from one connection: a connection naming a whole site is stopped there (`truncated`). */
|
|
28
|
+
static MAX_ISSUES = 5000;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* What a connection asks for, in order: `{key, label, type?, choices?,
|
|
32
|
+
* required?, secret?, placeholder?, hint?}` — `secret` written, never
|
|
33
|
+
* shown back. The tracker's own between its address and its token.
|
|
34
|
+
*/
|
|
35
|
+
static get fields() {
|
|
36
|
+
return [
|
|
37
|
+
{ key: 'name', label: 'Name', required: true, placeholder: 'What people pick it by, e.g. "Shop backlog"' },
|
|
38
|
+
{ key: 'baseUrl', label: 'Address', required: true, placeholder: this.baseUrlPlaceholder },
|
|
39
|
+
...this.ownFields,
|
|
40
|
+
{ key: 'token', label: 'Token', type: 'password', required: true, secret: true, hint: this.tokenHint },
|
|
41
|
+
];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** @protected */
|
|
45
|
+
static get ownFields() { return []; }
|
|
46
|
+
|
|
47
|
+
/** @protected */
|
|
48
|
+
static baseUrlPlaceholder = 'https://…';
|
|
49
|
+
|
|
50
|
+
/** @protected */
|
|
51
|
+
static tokenHint = 'An access token that may read the issues';
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @param {Object} connection - Its `fields`' values (and `columns`, a TrackerExport's own columns)
|
|
55
|
+
* @param {Object} [options]
|
|
56
|
+
* @param {Function} [options.fetch] - The fetch to read with (default: the global one)
|
|
57
|
+
* @param {number} [options.maxIssues] - Most issues read (default MAX_ISSUES)
|
|
58
|
+
*/
|
|
59
|
+
constructor(connection, { fetch = globalThis.fetch, maxIssues = new.target.MAX_ISSUES } = {}) {
|
|
60
|
+
this.connection = connection ?? {};
|
|
61
|
+
this.fetch = fetch;
|
|
62
|
+
this.maxIssues = maxIssues;
|
|
63
|
+
/** Whether the last read stopped at maxIssues. */
|
|
64
|
+
this.truncated = false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
get tracker() { return this.constructor.tracker; }
|
|
68
|
+
|
|
69
|
+
/** What the tracker is called ('Jira'). */
|
|
70
|
+
get label() { return TrackerExport.label(this.tracker); }
|
|
71
|
+
|
|
72
|
+
/** What is wrong with the connection, before anything is asked of the tracker: messages, none when it will do. */
|
|
73
|
+
problems() {
|
|
74
|
+
const c = this.connection;
|
|
75
|
+
const out = this.constructor.fields.filter(f => f.required && !String(c[f.key] ?? '').trim()).map(f => `${f.label} is needed.`);
|
|
76
|
+
if (c.baseUrl && !/^https?:\/\/[^\s/]+/i.test(String(c.baseUrl).trim())) out.push(`Address must start http:// or https:// ("${c.baseUrl}").`);
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The issues, as the tracker's API gives them (the JSON TrackerExport reads). */
|
|
81
|
+
async read() {
|
|
82
|
+
const problems = this.problems();
|
|
83
|
+
if (problems.length) throw new Error(`${this.label} connection "${this.connection.name ?? ''}": ${problems.join(' ')}`);
|
|
84
|
+
this.truncated = false;
|
|
85
|
+
return this._read();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The issues as a requirements model's spec, named as the connection is. */
|
|
89
|
+
async model() {
|
|
90
|
+
const json = await this.read();
|
|
91
|
+
const { tracker, ...spec } = new TrackerExport({ tracker: this.tracker, columns: this.connection.columns ?? {} }).read(json);
|
|
92
|
+
return { name: this.connection.name || `${this.label} issues`, ...spec };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** @protected @abstract */
|
|
96
|
+
async _read() {
|
|
97
|
+
throw new Error(`${this.constructor.name} does not say how to read ${this.label}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The headers that sign in. @protected @abstract */
|
|
101
|
+
headers() {
|
|
102
|
+
return {};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A path on the tracker's address. @protected */
|
|
106
|
+
url(path) {
|
|
107
|
+
return `${String(this.connection.baseUrl).trim().replace(/\/+$/, '')}${path}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* A request to the tracker, its answer's JSON and headers; an answer
|
|
112
|
+
* other than OK an error saying what the tracker said. @protected
|
|
113
|
+
*/
|
|
114
|
+
async request(url, { method = 'GET', body } = {}) {
|
|
115
|
+
if (typeof this.fetch !== 'function') throw new Error('No fetch to read the tracker with');
|
|
116
|
+
const res = await this.fetch(url, {
|
|
117
|
+
method,
|
|
118
|
+
headers: { Accept: 'application/json', ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}), ...this.headers() },
|
|
119
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
120
|
+
});
|
|
121
|
+
if (!res.ok) {
|
|
122
|
+
const text = await res.text().catch(() => '');
|
|
123
|
+
throw new Error(`${this.label} answered ${res.status}${res.statusText ? ` ${res.statusText}` : ''}${text ? `: ${text.slice(0, 300)}` : ''}`);
|
|
124
|
+
}
|
|
125
|
+
return { json: await res.json(), headers: res.headers };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Issues kept up to maxIssues; true once it is reached. @protected */
|
|
129
|
+
keep(into, more) {
|
|
130
|
+
for (const issue of more) {
|
|
131
|
+
if (into.length >= this.maxIssues) { this.truncated = true; return true; }
|
|
132
|
+
into.push(issue);
|
|
133
|
+
}
|
|
134
|
+
return into.length >= this.maxIssues;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** @protected */
|
|
138
|
+
static base64 = base64;
|
|
139
|
+
|
|
140
|
+
/** @protected — the tracker's issue types not read (TRACKERS: its sub-tasks). */
|
|
141
|
+
get skipTypes() { return TRACKERS[this.tracker]?.skipTypes ?? []; }
|
|
142
|
+
}
|