@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
package/dist/renderengine.umd.js
CHANGED
|
@@ -19756,14 +19756,19 @@
|
|
|
19756
19756
|
|
|
19757
19757
|
/**
|
|
19758
19758
|
* Fruchterman–Reingold: lines pull their ends together, every pair of boxes
|
|
19759
|
-
* pushes apart, cooling over the iterations
|
|
19760
|
-
* where the boxes
|
|
19759
|
+
* pushes apart, cooling over the iterations, all within a frame — a square
|
|
19760
|
+
* round where the boxes were, room for them a grid's worth — so a box with no
|
|
19761
|
+
* lines is not pushed away for ever. Deterministic: it starts from where the
|
|
19762
|
+
* boxes are (boxes on one spot are spread round it first).
|
|
19761
19763
|
*/
|
|
19762
19764
|
function forceDirected(boxes, edges, gap) {
|
|
19763
19765
|
const size = Math.max(...boxes.map(b => Math.max(b.width, b.height))) + gap;
|
|
19764
19766
|
const k = size * 1.2;
|
|
19765
19767
|
const pos = boxes.map((b, i) => ({ x: b.left + b.width / 2 + (i % 3) * 0.5, y: b.top + b.height / 2 + Math.floor(i / 3) * 0.5 }));
|
|
19766
19768
|
const index = new Map(boxes.map((b, i) => [b.id, i]));
|
|
19769
|
+
const centre = { x: pos.reduce((t, p) => t + p.x, 0) / pos.length, y: pos.reduce((t, p) => t + p.y, 0) / pos.length };
|
|
19770
|
+
const half = (k * (Math.ceil(Math.sqrt(boxes.length)) + 1)) / 2;
|
|
19771
|
+
const inFrame = (v, c) => Math.min(c + half, Math.max(c - half, v));
|
|
19767
19772
|
let temperature = size * 2;
|
|
19768
19773
|
for (let iteration = 0; iteration < 120; iteration++) {
|
|
19769
19774
|
const shift = pos.map(() => ({ x: 0, y: 0 }));
|
|
@@ -19788,8 +19793,8 @@
|
|
|
19788
19793
|
for (let i = 0; i < pos.length; i++) {
|
|
19789
19794
|
const d = Math.max(1e-9, Math.hypot(shift[i].x, shift[i].y));
|
|
19790
19795
|
const step = Math.min(d, temperature);
|
|
19791
|
-
pos[i].x
|
|
19792
|
-
pos[i].y
|
|
19796
|
+
pos[i].x = inFrame(pos[i].x + (shift[i].x / d) * step, centre.x);
|
|
19797
|
+
pos[i].y = inFrame(pos[i].y + (shift[i].y / d) * step, centre.y);
|
|
19793
19798
|
}
|
|
19794
19799
|
temperature *= 0.95;
|
|
19795
19800
|
}
|
|
@@ -24639,6 +24644,8 @@
|
|
|
24639
24644
|
this.instance = instance;
|
|
24640
24645
|
this.model = model;
|
|
24641
24646
|
this.nodeOptions = nodeOptions;
|
|
24647
|
+
/** A host's live status, `{text, color}` (the editor's elementStatus), or null: drawn as a pill. */
|
|
24648
|
+
this.status = null;
|
|
24642
24649
|
}
|
|
24643
24650
|
|
|
24644
24651
|
/** State lives on the instance and the model element, not here. @protected */
|
|
@@ -24754,6 +24761,27 @@
|
|
|
24754
24761
|
* gets no glyph badge (buildNode leaves iconShape unset) but keeps an icon the element sets.
|
|
24755
24762
|
*/
|
|
24756
24763
|
decorations(ctx) {
|
|
24764
|
+
return [...this._badge(ctx), ...this.statusPill(ctx)];
|
|
24765
|
+
}
|
|
24766
|
+
|
|
24767
|
+
/**
|
|
24768
|
+
* The host's status (`status`), as a small pill inside the box's bottom
|
|
24769
|
+
* right corner: its text, on its colour. Nothing without one.
|
|
24770
|
+
*/
|
|
24771
|
+
statusPill({ w, h }) {
|
|
24772
|
+
const text = this.status?.text;
|
|
24773
|
+
if (!text) return [];
|
|
24774
|
+
const label = String(text);
|
|
24775
|
+
const ph = 12, pw = Math.min(w - 6, label.length * 5.5 + 10);
|
|
24776
|
+
const x = w / 2 - pw - 3, y = h / 2 - ph - 3;
|
|
24777
|
+
return [Glyph.group([
|
|
24778
|
+
Glyph.rect(x, y, pw, ph, this.status.color || '#455a64', null, { rx: 6, ry: 6 }),
|
|
24779
|
+
Glyph.text(label, x + pw / 2, y + ph / 2, { fill: '#ffffff', textAnchor: 'middle', verticalAlign: 'middle', fontSize: '8px' }),
|
|
24780
|
+
], { 'pointer-events': 'none', class: 'element-status' })];
|
|
24781
|
+
}
|
|
24782
|
+
|
|
24783
|
+
/** The type badge (decorations). @private */
|
|
24784
|
+
_badge(ctx) {
|
|
24757
24785
|
const node = this.node;
|
|
24758
24786
|
const { w, h } = ctx;
|
|
24759
24787
|
const fixed = ArchimateElementTemplate.badgeSize(w, h);
|
|
@@ -28223,7 +28251,9 @@
|
|
|
28223
28251
|
* ModelCheckDialog — a model check's findings (ModelChecker#run), listed:
|
|
28224
28252
|
* each a button (⚠ a warning, ℹ for a look) that shows what it names
|
|
28225
28253
|
* (`onReveal`), with what can be done about it beside (`actionsFor`), or
|
|
28226
|
-
* "nothing found". Any editor or part that checks a model shows it this way
|
|
28254
|
+
* "nothing found". Any editor or part that checks a model shows it this way;
|
|
28255
|
+
* anything else listed to be opened (search results) too, with its own
|
|
28256
|
+
* `title` and `empty` text.
|
|
28227
28257
|
*/
|
|
28228
28258
|
class ModelCheckDialog {
|
|
28229
28259
|
/**
|
|
@@ -28232,9 +28262,13 @@
|
|
|
28232
28262
|
* @param {(finding: Object) => void} [options.onReveal] - Show what a finding names
|
|
28233
28263
|
* @param {(finding: Object) => Array<{label: string, action: Function}>} [options.actionsFor]
|
|
28234
28264
|
* @param {Document} [options.document]
|
|
28265
|
+
* @param {string} [options.title] - In place of "Model check: n findings"
|
|
28266
|
+
* @param {string} [options.empty] - What an empty list says
|
|
28235
28267
|
*/
|
|
28236
|
-
constructor({ findings, onReveal = () => {}, actionsFor = () => [], document: doc = globalThis.document }) {
|
|
28268
|
+
constructor({ findings, onReveal = () => {}, actionsFor = () => [], document: doc = globalThis.document, title = null, empty = 'Nothing found: the model passes every check.' }) {
|
|
28237
28269
|
this.findings = findings;
|
|
28270
|
+
this._title = title;
|
|
28271
|
+
this.empty = empty;
|
|
28238
28272
|
this.onReveal = onReveal;
|
|
28239
28273
|
this.actionsFor = actionsFor;
|
|
28240
28274
|
this.document = doc;
|
|
@@ -28242,6 +28276,7 @@
|
|
|
28242
28276
|
|
|
28243
28277
|
/** The dialog's title: how many findings. */
|
|
28244
28278
|
get title() {
|
|
28279
|
+
if (this._title) return this._title;
|
|
28245
28280
|
const n = this.findings.length;
|
|
28246
28281
|
return `Model check: ${n} finding${n === 1 ? '' : 's'}`;
|
|
28247
28282
|
}
|
|
@@ -28267,7 +28302,7 @@
|
|
|
28267
28302
|
on: { click: () => { dialog?.close(); act.action(); } },
|
|
28268
28303
|
})),
|
|
28269
28304
|
],
|
|
28270
|
-
})) : [{ text:
|
|
28305
|
+
})) : [{ text: this.empty }],
|
|
28271
28306
|
}, this.document);
|
|
28272
28307
|
dialog = new FormDialog({
|
|
28273
28308
|
title: this.title,
|
|
@@ -33184,8 +33219,12 @@
|
|
|
33184
33219
|
* elements (null cancels). Without it the first of KIND_PREFERENCE that is allowed is used.
|
|
33185
33220
|
* @param {(nodeOptions: Object) => void} [options.onNodeOptionsChange] - The ⚙ Settings menu changed
|
|
33186
33221
|
* the version or distinct shapes (the host may need to redraw more than the canvas, e.g. a palette)
|
|
33222
|
+
* @param {(element: Object) => ({text: string, color?: string}|null)} [options.elementStatus] - A host's
|
|
33223
|
+
* live status for an element (a component's lifecycle state), drawn on its boxes
|
|
33224
|
+
* @param {(element: Object) => Array<Object>} [options.elementMenuExtras] - A host's menu items for an
|
|
33225
|
+
* element (its transitions), at the end of a box's right-click menu
|
|
33187
33226
|
*/
|
|
33188
|
-
constructor({ view, model, nodeOptions = {}, chooseKind = null, onNodeOptionsChange = null, templateMenu = null, customViewpoints = [], settingsManager = null, ...options }) {
|
|
33227
|
+
constructor({ view, model, nodeOptions = {}, chooseKind = null, onNodeOptionsChange = null, templateMenu = null, customViewpoints = [], settingsManager = null, elementStatus = null, elementMenuExtras = null, ...options }) {
|
|
33189
33228
|
super({ ...options, templates: new Map(), edges: new Map(), history: view.history, arrangement: view.arrangement });
|
|
33190
33229
|
this.view = view;
|
|
33191
33230
|
this.model = model;
|
|
@@ -33198,6 +33237,9 @@
|
|
|
33198
33237
|
this.settingsManager = settingsManager;
|
|
33199
33238
|
/** Canvas templates (a CanvasTemplateMenu): placed on the canvas, saved from its shapes. */
|
|
33200
33239
|
this.templateMenu = templateMenu;
|
|
33240
|
+
/** A host's live status per element, drawn on its boxes, and its menu items per element. */
|
|
33241
|
+
this.elementStatus = elementStatus;
|
|
33242
|
+
this.elementMenuExtras = elementMenuExtras;
|
|
33201
33243
|
this.addDropHandler({
|
|
33202
33244
|
accepts: (dt) => Array.from(dt.types).includes(ARCHIMATE_TYPE_MIME),
|
|
33203
33245
|
drop: (dt, point) => this.addElement(dt.getData(ARCHIMATE_TYPE_MIME), point),
|
|
@@ -33235,6 +33277,7 @@
|
|
|
33235
33277
|
...(missing.length ? [{ label: `Add Missing Relationships (${missing.length})`, action: () => this.addMissingRelationships(elementId) }] : []),
|
|
33236
33278
|
...hintMenuItems(this.view, elementId, { onChange: () => this.render() }),
|
|
33237
33279
|
...(items.length ? [{ label: 'Save as template', items }] : []),
|
|
33280
|
+
...(element && this.elementMenuExtras ? this.elementMenuExtras(element) ?? [] : []),
|
|
33238
33281
|
];
|
|
33239
33282
|
}
|
|
33240
33283
|
|
|
@@ -33459,7 +33502,11 @@
|
|
|
33459
33502
|
templates.set(inst.id, existing?.instance === inst ? existing
|
|
33460
33503
|
: new ArchimateElementTemplate(inst, model, { nodeOptions: this.nodeOptions }));
|
|
33461
33504
|
}
|
|
33462
|
-
for (const t of templates.values())
|
|
33505
|
+
for (const t of templates.values()) {
|
|
33506
|
+
t.nodeOptions = this.nodeOptions;
|
|
33507
|
+
// Asked afresh at every sync: a host's status is live, not part of the model.
|
|
33508
|
+
t.status = this.elementStatus ? this.elementStatus(t.instance.resolve(model)) ?? null : null;
|
|
33509
|
+
}
|
|
33463
33510
|
// The view's groups (Archi groups: model folders with bounds on this view).
|
|
33464
33511
|
for (const folder of view.placedFolders) {
|
|
33465
33512
|
const existing = this.templates.get(folder.id);
|
|
@@ -33958,6 +34005,9 @@
|
|
|
33958
34005
|
/** Whether files in this format can be imported (opened) as well as exported. A subclass that only exports says no. */
|
|
33959
34006
|
get imports() { return true; }
|
|
33960
34007
|
|
|
34008
|
+
/** Whether a model can be exported in this format. A subclass that only reads (another tool's export) says no. */
|
|
34009
|
+
get exports() { return true; }
|
|
34010
|
+
|
|
33961
34011
|
/** True when import takes the file's bytes (a zip archive), else its text. */
|
|
33962
34012
|
get readsBytes() { return false; }
|
|
33963
34013
|
|
|
@@ -34017,7 +34067,7 @@
|
|
|
34017
34067
|
const choose = chooseFile ?? ((accept) => pickFile(accept, doc()));
|
|
34018
34068
|
const tell = report ?? ((message) => (doc().defaultView ?? globalThis).alert(message));
|
|
34019
34069
|
const groups = formats.map(format => [
|
|
34020
|
-
only !== 'import' && enabled(format, 'export') && { label: only ? format.label : `Export ${format.label}`, action: () => save(format.export(model()), format.fileName) },
|
|
34070
|
+
only !== 'import' && format.exports !== false && enabled(format, 'export') && { label: only ? format.label : `Export ${format.label}`, action: () => save(format.export(model()), format.fileName) },
|
|
34021
34071
|
only !== 'export' && format.imports && enabled(format, 'import') && {
|
|
34022
34072
|
label: only ? `${format.label}…` : `Import ${format.label}…`,
|
|
34023
34073
|
action: () => importFile(format, { choose, onImport, report: tell }),
|
|
@@ -42022,50 +42072,211 @@
|
|
|
42022
42072
|
}
|
|
42023
42073
|
}
|
|
42024
42074
|
|
|
42025
|
-
/**
|
|
42026
|
-
*
|
|
42027
|
-
*
|
|
42028
|
-
* the model as it was before and after (
|
|
42029
|
-
* puts the model back as it was (
|
|
42030
|
-
* undoable this way, whatever it touches.
|
|
42031
|
-
*/
|
|
42032
|
-
class
|
|
42033
|
-
/** A model edit is no edit on one view: undoing it shows no other. */
|
|
42034
|
-
viewId = null;
|
|
42035
|
-
|
|
42036
|
-
/**
|
|
42037
|
-
* @param {
|
|
42038
|
-
* @param {string} label - What the undo menu calls it ('Edit Customer')
|
|
42039
|
-
* @param {(model:
|
|
42040
|
-
*/
|
|
42041
|
-
constructor(model, label, change) {
|
|
42042
|
-
super(label);
|
|
42043
|
-
this.model = model;
|
|
42044
|
-
this.change = change;
|
|
42045
|
-
this.before = null;
|
|
42046
|
-
this.after = null;
|
|
42047
|
-
}
|
|
42048
|
-
|
|
42049
|
-
execute() {
|
|
42050
|
-
if (this.after) {
|
|
42051
|
-
this.model.restore(this.after);
|
|
42052
|
-
} else {
|
|
42053
|
-
this.before =
|
|
42054
|
-
this.result = this.change(this.model);
|
|
42055
|
-
this.after =
|
|
42056
|
-
}
|
|
42057
|
-
this.model.edited();
|
|
42058
|
-
}
|
|
42059
|
-
|
|
42060
|
-
undo() {
|
|
42061
|
-
this.model.restore(this.before);
|
|
42062
|
-
this.model.edited();
|
|
42063
|
-
}
|
|
42064
|
-
|
|
42065
|
-
/** The model as it is, a copy nothing later changes (its toJSON
|
|
42066
|
-
static snapshot(model) {
|
|
42067
|
-
return JSON.parse(JSON.stringify(model.toJSON()));
|
|
42068
|
-
}
|
|
42075
|
+
/**
|
|
42076
|
+
* ModelEditCommand — one edit of an EditableModel (a data model's entity, a
|
|
42077
|
+
* requirements model's story, a delete) as one undo step: run, it makes the
|
|
42078
|
+
* change and keeps the model as it was before and after (its toJSON); undone
|
|
42079
|
+
* or redone, it puts the model back as it was (its restore). Any change to a
|
|
42080
|
+
* model is undoable this way, whatever it touches.
|
|
42081
|
+
*/
|
|
42082
|
+
class ModelEditCommand extends Command {
|
|
42083
|
+
/** A model edit is no edit on one view: undoing it shows no other. */
|
|
42084
|
+
viewId = null;
|
|
42085
|
+
|
|
42086
|
+
/**
|
|
42087
|
+
* @param {EditableModel} model
|
|
42088
|
+
* @param {string} label - What the undo menu calls it ('Edit Customer')
|
|
42089
|
+
* @param {(model: EditableModel) => *} change - Makes the change
|
|
42090
|
+
*/
|
|
42091
|
+
constructor(model, label, change) {
|
|
42092
|
+
super(label);
|
|
42093
|
+
this.model = model;
|
|
42094
|
+
this.change = change;
|
|
42095
|
+
this.before = null;
|
|
42096
|
+
this.after = null;
|
|
42097
|
+
}
|
|
42098
|
+
|
|
42099
|
+
execute() {
|
|
42100
|
+
if (this.after) {
|
|
42101
|
+
this.model.restore(this.after);
|
|
42102
|
+
} else {
|
|
42103
|
+
this.before = ModelEditCommand.snapshot(this.model);
|
|
42104
|
+
this.result = this.change(this.model);
|
|
42105
|
+
this.after = ModelEditCommand.snapshot(this.model);
|
|
42106
|
+
}
|
|
42107
|
+
this.model.edited();
|
|
42108
|
+
}
|
|
42109
|
+
|
|
42110
|
+
undo() {
|
|
42111
|
+
this.model.restore(this.before);
|
|
42112
|
+
this.model.edited();
|
|
42113
|
+
}
|
|
42114
|
+
|
|
42115
|
+
/** The model as it is, a copy nothing later changes (its toJSON may share arrays). */
|
|
42116
|
+
static snapshot(model) {
|
|
42117
|
+
return JSON.parse(JSON.stringify(model.toJSON()));
|
|
42118
|
+
}
|
|
42119
|
+
}
|
|
42120
|
+
|
|
42121
|
+
/**
|
|
42122
|
+
* EditableModel — base of a model edited as a whole rather than view by view
|
|
42123
|
+
* (DataModel, RequirementsModel; a ViewSetModel keeps its views' histories
|
|
42124
|
+
* its own way). What a part needs of any model it edits, keeps and merges:
|
|
42125
|
+
*
|
|
42126
|
+
* - a name;
|
|
42127
|
+
* - whether it changed since it was saved (`edited()`, `markSaved()`,
|
|
42128
|
+
* `isDirty()`, `onEdit(fn)`), so a part shows it, keeps it and asks before
|
|
42129
|
+
* it is lost;
|
|
42130
|
+
* - an undo history, every edit one step on it (`editCommand(label, change)`:
|
|
42131
|
+
* the model before and after, as its toJSON, put back by `restore`);
|
|
42132
|
+
* - how it differs from another of its kind (`compareWith`, over the
|
|
42133
|
+
* subclass's `comparedKinds()`), which merges the other's into it.
|
|
42134
|
+
*
|
|
42135
|
+
* A subclass gives `toJSON()`, its constructor reading that back, and
|
|
42136
|
+
* `static STATE`: the fields `restore` puts back.
|
|
42137
|
+
*/
|
|
42138
|
+
class EditableModel {
|
|
42139
|
+
/** The fields a snapshot puts back (restore). A subclass lists its own, `name` among them. */
|
|
42140
|
+
static STATE = Object.freeze(['name']);
|
|
42141
|
+
|
|
42142
|
+
/** @param {{name?: string}} [spec] */
|
|
42143
|
+
constructor(spec = {}) {
|
|
42144
|
+
if (new.target === EditableModel) throw new TypeError('EditableModel is abstract — subclass it');
|
|
42145
|
+
/** What the model is called. */
|
|
42146
|
+
this.name = spec.name ?? '';
|
|
42147
|
+
this._version = 0;
|
|
42148
|
+
this._saved = 0;
|
|
42149
|
+
this._editListeners = [];
|
|
42150
|
+
}
|
|
42151
|
+
|
|
42152
|
+
/** The kinds it is compared on and merged by (ComparedKind), in the order a merge brings them. None here. */
|
|
42153
|
+
comparedKinds() {
|
|
42154
|
+
return [];
|
|
42155
|
+
}
|
|
42156
|
+
|
|
42157
|
+
/** How this model differs from another (ModelComparison), which can merge the other's into it. */
|
|
42158
|
+
compareWith(other) {
|
|
42159
|
+
return new ModelComparison(this, other);
|
|
42160
|
+
}
|
|
42161
|
+
|
|
42162
|
+
/** Its undo history: a merge is one step on it. */
|
|
42163
|
+
get history() {
|
|
42164
|
+
return (this._history ??= new CommandHistory());
|
|
42165
|
+
}
|
|
42166
|
+
|
|
42167
|
+
/** A change made as one undo step (ModelEditCommand), not yet run. */
|
|
42168
|
+
editCommand(label, change) {
|
|
42169
|
+
return new ModelEditCommand(this, label, change);
|
|
42170
|
+
}
|
|
42171
|
+
|
|
42172
|
+
/** Put the model back as a snapshot (its toJSON) says: the fields its class lists (STATE). */
|
|
42173
|
+
restore(json) {
|
|
42174
|
+
const back = new this.constructor(JSON.parse(JSON.stringify(json)));
|
|
42175
|
+
for (const key of this.constructor.STATE) this[key] = back[key];
|
|
42176
|
+
}
|
|
42177
|
+
|
|
42178
|
+
// ===== Changed since saved =====
|
|
42179
|
+
|
|
42180
|
+
/** Something changed: listeners hear of it. */
|
|
42181
|
+
edited() {
|
|
42182
|
+
this._version++;
|
|
42183
|
+
this._emitEdit();
|
|
42184
|
+
}
|
|
42185
|
+
|
|
42186
|
+
/** This state is the saved one (a file opened or saved). */
|
|
42187
|
+
markSaved() {
|
|
42188
|
+
this._saved = this._version;
|
|
42189
|
+
this._emitEdit();
|
|
42190
|
+
}
|
|
42191
|
+
|
|
42192
|
+
/** Whether it changed since markSaved. */
|
|
42193
|
+
isDirty() {
|
|
42194
|
+
return this._version !== this._saved;
|
|
42195
|
+
}
|
|
42196
|
+
|
|
42197
|
+
/** Call `fn(model)` after every change (and markSaved). Returns an unsubscribe function. */
|
|
42198
|
+
onEdit(fn) {
|
|
42199
|
+
this._editListeners.push(fn);
|
|
42200
|
+
return () => { this._editListeners = this._editListeners.filter(l => l !== fn); };
|
|
42201
|
+
}
|
|
42202
|
+
|
|
42203
|
+
/** @private */
|
|
42204
|
+
_emitEdit() {
|
|
42205
|
+
for (const fn of this._editListeners) fn(this);
|
|
42206
|
+
}
|
|
42207
|
+
|
|
42208
|
+
/** An id made from `name`, unlike any of `taken`'s ids ('customer', 'customer-2'). */
|
|
42209
|
+
_freshId(name, taken) {
|
|
42210
|
+
const base = String(name ?? 'item').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'item';
|
|
42211
|
+
let id = base;
|
|
42212
|
+
for (let n = 2; taken.some(t => t.id === id); n++) id = `${base}-${n}`;
|
|
42213
|
+
return id;
|
|
42214
|
+
}
|
|
42215
|
+
|
|
42216
|
+
/** The model as plain JSON, read back by its constructor. @abstract */
|
|
42217
|
+
toJSON() {
|
|
42218
|
+
throw new Error(`${this.constructor.name} must implement toJSON()`);
|
|
42219
|
+
}
|
|
42220
|
+
}
|
|
42221
|
+
|
|
42222
|
+
/**
|
|
42223
|
+
* Benchmark — what a model is measured by, so a change to it (a proposal, a
|
|
42224
|
+
* merge) says whether it made the model better. Each measure:
|
|
42225
|
+
* `{ key, label, value, better: 'lower'|'higher', format?: 'count'|'share'|'score' }`
|
|
42226
|
+
* (a share is 0..1; a value of null is not measured yet).
|
|
42227
|
+
*
|
|
42228
|
+
* Every model with a checker is measured by its findings — warnings counted
|
|
42229
|
+
* apart (`Benchmark.findings`); a model with more to say gives its own
|
|
42230
|
+
* `benchmark()` (a data model its Scorecard, a requirements model how many
|
|
42231
|
+
* stories say when they are done and trace to something), starting from those.
|
|
42232
|
+
*/
|
|
42233
|
+
class Benchmark {
|
|
42234
|
+
/** A model's measures: its own benchmark(), else its checker's findings, else none. */
|
|
42235
|
+
static of(model) {
|
|
42236
|
+
if (typeof model?.benchmark === 'function') return model.benchmark();
|
|
42237
|
+
return Benchmark.findings(model);
|
|
42238
|
+
}
|
|
42239
|
+
|
|
42240
|
+
/** The check's warnings and all its findings, each fewer the better. */
|
|
42241
|
+
static findings(model) {
|
|
42242
|
+
if (typeof model?.checker !== 'function') return [];
|
|
42243
|
+
const found = model.checker().run();
|
|
42244
|
+
return [
|
|
42245
|
+
{ key: 'warnings', label: 'Check warnings', value: found.filter(f => f.severity === 'warning').length, better: 'lower', format: 'count' },
|
|
42246
|
+
{ key: 'findings', label: 'Check findings', value: found.length, better: 'lower', format: 'count' },
|
|
42247
|
+
];
|
|
42248
|
+
}
|
|
42249
|
+
|
|
42250
|
+
/**
|
|
42251
|
+
* Two models' measures side by side, matched by key: `{key, label, format,
|
|
42252
|
+
* before, after, change}` — change 'better', 'worse', 'same', or null when
|
|
42253
|
+
* either is not measured.
|
|
42254
|
+
*/
|
|
42255
|
+
static compare(before, after) {
|
|
42256
|
+
const then = new Map(before.map(m => [m.key, m]));
|
|
42257
|
+
return after.filter(m => then.has(m.key)).map(m => {
|
|
42258
|
+
const was = then.get(m.key).value, now = m.value;
|
|
42259
|
+
const change = was == null || now == null ? null
|
|
42260
|
+
: was === now ? 'same'
|
|
42261
|
+
: (now < was) === (m.better === 'lower') ? 'better' : 'worse';
|
|
42262
|
+
return { key: m.key, label: m.label, format: m.format ?? 'count', before: was, after: now, change };
|
|
42263
|
+
});
|
|
42264
|
+
}
|
|
42265
|
+
|
|
42266
|
+
/** A measure's value as it reads ('3', '50%', '72 of 100', '—'). */
|
|
42267
|
+
static format(value, format = 'count') {
|
|
42268
|
+
if (value == null) return '—';
|
|
42269
|
+
if (format === 'share') return `${Math.round(value * 100)}%`;
|
|
42270
|
+
if (format === 'score') return `${value} of 100`;
|
|
42271
|
+
return String(value);
|
|
42272
|
+
}
|
|
42273
|
+
|
|
42274
|
+
/** A comparison in a line: "Check findings 7 → 3 (better); …" — what did not change left out unless nothing did. */
|
|
42275
|
+
static text(rows) {
|
|
42276
|
+
const moved = rows.filter(r => r.change !== 'same');
|
|
42277
|
+
const shown = moved.length ? moved : rows;
|
|
42278
|
+
return shown.map(r => `${r.label} ${Benchmark.format(r.before, r.format)} → ${Benchmark.format(r.after, r.format)}${r.change && r.change !== 'same' ? ` (${r.change})` : ''}`).join('; ');
|
|
42279
|
+
}
|
|
42069
42280
|
}
|
|
42070
42281
|
|
|
42071
42282
|
/** Words that stay small inside a title. */
|
|
@@ -42719,8 +42930,42 @@
|
|
|
42719
42930
|
}
|
|
42720
42931
|
}
|
|
42721
42932
|
|
|
42933
|
+
/** A plain copy of JSON-shaped data. */
|
|
42934
|
+
const copy$2 = (value) => (value === undefined ? undefined : JSON.parse(JSON.stringify(value)));
|
|
42935
|
+
|
|
42936
|
+
/** A list of a model's held in an array (`list(model)`), each by an id; bring puts a copy in place or adds it. */
|
|
42937
|
+
class ListCompared extends ComparedKind {
|
|
42938
|
+
constructor() {
|
|
42939
|
+
super();
|
|
42940
|
+
if (new.target === ListCompared) throw new TypeError('ListCompared is abstract — subclass it');
|
|
42941
|
+
}
|
|
42942
|
+
|
|
42943
|
+
/** The array the model holds them in. @abstract */
|
|
42944
|
+
list(model) { throw new Error(`${this.constructor.name} must implement list()`); }
|
|
42945
|
+
|
|
42946
|
+
items(model) { return this.list(model); }
|
|
42947
|
+
get(model, id) { return this.items(model).find(item => this.idOf(item) === id) ?? null; }
|
|
42948
|
+
|
|
42949
|
+
/** Put a copy of `item` at its place (in place of the one with its id, else last); undone, as it was. */
|
|
42950
|
+
bring(model, item) {
|
|
42951
|
+
const list = this.list(model);
|
|
42952
|
+
const twin = this.copyOf(item);
|
|
42953
|
+
const at = list.findIndex(x => this.idOf(x) === this.idOf(item));
|
|
42954
|
+
if (at < 0) {
|
|
42955
|
+
list.push(twin);
|
|
42956
|
+
return () => { const i = list.indexOf(twin); if (i >= 0) list.splice(i, 1); };
|
|
42957
|
+
}
|
|
42958
|
+
const old = list[at];
|
|
42959
|
+
list[at] = twin;
|
|
42960
|
+
return () => { const i = list.indexOf(twin); if (i >= 0) list[i] = old; };
|
|
42961
|
+
}
|
|
42962
|
+
|
|
42963
|
+
/** A copy to put in the other model. */
|
|
42964
|
+
copyOf(item) { return copy$2(item); }
|
|
42965
|
+
}
|
|
42966
|
+
|
|
42722
42967
|
/** A plain copy of JSON-shaped data. */
|
|
42723
|
-
const copy = (value) => (value === undefined ? undefined : JSON.parse(JSON.stringify(value)));
|
|
42968
|
+
const copy$1 = (value) => (value === undefined ? undefined : JSON.parse(JSON.stringify(value)));
|
|
42724
42969
|
|
|
42725
42970
|
/**
|
|
42726
42971
|
* The kinds two DataModels are compared on and merged by (ModelComparison,
|
|
@@ -42730,37 +42975,6 @@
|
|
|
42730
42975
|
* two models alike but for where their boxes are, are alike.
|
|
42731
42976
|
*/
|
|
42732
42977
|
|
|
42733
|
-
/** A list of a model's held in an array (`list(model)`), each by an id; bring puts a copy in place or adds it. */
|
|
42734
|
-
class ListCompared extends ComparedKind {
|
|
42735
|
-
constructor() {
|
|
42736
|
-
super();
|
|
42737
|
-
if (new.target === ListCompared) throw new TypeError('ListCompared is abstract — subclass it');
|
|
42738
|
-
}
|
|
42739
|
-
|
|
42740
|
-
/** The array the model holds them in. @abstract */
|
|
42741
|
-
list(model) { throw new Error(`${this.constructor.name} must implement list()`); }
|
|
42742
|
-
|
|
42743
|
-
items(model) { return this.list(model); }
|
|
42744
|
-
get(model, id) { return this.items(model).find(item => this.idOf(item) === id) ?? null; }
|
|
42745
|
-
|
|
42746
|
-
/** Put a copy of `item` at its place (in place of the one with its id, else last); undone, as it was. */
|
|
42747
|
-
bring(model, item) {
|
|
42748
|
-
const list = this.list(model);
|
|
42749
|
-
const twin = this.copyOf(item);
|
|
42750
|
-
const at = list.findIndex(x => this.idOf(x) === this.idOf(item));
|
|
42751
|
-
if (at < 0) {
|
|
42752
|
-
list.push(twin);
|
|
42753
|
-
return () => { const i = list.indexOf(twin); if (i >= 0) list.splice(i, 1); };
|
|
42754
|
-
}
|
|
42755
|
-
const old = list[at];
|
|
42756
|
-
list[at] = twin;
|
|
42757
|
-
return () => { const i = list.indexOf(twin); if (i >= 0) list[i] = old; };
|
|
42758
|
-
}
|
|
42759
|
-
|
|
42760
|
-
/** A copy to put in the other model. */
|
|
42761
|
-
copyOf(item) { return copy(item); }
|
|
42762
|
-
}
|
|
42763
|
-
|
|
42764
42978
|
/** One level's entities: compared on all but where they are drawn. */
|
|
42765
42979
|
class LevelEntitiesCompared extends ListCompared {
|
|
42766
42980
|
constructor(level) {
|
|
@@ -42774,7 +42988,7 @@
|
|
|
42774
42988
|
|
|
42775
42989
|
content(entity) {
|
|
42776
42990
|
const { x, y, ...rest } = entity;
|
|
42777
|
-
return copy(rest);
|
|
42991
|
+
return copy$1(rest);
|
|
42778
42992
|
}
|
|
42779
42993
|
}
|
|
42780
42994
|
|
|
@@ -42793,7 +43007,7 @@
|
|
|
42793
43007
|
list(model) { return model.level(this.level).relationships; }
|
|
42794
43008
|
idOf(r) { return r.id ?? `${r.source}->${r.target}${r.name ? `:${r.name}` : ''}`; }
|
|
42795
43009
|
nameOf(r) { return `${r.source} ${r.name || '→'} ${r.target}`; }
|
|
42796
|
-
content(r) { return copy(r); }
|
|
43010
|
+
content(r) { return copy$1(r); }
|
|
42797
43011
|
requires(r) { return [[`${this.level}-entity`, r.source], [`${this.level}-entity`, r.target]]; }
|
|
42798
43012
|
}
|
|
42799
43013
|
|
|
@@ -42820,8 +43034,8 @@
|
|
|
42820
43034
|
|
|
42821
43035
|
/** Perspectives and contexts are classes; their JSON is what counts, and a copy is one made from it. */
|
|
42822
43036
|
class ModelPartsCompared extends ListCompared {
|
|
42823
|
-
content(item) { return copy(item.toJSON()); }
|
|
42824
|
-
copyOf(item) { return new item.constructor(copy(item.toJSON())); }
|
|
43037
|
+
content(item) { return copy$1(item.toJSON()); }
|
|
43038
|
+
copyOf(item) { return new item.constructor(copy$1(item.toJSON())); }
|
|
42825
43039
|
}
|
|
42826
43040
|
|
|
42827
43041
|
class ContextsCompared extends ModelPartsCompared {
|
|
@@ -42863,11 +43077,13 @@
|
|
|
42863
43077
|
* (`{ entities, relationships }`, as dataModelDiagram takes them), is read as
|
|
42864
43078
|
* three levels mapped one to one.
|
|
42865
43079
|
*
|
|
42866
|
-
*
|
|
42867
|
-
*
|
|
42868
|
-
* keep it and ask before it is lost.
|
|
43080
|
+
* What any model a part edits has — whether it changed since saved, its undo
|
|
43081
|
+
* history, compare and merge — is EditableModel's.
|
|
42869
43082
|
*/
|
|
42870
|
-
class DataModel {
|
|
43083
|
+
class DataModel extends EditableModel {
|
|
43084
|
+
/** What a snapshot puts back (EditableModel#restore). */
|
|
43085
|
+
static STATE = Object.freeze(['name', 'levels', 'mappings', 'mappingLayout', 'noteLayout', 'perspectives', 'contexts', 'dataTypes', 'namingStandard', 'classificationScheme', 'scorecard']);
|
|
43086
|
+
|
|
42871
43087
|
/** The levels, outermost first. */
|
|
42872
43088
|
static LEVELS = LEVELS;
|
|
42873
43089
|
|
|
@@ -42951,11 +43167,7 @@
|
|
|
42951
43167
|
* @param {string} [spec.name] - What the model is called
|
|
42952
43168
|
*/
|
|
42953
43169
|
constructor(spec = {}) {
|
|
42954
|
-
|
|
42955
|
-
this.name = spec.name ?? '';
|
|
42956
|
-
this._version = 0;
|
|
42957
|
-
this._saved = 0;
|
|
42958
|
-
this._editListeners = [];
|
|
43170
|
+
super(spec);
|
|
42959
43171
|
const { levels, mappings = [], mappingLayout = {}, noteLayout = {} } = spec.levels ? spec : DataModel.levelsFromCombined(spec);
|
|
42960
43172
|
this.levels = Object.fromEntries(LEVELS.map(level => [level, new LevelSchema(level, levels?.[level] ?? {})]));
|
|
42961
43173
|
this.mappings = LEVELS.slice(1).map((to, i) => {
|
|
@@ -42982,6 +43194,14 @@
|
|
|
42982
43194
|
return new ModelScorecard(this);
|
|
42983
43195
|
}
|
|
42984
43196
|
|
|
43197
|
+
/** What it is measured by (Benchmark): its check's findings, and its Scorecard total. */
|
|
43198
|
+
benchmark() {
|
|
43199
|
+
return [
|
|
43200
|
+
...Benchmark.findings(this),
|
|
43201
|
+
{ key: 'scorecard', label: 'Scorecard', value: this.scored().total().score, better: 'higher', format: 'score' },
|
|
43202
|
+
];
|
|
43203
|
+
}
|
|
43204
|
+
|
|
42985
43205
|
/** Keep a reviewer's scores and the weights (ModelScorecard.specFrom's), as one undo step. */
|
|
42986
43206
|
scorecardCommand(spec) {
|
|
42987
43207
|
return this.editCommand('Score the model', (model) => {
|
|
@@ -43029,46 +43249,6 @@
|
|
|
43029
43249
|
];
|
|
43030
43250
|
}
|
|
43031
43251
|
|
|
43032
|
-
/** How this model differs from another (ModelComparison), which can merge the other's into it. */
|
|
43033
|
-
compareWith(other) {
|
|
43034
|
-
return new ModelComparison(this, other);
|
|
43035
|
-
}
|
|
43036
|
-
|
|
43037
|
-
/** Its undo history: a merge is one step on it. */
|
|
43038
|
-
get history() {
|
|
43039
|
-
return (this._history ??= new CommandHistory());
|
|
43040
|
-
}
|
|
43041
|
-
|
|
43042
|
-
// ===== Changed since saved =====
|
|
43043
|
-
|
|
43044
|
-
/** Something changed (a perspective or context, a box or note moved): listeners hear of it. */
|
|
43045
|
-
edited() {
|
|
43046
|
-
this._version++;
|
|
43047
|
-
this._emitEdit();
|
|
43048
|
-
}
|
|
43049
|
-
|
|
43050
|
-
/** This state is the saved one (a file opened or saved). */
|
|
43051
|
-
markSaved() {
|
|
43052
|
-
this._saved = this._version;
|
|
43053
|
-
this._emitEdit();
|
|
43054
|
-
}
|
|
43055
|
-
|
|
43056
|
-
/** Whether it changed since markSaved. */
|
|
43057
|
-
isDirty() {
|
|
43058
|
-
return this._version !== this._saved;
|
|
43059
|
-
}
|
|
43060
|
-
|
|
43061
|
-
/** Call `fn(model)` after every change (and markSaved). Returns an unsubscribe function. */
|
|
43062
|
-
onEdit(fn) {
|
|
43063
|
-
this._editListeners.push(fn);
|
|
43064
|
-
return () => { this._editListeners = this._editListeners.filter(l => l !== fn); };
|
|
43065
|
-
}
|
|
43066
|
-
|
|
43067
|
-
/** @private */
|
|
43068
|
-
_emitEdit() {
|
|
43069
|
-
for (const fn of this._editListeners) fn(this);
|
|
43070
|
-
}
|
|
43071
|
-
|
|
43072
43252
|
/** A level's schema (LevelSchema). */
|
|
43073
43253
|
level(name) {
|
|
43074
43254
|
const schema = this.levels[name];
|
|
@@ -43284,13 +43464,7 @@
|
|
|
43284
43464
|
|
|
43285
43465
|
// ===== Perspectives and contexts of one's own =====
|
|
43286
43466
|
|
|
43287
|
-
// ===== Editing what a level holds (each
|
|
43288
|
-
|
|
43289
|
-
/** Put the model back as a snapshot says (DataModelEditCommand): its levels, mappings, layout, perspectives, contexts, data types. */
|
|
43290
|
-
restore(json) {
|
|
43291
|
-
const back = new DataModel(JSON.parse(JSON.stringify(json)));
|
|
43292
|
-
for (const key of ['name', 'levels', 'mappings', 'mappingLayout', 'noteLayout', 'perspectives', 'contexts', 'dataTypes', 'namingStandard', 'classificationScheme', 'scorecard']) this[key] = back[key];
|
|
43293
|
-
}
|
|
43467
|
+
// ===== Editing what a level holds (each an editCommand: one undo step) =====
|
|
43294
43468
|
|
|
43295
43469
|
/** Set how its data is classified (a ClassificationScheme spec), as one undo step. */
|
|
43296
43470
|
classificationSchemeCommand(spec) {
|
|
@@ -43306,11 +43480,6 @@
|
|
|
43306
43480
|
});
|
|
43307
43481
|
}
|
|
43308
43482
|
|
|
43309
|
-
/** A change made as one undo step. */
|
|
43310
|
-
editCommand(label, change) {
|
|
43311
|
-
return new DataModelEditCommand(this, label, change);
|
|
43312
|
-
}
|
|
43313
|
-
|
|
43314
43483
|
/**
|
|
43315
43484
|
* Add an entity at a level (`id` null: one made from its name) or change
|
|
43316
43485
|
* one (what the spec leaves out, where it is drawn among it, kept).
|
|
@@ -43662,13 +43831,6 @@
|
|
|
43662
43831
|
}
|
|
43663
43832
|
|
|
43664
43833
|
/** An id from a name, not yet among `taken`. @private */
|
|
43665
|
-
_freshId(name, taken) {
|
|
43666
|
-
const base = String(name ?? 'item').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'item';
|
|
43667
|
-
let id = base;
|
|
43668
|
-
for (let n = 2; taken.some(t => t.id === id); n++) id = `${base}-${n}`;
|
|
43669
|
-
return id;
|
|
43670
|
-
}
|
|
43671
|
-
|
|
43672
43834
|
toJSON() {
|
|
43673
43835
|
return {
|
|
43674
43836
|
name: this.name,
|
|
@@ -44768,6 +44930,1177 @@
|
|
|
44768
44930
|
}
|
|
44769
44931
|
}
|
|
44770
44932
|
|
|
44933
|
+
/**
|
|
44934
|
+
* The kinds two RequirementsModels are compared on and merged by
|
|
44935
|
+
* (ModelComparison): epics, releases, then stories, in the order a merge
|
|
44936
|
+
* brings them — a story needs its epic and its release.
|
|
44937
|
+
*/
|
|
44938
|
+
|
|
44939
|
+
class EpicsCompared extends ListCompared {
|
|
44940
|
+
get key() { return 'epic'; }
|
|
44941
|
+
get label() { return 'Epics'; }
|
|
44942
|
+
list(model) { return model.epics; }
|
|
44943
|
+
content(epic) { return { ...epic }; }
|
|
44944
|
+
}
|
|
44945
|
+
|
|
44946
|
+
class ReleasesCompared extends ListCompared {
|
|
44947
|
+
get key() { return 'release'; }
|
|
44948
|
+
get label() { return 'Releases'; }
|
|
44949
|
+
list(model) { return model.releases; }
|
|
44950
|
+
content(release) { return { ...release }; }
|
|
44951
|
+
}
|
|
44952
|
+
|
|
44953
|
+
/** Stories: compared on everything, where they sit in the list aside. */
|
|
44954
|
+
class StoriesCompared extends ListCompared {
|
|
44955
|
+
get key() { return 'story'; }
|
|
44956
|
+
get label() { return 'Stories'; }
|
|
44957
|
+
list(model) { return model.stories; }
|
|
44958
|
+
content(story) { return JSON.parse(JSON.stringify(story)); }
|
|
44959
|
+
|
|
44960
|
+
requires(story) {
|
|
44961
|
+
return [...(story.epic ? [['epic', story.epic]] : []), ...(story.release ? [['release', story.release]] : [])];
|
|
44962
|
+
}
|
|
44963
|
+
}
|
|
44964
|
+
|
|
44965
|
+
/**
|
|
44966
|
+
* RequirementsChecker — what a requirements model is missing, as a team
|
|
44967
|
+
* reviewing a backlog would say it: a story with no acceptance criteria
|
|
44968
|
+
* (nothing to say when it is done), one that says neither who wants it nor
|
|
44969
|
+
* what (no "As a … I want …"), one that traces to nothing (nothing realises
|
|
44970
|
+
* it), one under no epic, an epic with no stories, two stories of one name.
|
|
44971
|
+
*
|
|
44972
|
+
* With `resolve(trace)` — the host's way to find what a trace names in its
|
|
44973
|
+
* other models (true, false, or undefined when it cannot tell) — a trace to
|
|
44974
|
+
* something that is not there is found too.
|
|
44975
|
+
*
|
|
44976
|
+
* Findings name a story or an epic (`kind`), as ModelChecker's do.
|
|
44977
|
+
*/
|
|
44978
|
+
class RequirementsChecker extends ModelChecker {
|
|
44979
|
+
/**
|
|
44980
|
+
* @param {RequirementsModel} model
|
|
44981
|
+
* @param {{resolve?: (trace: Object) => (boolean|undefined)}} [options]
|
|
44982
|
+
*/
|
|
44983
|
+
constructor(model, { resolve = null } = {}) {
|
|
44984
|
+
super(model);
|
|
44985
|
+
this.resolve = resolve;
|
|
44986
|
+
}
|
|
44987
|
+
|
|
44988
|
+
checks() {
|
|
44989
|
+
return [this.noCriteria, this.noNarrative, this.untraced, this.brokenTraces, this.noEpic, this.emptyEpics, this.sameNames];
|
|
44990
|
+
}
|
|
44991
|
+
|
|
44992
|
+
/** @private */
|
|
44993
|
+
_story(story, code, severity, message) {
|
|
44994
|
+
return { code, severity, kind: 'story', ids: [story.id], message };
|
|
44995
|
+
}
|
|
44996
|
+
|
|
44997
|
+
noCriteria() {
|
|
44998
|
+
return this.model.stories.filter(s => !s.criteria.length)
|
|
44999
|
+
.map(s => this._story(s, 'story-no-criteria', 'warning', `"${s.name}" has no acceptance criteria: nothing says when it is done`));
|
|
45000
|
+
}
|
|
45001
|
+
|
|
45002
|
+
noNarrative() {
|
|
45003
|
+
return this.model.stories.filter(s => !s.want && !s.description)
|
|
45004
|
+
.map(s => this._story(s, 'story-no-narrative', 'info', `"${s.name}" says neither who wants it nor what (As a … I want … so that …)`));
|
|
45005
|
+
}
|
|
45006
|
+
|
|
45007
|
+
untraced() {
|
|
45008
|
+
return this.model.stories.filter(s => !s.traces.length)
|
|
45009
|
+
.map(s => this._story(s, 'story-untraced', 'info', `"${s.name}" traces to nothing that realises it`));
|
|
45010
|
+
}
|
|
45011
|
+
|
|
45012
|
+
brokenTraces() {
|
|
45013
|
+
if (!this.resolve) return [];
|
|
45014
|
+
return this.model.stories.flatMap(s => s.traces.filter(t => this.resolve(t) === false)
|
|
45015
|
+
.map(t => this._story(s, 'trace-missing', 'warning', `"${s.name}" traces to ${t.kind} "${t.name || t.ref}", which is not there`)));
|
|
45016
|
+
}
|
|
45017
|
+
|
|
45018
|
+
noEpic() {
|
|
45019
|
+
if (!this.model.epics.length) return [];
|
|
45020
|
+
return this.model.stories.filter(s => !this.model.epic(s.epic))
|
|
45021
|
+
.map(s => this._story(s, 'story-no-epic', 'info', `"${s.name}" is under no epic`));
|
|
45022
|
+
}
|
|
45023
|
+
|
|
45024
|
+
emptyEpics() {
|
|
45025
|
+
return this.model.epics.filter(e => !this.model.storiesOf(e.id).length)
|
|
45026
|
+
.map(e => ({ code: 'epic-empty', severity: 'info', kind: 'epic', ids: [e.id], message: `Epic "${e.name}" has no stories` }));
|
|
45027
|
+
}
|
|
45028
|
+
|
|
45029
|
+
sameNames() {
|
|
45030
|
+
const byName = new Map();
|
|
45031
|
+
for (const s of this.model.stories) {
|
|
45032
|
+
const key = s.name.trim().toLowerCase();
|
|
45033
|
+
if (key) byName.set(key, [...(byName.get(key) ?? []), s]);
|
|
45034
|
+
}
|
|
45035
|
+
return [...byName.values()].filter(list => list.length > 1)
|
|
45036
|
+
.map(list => ({ code: 'story-duplicate', severity: 'warning', kind: 'story', ids: list.map(s => s.id), message: `${list.length} stories are called "${list[0].name}"` }));
|
|
45037
|
+
}
|
|
45038
|
+
}
|
|
45039
|
+
|
|
45040
|
+
/** A plain copy of JSON-shaped data. */
|
|
45041
|
+
const copy = (value) => JSON.parse(JSON.stringify(value));
|
|
45042
|
+
|
|
45043
|
+
/**
|
|
45044
|
+
* What a story can trace to, and what its trace is called: ArchiMate's
|
|
45045
|
+
* motivation and implementation elements, any other element of an
|
|
45046
|
+
* architecture, and a data model's concepts, entities and tables — so a trace
|
|
45047
|
+
* runs epic → story → requirement → component → entity → table. A trace's
|
|
45048
|
+
* `kind` is any string; these are the ones offered.
|
|
45049
|
+
*/
|
|
45050
|
+
const TRACE_KINDS = Object.freeze([
|
|
45051
|
+
['requirement', 'Requirement (ArchiMate)'],
|
|
45052
|
+
['work-package', 'Work package (ArchiMate)'],
|
|
45053
|
+
['element', 'Architecture element'],
|
|
45054
|
+
['concept', 'Data concept'],
|
|
45055
|
+
['entity', 'Data entity'],
|
|
45056
|
+
['table', 'Table'],
|
|
45057
|
+
]);
|
|
45058
|
+
|
|
45059
|
+
/** Where a story is in its life, in order; a status is any string, these are offered. */
|
|
45060
|
+
const STATUSES = Object.freeze(['To do', 'In progress', 'Done']);
|
|
45061
|
+
|
|
45062
|
+
/**
|
|
45063
|
+
* RequirementsModel — what a system is to do, as an agile team writes it:
|
|
45064
|
+
* **epics** (large outcomes), the **stories** that deliver them ("As a … I
|
|
45065
|
+
* want … so that …"), each with its **acceptance criteria**, sliced into
|
|
45066
|
+
* **releases**; and **traces** from a story to what realises it in the other
|
|
45067
|
+
* models (TRACE_KINDS: an ArchiMate requirement or work package, an element,
|
|
45068
|
+
* a data concept, entity or table — `{kind, ref, name?, model?}`, `ref` that
|
|
45069
|
+
* model's id).
|
|
45070
|
+
*
|
|
45071
|
+
* Read as a **story map** (`storyMap()`): epics across in order, releases
|
|
45072
|
+
* down, each cell the stories of that epic in that release; stories with no
|
|
45073
|
+
* epic or no release have a column and a row of their own. `coverage()` and
|
|
45074
|
+
* the checker (RequirementsChecker) say what is missing — a story with no
|
|
45075
|
+
* acceptance criteria, nothing realising it — the numbers a bench measures.
|
|
45076
|
+
*
|
|
45077
|
+
* Spec (its toJSON): `{ name?, epics: [{id, name, description?}], releases:
|
|
45078
|
+
* [{id, name}], stories: [{id, name, epic?, release?, as?, want?, soThat?,
|
|
45079
|
+
* description?, criteria: [{id, text}], status?, priority?, points?, labels?,
|
|
45080
|
+
* source?: {system, key, url?}, traces: [{kind, ref, name?, model?}]}] }`.
|
|
45081
|
+
* Ids are made from names when not given. Every edit is a command (one undo
|
|
45082
|
+
* step); compare and merge, and changed-since-saved, are EditableModel's.
|
|
45083
|
+
*/
|
|
45084
|
+
class RequirementsModel extends EditableModel {
|
|
45085
|
+
static STATE = Object.freeze(['name', 'epics', 'releases', 'stories']);
|
|
45086
|
+
|
|
45087
|
+
static TRACE_KINDS = TRACE_KINDS;
|
|
45088
|
+
|
|
45089
|
+
static STATUSES = STATUSES;
|
|
45090
|
+
|
|
45091
|
+
/** A story map's column for stories with no epic, and its row for those in no release. */
|
|
45092
|
+
static NO_EPIC = '';
|
|
45093
|
+
static NO_RELEASE = '';
|
|
45094
|
+
|
|
45095
|
+
constructor(spec = {}) {
|
|
45096
|
+
super(spec);
|
|
45097
|
+
this.epics = [];
|
|
45098
|
+
this.releases = [];
|
|
45099
|
+
this.stories = [];
|
|
45100
|
+
for (const e of spec.epics ?? []) this.epics.push(this._epic(e));
|
|
45101
|
+
for (const r of spec.releases ?? []) this.releases.push(this._release(r));
|
|
45102
|
+
for (const s of spec.stories ?? []) this.stories.push(this._story(s));
|
|
45103
|
+
}
|
|
45104
|
+
|
|
45105
|
+
// ===== Reading =====
|
|
45106
|
+
|
|
45107
|
+
epic(id) { return this.epics.find(e => e.id === id) ?? null; }
|
|
45108
|
+
release(id) { return this.releases.find(r => r.id === id) ?? null; }
|
|
45109
|
+
story(id) { return this.stories.find(s => s.id === id) ?? null; }
|
|
45110
|
+
|
|
45111
|
+
/** The stories of an epic (NO_EPIC: of none), in order; with `release`, only those in it (NO_RELEASE: in none). */
|
|
45112
|
+
storiesOf(epicId, release) {
|
|
45113
|
+
return this.stories.filter(s => (s.epic || '') === (epicId || '')
|
|
45114
|
+
&& (release === undefined || (s.release || '') === (release || '')));
|
|
45115
|
+
}
|
|
45116
|
+
|
|
45117
|
+
/** The story's sentence: "As a … I want … so that …", from what it has. */
|
|
45118
|
+
static narrative(story) {
|
|
45119
|
+
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}`]
|
|
45120
|
+
.filter(Boolean).join(', ');
|
|
45121
|
+
}
|
|
45122
|
+
|
|
45123
|
+
/**
|
|
45124
|
+
* The story map: `columns` the epics in order (then NO_EPIC, if any story
|
|
45125
|
+
* has none), `rows` the releases in order (then NO_RELEASE, if any story is
|
|
45126
|
+
* in none, or there are no releases), and `cell(epicId, releaseId)` the
|
|
45127
|
+
* stories there, in order.
|
|
45128
|
+
*/
|
|
45129
|
+
storyMap() {
|
|
45130
|
+
const columns = [...this.epics.map(e => ({ id: e.id, name: e.name, epic: e }))];
|
|
45131
|
+
if (this.stories.some(s => !this.epic(s.epic))) columns.push({ id: RequirementsModel.NO_EPIC, name: 'No epic', epic: null });
|
|
45132
|
+
const rows = this.releases.map(r => ({ id: r.id, name: r.name, release: r }));
|
|
45133
|
+
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 });
|
|
45134
|
+
const epicOf = (s) => (this.epic(s.epic) ? s.epic : RequirementsModel.NO_EPIC);
|
|
45135
|
+
const releaseOf = (s) => (this.release(s.release) ? s.release : RequirementsModel.NO_RELEASE);
|
|
45136
|
+
return {
|
|
45137
|
+
columns, rows,
|
|
45138
|
+
cell: (epicId, releaseId) => this.stories.filter(s => epicOf(s) === epicId && releaseOf(s) === releaseId),
|
|
45139
|
+
};
|
|
45140
|
+
}
|
|
45141
|
+
|
|
45142
|
+
/** The stories that trace to `ref` (with `kind`, only traces of that kind): what a change to it touches. */
|
|
45143
|
+
tracingTo(ref, kind) {
|
|
45144
|
+
return this.stories.filter(s => s.traces.some(t => t.ref === ref && (!kind || t.kind === kind)));
|
|
45145
|
+
}
|
|
45146
|
+
|
|
45147
|
+
/**
|
|
45148
|
+
* How complete it is, as numbers to measure by: stories, how many have
|
|
45149
|
+
* acceptance criteria, how many trace to something, epics with no stories,
|
|
45150
|
+
* and each as a share (0..1; 1 with nothing to count).
|
|
45151
|
+
*/
|
|
45152
|
+
coverage() {
|
|
45153
|
+
const stories = this.stories.length;
|
|
45154
|
+
const withCriteria = this.stories.filter(s => s.criteria.length).length;
|
|
45155
|
+
const traced = this.stories.filter(s => s.traces.length).length;
|
|
45156
|
+
const emptyEpics = this.epics.filter(e => !this.storiesOf(e.id).length).length;
|
|
45157
|
+
const share = (n, of) => (of ? n / of : 1);
|
|
45158
|
+
return {
|
|
45159
|
+
stories, withCriteria, traced, epics: this.epics.length, emptyEpics,
|
|
45160
|
+
criteriaShare: share(withCriteria, stories), traceShare: share(traced, stories),
|
|
45161
|
+
};
|
|
45162
|
+
}
|
|
45163
|
+
|
|
45164
|
+
/** What it is measured by (Benchmark): its check's findings, and how many stories say when they are done and trace to something. */
|
|
45165
|
+
benchmark() {
|
|
45166
|
+
const c = this.coverage();
|
|
45167
|
+
return [
|
|
45168
|
+
...Benchmark.findings(this),
|
|
45169
|
+
{ key: 'criteria', label: 'Stories with acceptance criteria', value: c.criteriaShare, better: 'higher', format: 'share' },
|
|
45170
|
+
{ key: 'traced', label: 'Stories traced', value: c.traceShare, better: 'higher', format: 'share' },
|
|
45171
|
+
];
|
|
45172
|
+
}
|
|
45173
|
+
|
|
45174
|
+
/** Its checks (RequirementsChecker). */
|
|
45175
|
+
checker(options) {
|
|
45176
|
+
return new RequirementsChecker(this, options);
|
|
45177
|
+
}
|
|
45178
|
+
|
|
45179
|
+
comparedKinds() {
|
|
45180
|
+
return [new EpicsCompared(), new ReleasesCompared(), new StoriesCompared()];
|
|
45181
|
+
}
|
|
45182
|
+
|
|
45183
|
+
// ===== Editing (each an editCommand: one undo step) =====
|
|
45184
|
+
|
|
45185
|
+
/** Add an epic (`id` null) or change one. The command's result is the epic. */
|
|
45186
|
+
saveEpicCommand(id, spec) {
|
|
45187
|
+
return this.editCommand(id ? `Edit epic ${spec.name ?? id}` : `New epic ${spec.name ?? ''}`.trim(), (model) => {
|
|
45188
|
+
if (!id) { const epic = model._epic(spec); model.epics.push(epic); return epic; }
|
|
45189
|
+
const epic = model.epic(id);
|
|
45190
|
+
if (epic) Object.assign(epic, model._epic({ ...epic, ...spec, id }));
|
|
45191
|
+
return epic;
|
|
45192
|
+
});
|
|
45193
|
+
}
|
|
45194
|
+
|
|
45195
|
+
/** Remove an epic; its stories stay, with no epic. */
|
|
45196
|
+
removeEpicCommand(id) {
|
|
45197
|
+
return this.editCommand(`Remove epic ${this.epic(id)?.name ?? id}`, (model) => {
|
|
45198
|
+
model.epics = model.epics.filter(e => e.id !== id);
|
|
45199
|
+
for (const s of model.stories) if (s.epic === id) s.epic = '';
|
|
45200
|
+
});
|
|
45201
|
+
}
|
|
45202
|
+
|
|
45203
|
+
/** Add a release (`id` null) or rename one. */
|
|
45204
|
+
saveReleaseCommand(id, spec) {
|
|
45205
|
+
return this.editCommand(id ? `Edit release ${spec.name ?? id}` : `New release ${spec.name ?? ''}`.trim(), (model) => {
|
|
45206
|
+
if (!id) { const release = model._release(spec); model.releases.push(release); return release; }
|
|
45207
|
+
const release = model.release(id);
|
|
45208
|
+
if (release) Object.assign(release, model._release({ ...release, ...spec, id }));
|
|
45209
|
+
return release;
|
|
45210
|
+
});
|
|
45211
|
+
}
|
|
45212
|
+
|
|
45213
|
+
/** Remove a release; its stories stay, planned in none. */
|
|
45214
|
+
removeReleaseCommand(id) {
|
|
45215
|
+
return this.editCommand(`Remove release ${this.release(id)?.name ?? id}`, (model) => {
|
|
45216
|
+
model.releases = model.releases.filter(r => r.id !== id);
|
|
45217
|
+
for (const s of model.stories) if (s.release === id) s.release = '';
|
|
45218
|
+
});
|
|
45219
|
+
}
|
|
45220
|
+
|
|
45221
|
+
/** Add a story (`id` null) or change one — what the spec leaves out kept. The command's result is the story. */
|
|
45222
|
+
saveStoryCommand(id, spec) {
|
|
45223
|
+
return this.editCommand(id ? `Edit ${spec.name ?? this.story(id)?.name ?? id}` : `New story ${spec.name ?? ''}`.trim(), (model) => {
|
|
45224
|
+
if (!id) { const story = model._story(spec); model.stories.push(story); return story; }
|
|
45225
|
+
const at = model.stories.findIndex(s => s.id === id);
|
|
45226
|
+
if (at < 0) return null;
|
|
45227
|
+
model.stories[at] = model._story({ ...model.stories[at], ...spec, id });
|
|
45228
|
+
return model.stories[at];
|
|
45229
|
+
});
|
|
45230
|
+
}
|
|
45231
|
+
|
|
45232
|
+
removeStoryCommand(id) {
|
|
45233
|
+
return this.editCommand(`Remove ${this.story(id)?.name ?? id}`, (model) => {
|
|
45234
|
+
model.stories = model.stories.filter(s => s.id !== id);
|
|
45235
|
+
});
|
|
45236
|
+
}
|
|
45237
|
+
|
|
45238
|
+
/**
|
|
45239
|
+
* Move a story on the map: to an epic and a release (NO_EPIC, NO_RELEASE:
|
|
45240
|
+
* none), before another story there (`before`; none: last).
|
|
45241
|
+
*/
|
|
45242
|
+
moveStoryCommand(id, { epic, release, before = null }) {
|
|
45243
|
+
return this.editCommand(`Move ${this.story(id)?.name ?? id}`, (model) => {
|
|
45244
|
+
const story = model.story(id);
|
|
45245
|
+
if (!story || before === id) return;
|
|
45246
|
+
if (epic !== undefined) story.epic = epic || '';
|
|
45247
|
+
if (release !== undefined) story.release = release || '';
|
|
45248
|
+
model.stories.splice(model.stories.indexOf(story), 1);
|
|
45249
|
+
const at = before ? model.stories.findIndex(s => s.id === before) : -1;
|
|
45250
|
+
model.stories.splice(at < 0 ? model.stories.length : at, 0, story);
|
|
45251
|
+
});
|
|
45252
|
+
}
|
|
45253
|
+
|
|
45254
|
+
/** Trace a story to something that realises it (`{kind, ref, name?, model?}`); once per kind and ref. */
|
|
45255
|
+
traceCommand(storyId, trace) {
|
|
45256
|
+
return this.editCommand(`Trace ${this.story(storyId)?.name ?? storyId} to ${trace.name || trace.ref}`, (model) => {
|
|
45257
|
+
const story = model.story(storyId);
|
|
45258
|
+
if (!story || story.traces.some(t => t.kind === trace.kind && t.ref === trace.ref)) return;
|
|
45259
|
+
story.traces.push(RequirementsModel._trace(trace));
|
|
45260
|
+
});
|
|
45261
|
+
}
|
|
45262
|
+
|
|
45263
|
+
/** Remove a story's trace to `ref` (of `kind`, if given). */
|
|
45264
|
+
untraceCommand(storyId, ref, kind) {
|
|
45265
|
+
return this.editCommand(`Remove the trace of ${this.story(storyId)?.name ?? storyId}`, (model) => {
|
|
45266
|
+
const story = model.story(storyId);
|
|
45267
|
+
if (story) story.traces = story.traces.filter(t => !(t.ref === ref && (!kind || t.kind === kind)));
|
|
45268
|
+
});
|
|
45269
|
+
}
|
|
45270
|
+
|
|
45271
|
+
/**
|
|
45272
|
+
* Bring epics, releases and stories in (an import): one undo step. One with
|
|
45273
|
+
* the id of one there replaces it; the rest are added.
|
|
45274
|
+
*/
|
|
45275
|
+
bringCommand({ epics = [], releases = [], stories = [] }, label = 'Import') {
|
|
45276
|
+
return this.editCommand(label, (model) => {
|
|
45277
|
+
const put = (list, item) => {
|
|
45278
|
+
const at = list.findIndex(x => x.id === item.id);
|
|
45279
|
+
if (at < 0) list.push(item); else list[at] = item;
|
|
45280
|
+
};
|
|
45281
|
+
for (const e of epics) put(model.epics, model._epic(e));
|
|
45282
|
+
for (const r of releases) put(model.releases, model._release(r));
|
|
45283
|
+
for (const s of stories) put(model.stories, model._story(s));
|
|
45284
|
+
});
|
|
45285
|
+
}
|
|
45286
|
+
|
|
45287
|
+
// ===== Its parts, read from a spec =====
|
|
45288
|
+
|
|
45289
|
+
/** @private */
|
|
45290
|
+
_epic({ id, name = '', description = '', source } = {}) {
|
|
45291
|
+
return {
|
|
45292
|
+
id: id ?? this._freshId(name || 'epic', this.epics), name, description,
|
|
45293
|
+
...(source && { source: copy(source) }),
|
|
45294
|
+
};
|
|
45295
|
+
}
|
|
45296
|
+
|
|
45297
|
+
/** @private */
|
|
45298
|
+
_release({ id, name = '' } = {}) {
|
|
45299
|
+
return { id: id ?? this._freshId(name || 'release', this.releases), name };
|
|
45300
|
+
}
|
|
45301
|
+
|
|
45302
|
+
/** @private */
|
|
45303
|
+
_story(spec = {}) {
|
|
45304
|
+
const { id, name = '', epic = '', release = '', as = '', want = '', soThat = '', description = '', status = '', priority = '', points = null, labels = [], source, criteria = [], traces = [] } = spec;
|
|
45305
|
+
const story = {
|
|
45306
|
+
id: id ?? this._freshId(name || 'story', this.stories), name, epic: epic ?? '', release: release ?? '',
|
|
45307
|
+
as, want, soThat, description, status, priority,
|
|
45308
|
+
points: Number.isFinite(Number(points)) && points !== null && points !== '' ? Number(points) : null,
|
|
45309
|
+
labels: [...labels],
|
|
45310
|
+
criteria: [],
|
|
45311
|
+
traces: traces.map(t => RequirementsModel._trace(t)),
|
|
45312
|
+
...(source && { source: copy(source) }),
|
|
45313
|
+
};
|
|
45314
|
+
// A new criterion is numbered past every id the story's criteria have, so none is given twice.
|
|
45315
|
+
const given = new Set(criteria.map(c => (typeof c === 'object' && c?.id) || null).filter(Boolean));
|
|
45316
|
+
let n = 0;
|
|
45317
|
+
const fresh = () => { let id; do id = `${story.id}-ac${++n}`; while (given.has(id)); given.add(id); return id; };
|
|
45318
|
+
for (const c of criteria) {
|
|
45319
|
+
const text = typeof c === 'string' ? c : c?.text;
|
|
45320
|
+
if (!String(text ?? '').trim()) continue;
|
|
45321
|
+
story.criteria.push({ id: (typeof c === 'object' && c.id) || fresh(), text: String(text).trim() });
|
|
45322
|
+
}
|
|
45323
|
+
return story;
|
|
45324
|
+
}
|
|
45325
|
+
|
|
45326
|
+
/** @private */
|
|
45327
|
+
static _trace({ kind = 'element', ref, name = '', model = '' } = {}) {
|
|
45328
|
+
return { kind, ref: String(ref ?? ''), ...(name && { name }), ...(model && { model }) };
|
|
45329
|
+
}
|
|
45330
|
+
|
|
45331
|
+
toJSON() {
|
|
45332
|
+
return {
|
|
45333
|
+
name: this.name,
|
|
45334
|
+
epics: copy(this.epics),
|
|
45335
|
+
releases: copy(this.releases),
|
|
45336
|
+
stories: copy(this.stories),
|
|
45337
|
+
};
|
|
45338
|
+
}
|
|
45339
|
+
}
|
|
45340
|
+
|
|
45341
|
+
/**
|
|
45342
|
+
* RequirementsFileFormat — a requirements model as a file
|
|
45343
|
+
* (`.requirements.json`): its epics, releases and stories
|
|
45344
|
+
* (RequirementsModel#toJSON), read back by `new RequirementsModel(json)`.
|
|
45345
|
+
*/
|
|
45346
|
+
class RequirementsFileFormat extends ModelFileFormat {
|
|
45347
|
+
get label() { return '.requirements.json'; }
|
|
45348
|
+
get accept() { return '.requirements.json,.json'; }
|
|
45349
|
+
get fileName() { return 'model.requirements.json'; }
|
|
45350
|
+
get featureName() { return 'Requirements'; }
|
|
45351
|
+
|
|
45352
|
+
/** JSON with a requirements model's shape: stories, or epics. */
|
|
45353
|
+
recognizes(text) {
|
|
45354
|
+
try {
|
|
45355
|
+
const json = JSON.parse(text);
|
|
45356
|
+
return !!json && typeof json === 'object' && (Array.isArray(json.stories) || Array.isArray(json.epics));
|
|
45357
|
+
} catch {
|
|
45358
|
+
return false;
|
|
45359
|
+
}
|
|
45360
|
+
}
|
|
45361
|
+
|
|
45362
|
+
export(model) {
|
|
45363
|
+
return { data: JSON.stringify(model.toJSON(), null, 2), mimeType: 'application/json' };
|
|
45364
|
+
}
|
|
45365
|
+
|
|
45366
|
+
import(text) {
|
|
45367
|
+
return new RequirementsModel(JSON.parse(text));
|
|
45368
|
+
}
|
|
45369
|
+
}
|
|
45370
|
+
|
|
45371
|
+
/**
|
|
45372
|
+
* Where each tracker's export keeps what a story needs: per tracker, the
|
|
45373
|
+
* column names (CSV) each field may be under, first found wins; which issue
|
|
45374
|
+
* types are epics; how its list fields are separated. One definition per
|
|
45375
|
+
* tracker — the CSV and the JSON readers both map onto these fields. A host
|
|
45376
|
+
* gives its own (a custom field's column) through `new TrackerExport({ columns })`.
|
|
45377
|
+
*/
|
|
45378
|
+
const TRACKERS = Object.freeze({
|
|
45379
|
+
jira: {
|
|
45380
|
+
label: 'Jira',
|
|
45381
|
+
// Columns only its exports have, to tell it by.
|
|
45382
|
+
marks: ['Issue key', 'Issue Type'],
|
|
45383
|
+
columns: {
|
|
45384
|
+
key: ['Issue key', 'Key'], id: ['Issue id'], type: ['Issue Type'], title: ['Summary'],
|
|
45385
|
+
description: ['Description'], parent: ['Parent', 'Parent id', 'Parent key', 'Custom field (Epic Link)', 'Epic Link'],
|
|
45386
|
+
status: ['Status'], priority: ['Priority'],
|
|
45387
|
+
points: ['Custom field (Story Points)', 'Story Points', 'Custom field (Story point estimate)', 'Story point estimate'],
|
|
45388
|
+
release: ['Fix Version/s', 'Fix versions', 'Sprint'], labels: ['Labels'],
|
|
45389
|
+
criteria: ['Custom field (Acceptance Criteria)', 'Acceptance Criteria'], url: [],
|
|
45390
|
+
},
|
|
45391
|
+
epicTypes: ['Epic'], skipTypes: ['Sub-task', 'Subtask'], labelSeparator: /[\s,]+/,
|
|
45392
|
+
},
|
|
45393
|
+
azure: {
|
|
45394
|
+
label: 'Azure DevOps',
|
|
45395
|
+
marks: ['Work Item Type'],
|
|
45396
|
+
columns: {
|
|
45397
|
+
key: ['ID'], id: ['ID'], type: ['Work Item Type'], title: ['Title', 'Title 1', 'Title 2', 'Title 3'],
|
|
45398
|
+
description: ['Description'], parent: ['Parent'], status: ['State'], priority: ['Priority'],
|
|
45399
|
+
points: ['Story Points', 'Effort', 'Size'], release: ['Iteration Path'], labels: ['Tags'],
|
|
45400
|
+
criteria: ['Acceptance Criteria'], url: [],
|
|
45401
|
+
},
|
|
45402
|
+
epicTypes: ['Epic', 'Feature'], skipTypes: ['Task'], labelSeparator: /\s*;\s*/,
|
|
45403
|
+
},
|
|
45404
|
+
gitlab: {
|
|
45405
|
+
label: 'GitLab',
|
|
45406
|
+
marks: ['Issue ID', 'URL'],
|
|
45407
|
+
columns: {
|
|
45408
|
+
key: ['Issue ID', 'IID'], id: ['Issue ID', 'IID'], type: [], title: ['Title'], description: ['Description'],
|
|
45409
|
+
parent: ['Epic ID'], parentName: ['Epic Title'], status: ['State'], priority: [], points: ['Weight'],
|
|
45410
|
+
release: ['Milestone'], labels: ['Labels'], criteria: [], url: ['URL'],
|
|
45411
|
+
},
|
|
45412
|
+
epicTypes: [], skipTypes: [], labelSeparator: /\s*,\s*/,
|
|
45413
|
+
},
|
|
45414
|
+
});
|
|
45415
|
+
|
|
45416
|
+
/**
|
|
45417
|
+
* Where Jira's JSON keeps what has no field of its own: custom fields, by key.
|
|
45418
|
+
* Jira Cloud's "Story point estimate" is customfield_10016 unless a site
|
|
45419
|
+
* changed it; a host names its own (`columns: { points: ['customfield_10026'] }`).
|
|
45420
|
+
*/
|
|
45421
|
+
const JIRA_FIELDS = Object.freeze({
|
|
45422
|
+
points: ['story_points', 'storyPoints', 'customfield_10016'],
|
|
45423
|
+
criteria: ['acceptanceCriteria'],
|
|
45424
|
+
});
|
|
45425
|
+
|
|
45426
|
+
/** Text with HTML in it (Azure DevOps keeps descriptions as HTML) as lines of plain text. */
|
|
45427
|
+
function plainText(value) {
|
|
45428
|
+
if (value == null) return '';
|
|
45429
|
+
if (typeof value === 'object') return documentText(value);
|
|
45430
|
+
return String(value)
|
|
45431
|
+
.replace(/<\s*(br|\/p|\/div|\/li|\/h\d)\s*\/?>/gi, '\n').replace(/<\s*li[^>]*>/gi, '- ')
|
|
45432
|
+
.replace(/<[^>]+>/g, '')
|
|
45433
|
+
.replace(/ /g, ' ').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, '&')
|
|
45434
|
+
.replace(/\r\n?/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
|
|
45435
|
+
}
|
|
45436
|
+
|
|
45437
|
+
/** Jira Cloud's description, a document of nodes, as text: each paragraph and list item a line. */
|
|
45438
|
+
function documentText(node) {
|
|
45439
|
+
if (!node || typeof node !== 'object') return '';
|
|
45440
|
+
if (node.type === 'text') return node.text ?? '';
|
|
45441
|
+
const inner = (node.content ?? []).map(documentText).join('');
|
|
45442
|
+
return ['paragraph', 'heading', 'listItem', 'codeBlock'].includes(node.type) ? `${node.type === 'listItem' ? '- ' : ''}${inner.trim()}\n` : inner;
|
|
45443
|
+
}
|
|
45444
|
+
|
|
45445
|
+
/** The lines of a list: "- x", "* x", "1. x", "• x", "[ ] x" — each item a line, its marker gone. */
|
|
45446
|
+
function listItems(text) {
|
|
45447
|
+
return String(text ?? '').split('\n')
|
|
45448
|
+
.map(line => line.replace(/^\s*(?:[-*•+]|\d+[.)]|\[[ xX]?\])\s+/, '').trim())
|
|
45449
|
+
.filter(Boolean);
|
|
45450
|
+
}
|
|
45451
|
+
|
|
45452
|
+
/**
|
|
45453
|
+
* Acceptance criteria in a story's text: the lines under an "Acceptance
|
|
45454
|
+
* criteria" heading, else its Given/When/Then scenarios (one each), else none.
|
|
45455
|
+
* @returns {{criteria: Array<string>, rest: string}} - and the text without them
|
|
45456
|
+
*/
|
|
45457
|
+
function criteriaFrom(text) {
|
|
45458
|
+
const s = plainText(text);
|
|
45459
|
+
const heading = /^\s*(?:#+\s*|\*\*)?acceptance criteria\b[^\n]*\n/im.exec(s);
|
|
45460
|
+
if (heading) {
|
|
45461
|
+
const after = s.slice(heading.index + heading[0].length);
|
|
45462
|
+
const end = after.search(/\n\s*\n\s*(?:#+\s|\*\*|[A-Z][^\n:]{0,40}:\s*\n)/);
|
|
45463
|
+
const block = end < 0 ? after : after.slice(0, end);
|
|
45464
|
+
return { criteria: listItems(block), rest: (s.slice(0, heading.index) + (end < 0 ? '' : after.slice(end))).replace(/\n{3,}/g, '\n\n').trim() };
|
|
45465
|
+
}
|
|
45466
|
+
const scenarios = [];
|
|
45467
|
+
let current = null;
|
|
45468
|
+
for (const line of s.split('\n')) {
|
|
45469
|
+
const t = line.replace(/^\s*(?:[-*•]\s+)?/, '').trim();
|
|
45470
|
+
if (/^given\b/i.test(t)) { current = [t]; scenarios.push(current); } else if (current && /^(when|then|and|but)\b/i.test(t)) current.push(t); else current = null;
|
|
45471
|
+
}
|
|
45472
|
+
if (scenarios.length) return { criteria: scenarios.map(lines => lines.join(' ')), rest: s.split('\n').filter(l => !/^\s*(?:[-*•]\s+)?(given|when|then|and|but)\b/i.test(l)).join('\n').trim() };
|
|
45473
|
+
return { criteria: [], rest: s };
|
|
45474
|
+
}
|
|
45475
|
+
|
|
45476
|
+
/** A field that holds only acceptance criteria: as criteriaFrom reads them, else each of its lines one. */
|
|
45477
|
+
function criteriaField(text) {
|
|
45478
|
+
const found = criteriaFrom(text).criteria;
|
|
45479
|
+
return found.length ? found : listItems(plainText(text));
|
|
45480
|
+
}
|
|
45481
|
+
|
|
45482
|
+
/** "As a <who>, I want <what> so that <why>" in a text: its three parts (empty where it says nothing). */
|
|
45483
|
+
function narrativeFrom(text) {
|
|
45484
|
+
const m = /\bas an?\s+([^,\n]+?)\s*,?\s+i\s+(?:want|need|would like)\s+([^\n]+?)(?:\s*,?\s+so\s+that\s+([^\n]+?))?\s*(?:[.\n]|$)/i.exec(plainText(text));
|
|
45485
|
+
return m ? { as: m[1].trim(), want: m[2].trim(), soThat: (m[3] ?? '').trim() } : { as: '', want: '', soThat: '' };
|
|
45486
|
+
}
|
|
45487
|
+
|
|
45488
|
+
/** A text without its "As a … I want …" line: what is left to describe the story, once its sentence is read (narrativeFrom). */
|
|
45489
|
+
function withoutNarrative(text) {
|
|
45490
|
+
return String(text ?? '').replace(/^\s*\**\s*as an?\s[^\n]*\bi\s+(want|need|would like)\b[^\n]*\n?/im, '').trim();
|
|
45491
|
+
}
|
|
45492
|
+
|
|
45493
|
+
/**
|
|
45494
|
+
* TrackerExport — epics and stories from what an issue tracker exports: Jira,
|
|
45495
|
+
* Azure DevOps or GitLab (TRACKERS), as CSV or as their API's JSON. An issue
|
|
45496
|
+
* of an epic type becomes an epic (GitLab's epics are named by their issues'
|
|
45497
|
+
* Epic ID and title); the rest become stories under their parent epic, with
|
|
45498
|
+
* their status, priority, points, labels and release (fix version, iteration
|
|
45499
|
+
* or milestone). Acceptance criteria come from their own field, else from
|
|
45500
|
+
* the description (criteriaFrom); "As a … I want … so that …" from the
|
|
45501
|
+
* description or the title (narrativeFrom). Each keeps where it came from
|
|
45502
|
+
* (`source: {system, key, url?}`), and its id is the tracker's key, so
|
|
45503
|
+
* reading a newer export over a model replaces what it had.
|
|
45504
|
+
*
|
|
45505
|
+
* `read(text)` gives a RequirementsModel spec (`{epics, releases, stories}`).
|
|
45506
|
+
*/
|
|
45507
|
+
class TrackerExport {
|
|
45508
|
+
/**
|
|
45509
|
+
* @param {Object} [options]
|
|
45510
|
+
* @param {string} [options.tracker] - 'jira', 'azure', 'gitlab' (default: told from the export)
|
|
45511
|
+
* @param {Object<string, Array<string>>} [options.columns] - Column names to look in first, by field (in Jira's JSON, field keys)
|
|
45512
|
+
*/
|
|
45513
|
+
constructor({ tracker = null, columns = {} } = {}) {
|
|
45514
|
+
this.tracker = tracker;
|
|
45515
|
+
this.columns = columns;
|
|
45516
|
+
}
|
|
45517
|
+
|
|
45518
|
+
/** What a tracker is called ('Jira'). */
|
|
45519
|
+
static label(tracker) {
|
|
45520
|
+
return TRACKERS[tracker]?.label ?? 'Tracker';
|
|
45521
|
+
}
|
|
45522
|
+
|
|
45523
|
+
/** Which tracker an export's columns (or JSON) are from, or null. */
|
|
45524
|
+
static detect(text) {
|
|
45525
|
+
const json = TrackerExport._json(text);
|
|
45526
|
+
if (json) {
|
|
45527
|
+
if (Array.isArray(json.issues) && json.issues[0]?.fields) return 'jira';
|
|
45528
|
+
if (Array.isArray(json.value ?? json.workItems) && (json.value ?? json.workItems)[0]?.fields) return 'azure';
|
|
45529
|
+
if (Array.isArray(json) && json[0] && 'iid' in json[0]) return 'gitlab';
|
|
45530
|
+
return null;
|
|
45531
|
+
}
|
|
45532
|
+
const { columns } = parseCsvTable(text);
|
|
45533
|
+
const has = new Set(columns.map(c => c.toLowerCase()));
|
|
45534
|
+
const found = Object.entries(TRACKERS).find(([, t]) => t.marks.every(m => has.has(m.toLowerCase())));
|
|
45535
|
+
return found?.[0] ?? null;
|
|
45536
|
+
}
|
|
45537
|
+
|
|
45538
|
+
/** @private — an API's JSON, given as text or already parsed. */
|
|
45539
|
+
static _json(text) {
|
|
45540
|
+
if (text && typeof text === 'object') return text;
|
|
45541
|
+
const s = String(text ?? '').trim();
|
|
45542
|
+
if (!s.startsWith('{') && !s.startsWith('[')) return null;
|
|
45543
|
+
try { return JSON.parse(s); } catch { return null; }
|
|
45544
|
+
}
|
|
45545
|
+
|
|
45546
|
+
/**
|
|
45547
|
+
* The export (its text; an API's JSON as text or parsed — TrackerApi) as a requirements model's spec.
|
|
45548
|
+
* @returns {{epics: Array, releases: Array, stories: Array, tracker: string|null}}
|
|
45549
|
+
*/
|
|
45550
|
+
read(text) {
|
|
45551
|
+
const tracker = this.tracker ?? TrackerExport.detect(text) ?? 'jira';
|
|
45552
|
+
const def = TRACKERS[tracker];
|
|
45553
|
+
if (!def) throw new Error(`Unknown tracker: "${tracker}"`);
|
|
45554
|
+
const json = TrackerExport._json(text);
|
|
45555
|
+
const issues = json ? this._fromJson(tracker, json) : this._fromCsv(def, text);
|
|
45556
|
+
return { ...this._model(tracker, def, issues), tracker };
|
|
45557
|
+
}
|
|
45558
|
+
|
|
45559
|
+
/** CSV rows as issues: each field from the first of its columns there. @private */
|
|
45560
|
+
_fromCsv(def, text) {
|
|
45561
|
+
const { columns, rows } = parseCsvTable(text);
|
|
45562
|
+
const lower = new Map(columns.map(c => [c.toLowerCase(), c]));
|
|
45563
|
+
const columnOf = (field) => [...(this.columns[field] ?? []), ...(def.columns[field] ?? [])].map(c => lower.get(c.toLowerCase())).find(Boolean);
|
|
45564
|
+
const fields = Object.keys({ ...def.columns, ...this.columns, parentName: [] });
|
|
45565
|
+
const at = Object.fromEntries(fields.map(f => [f, columnOf(f)]));
|
|
45566
|
+
return rows.map(row => Object.fromEntries(fields.map(f => [f, at[f] ? row[at[f]] : ''])));
|
|
45567
|
+
}
|
|
45568
|
+
|
|
45569
|
+
/** An API's JSON as issues, in the CSV's fields. @private */
|
|
45570
|
+
_fromJson(tracker, json) {
|
|
45571
|
+
if (tracker === 'jira') {
|
|
45572
|
+
return (json.issues ?? []).map(({ key, id, fields: f = {} }) => {
|
|
45573
|
+
const custom = (names) => Object.entries(f).find(([k]) => names.some(n => k.toLowerCase().includes(n)))?.[1];
|
|
45574
|
+
// A field the host named (its custom field's key), else Jira Cloud's usual one.
|
|
45575
|
+
const field = (name) => [...(this.columns[name] ?? []), ...(JIRA_FIELDS[name] ?? [])].map(k => f[k]).find(v => v != null && v !== '');
|
|
45576
|
+
return {
|
|
45577
|
+
key, id: String(id ?? ''), type: f.issuetype?.name ?? '', title: f.summary ?? '', description: f.description ?? '',
|
|
45578
|
+
parent: f.parent?.key ?? f.parent?.id ?? '', status: f.status?.name ?? '', priority: f.priority?.name ?? '',
|
|
45579
|
+
points: field('points') ?? custom(['storypoint', 'story_point']) ?? '',
|
|
45580
|
+
release: (f.fixVersions ?? [])[0]?.name ?? '', labels: (f.labels ?? []).join(' '),
|
|
45581
|
+
criteria: field('criteria') ?? custom(['acceptance']) ?? '', url: '',
|
|
45582
|
+
};
|
|
45583
|
+
});
|
|
45584
|
+
}
|
|
45585
|
+
if (tracker === 'azure') {
|
|
45586
|
+
return (json.value ?? json.workItems ?? []).map(({ id, fields: f = {}, url }) => ({
|
|
45587
|
+
key: String(id), id: String(id), type: f['System.WorkItemType'] ?? '', title: f['System.Title'] ?? '',
|
|
45588
|
+
description: f['System.Description'] ?? '', parent: String(f['System.Parent'] ?? ''), status: f['System.State'] ?? '',
|
|
45589
|
+
priority: String(f['Microsoft.VSTS.Common.Priority'] ?? ''),
|
|
45590
|
+
points: f['Microsoft.VSTS.Scheduling.StoryPoints'] ?? f['Microsoft.VSTS.Scheduling.Effort'] ?? '',
|
|
45591
|
+
release: f['System.IterationPath'] ?? '', labels: f['System.Tags'] ?? '',
|
|
45592
|
+
criteria: f['Microsoft.VSTS.Common.AcceptanceCriteria'] ?? '', url: url ?? '',
|
|
45593
|
+
}));
|
|
45594
|
+
}
|
|
45595
|
+
return (Array.isArray(json) ? json : []).map(i => ({
|
|
45596
|
+
key: String(i.iid ?? i.id), id: String(i.iid ?? i.id), type: '', title: i.title ?? '', description: i.description ?? '',
|
|
45597
|
+
parent: String(i.epic?.id ?? i.epic_iid ?? ''), parentName: i.epic?.title ?? '', status: i.state ?? '', priority: '',
|
|
45598
|
+
points: i.weight ?? '', release: i.milestone?.title ?? '', labels: (i.labels ?? []).map(l => l.name ?? l).join(','),
|
|
45599
|
+
criteria: '', url: i.web_url ?? '',
|
|
45600
|
+
}));
|
|
45601
|
+
}
|
|
45602
|
+
|
|
45603
|
+
/** Issues as epics, releases and stories. @private */
|
|
45604
|
+
_model(tracker, def, issues) {
|
|
45605
|
+
const is = (list, type) => list.some(t => t.toLowerCase() === String(type ?? '').trim().toLowerCase());
|
|
45606
|
+
const keyOf = new Map(); // an issue's id or key → its key (Jira's CSV names a parent by its id)
|
|
45607
|
+
for (const i of issues) for (const k of [i.key, i.id]) if (k) keyOf.set(String(k), String(i.key || i.id));
|
|
45608
|
+
const source = (i, key) => ({ system: tracker, key, ...(i.url && { url: i.url }) });
|
|
45609
|
+
|
|
45610
|
+
const epics = new Map();
|
|
45611
|
+
for (const i of issues) {
|
|
45612
|
+
if (!is(def.epicTypes, i.type)) continue;
|
|
45613
|
+
const key = String(i.key || i.id);
|
|
45614
|
+
epics.set(key, { id: key, name: plainText(i.title), description: plainText(i.description), source: source(i, key) });
|
|
45615
|
+
}
|
|
45616
|
+
const releases = new Map();
|
|
45617
|
+
const stories = [];
|
|
45618
|
+
for (const i of issues) {
|
|
45619
|
+
if (is(def.epicTypes, i.type) || is(def.skipTypes, i.type) || !String(i.title ?? '').trim()) continue;
|
|
45620
|
+
const key = String(i.key || i.id);
|
|
45621
|
+
const parent = i.parent ? keyOf.get(String(i.parent)) ?? String(i.parent) : '';
|
|
45622
|
+
if (parent && !epics.has(parent) && i.parentName) epics.set(parent, { id: parent, name: plainText(i.parentName), description: '', source: { system: tracker, key: parent } });
|
|
45623
|
+
const release = plainText(i.release);
|
|
45624
|
+
if (release && !releases.has(release)) releases.set(release, { id: release, name: release.split(/[\\/]/).pop() });
|
|
45625
|
+
const own = criteriaField(i.criteria);
|
|
45626
|
+
const inText = own.length ? { criteria: [], rest: plainText(i.description) } : criteriaFrom(i.description);
|
|
45627
|
+
const said = narrativeFrom(i.description);
|
|
45628
|
+
const { as, want, soThat } = said.want ? said : narrativeFrom(i.title);
|
|
45629
|
+
stories.push({
|
|
45630
|
+
id: key, name: plainText(i.title), epic: parent, release,
|
|
45631
|
+
as, want, soThat, description: said.want ? withoutNarrative(inText.rest) : inText.rest,
|
|
45632
|
+
criteria: own.length ? own : inText.criteria,
|
|
45633
|
+
status: plainText(i.status), priority: plainText(i.priority), points: i.points === '' ? null : i.points,
|
|
45634
|
+
labels: String(i.labels ?? '').split(def.labelSeparator).map(l => l.trim()).filter(Boolean),
|
|
45635
|
+
source: source(i, key), traces: [],
|
|
45636
|
+
});
|
|
45637
|
+
}
|
|
45638
|
+
// A story whose parent is no epic here (a story's sub-item, an epic not exported) is under none.
|
|
45639
|
+
for (const s of stories) if (s.epic && !epics.has(s.epic)) s.epic = '';
|
|
45640
|
+
return { epics: [...epics.values()], releases: [...releases.values()], stories };
|
|
45641
|
+
}
|
|
45642
|
+
}
|
|
45643
|
+
|
|
45644
|
+
/**
|
|
45645
|
+
* Storyboard — epics and stories from a storyboard or requirements document
|
|
45646
|
+
* written in Markdown (or plain text with Markdown headings), as teams keep
|
|
45647
|
+
* them beside or before a tracker:
|
|
45648
|
+
*
|
|
45649
|
+
* - a heading that says "Epic" (`## Epic: Checkout`) is an epic, one that says
|
|
45650
|
+
* "Story" (`### Story: Pay by card`) a story; otherwise the document's one
|
|
45651
|
+
* title (`#`) is its name, the next level down are epics and the level
|
|
45652
|
+
* below that stories;
|
|
45653
|
+
* - under an epic, a list item that reads "As a … I want …" is a story too;
|
|
45654
|
+
* - under a story, "As a … I want … so that …" is its narrative, the list
|
|
45655
|
+
* under "Acceptance criteria" (or its Given/When/Then lines) its criteria,
|
|
45656
|
+
* and the rest its description — a picture (``) kept there
|
|
45657
|
+
* by its caption.
|
|
45658
|
+
*
|
|
45659
|
+
* `read(text)` gives a RequirementsModel spec (`{name, epics, stories}`); ids
|
|
45660
|
+
* are made from names (prefix: `prefix`, so two storyboards read into one
|
|
45661
|
+
* model stay apart).
|
|
45662
|
+
*/
|
|
45663
|
+
class Storyboard {
|
|
45664
|
+
/** @param {{prefix?: string}} [options] */
|
|
45665
|
+
constructor({ prefix = '' } = {}) {
|
|
45666
|
+
this.prefix = prefix;
|
|
45667
|
+
}
|
|
45668
|
+
|
|
45669
|
+
read(text) {
|
|
45670
|
+
const lines = String(text ?? '').replace(/\r\n?/g, '\n').split('\n');
|
|
45671
|
+
const headings = lines.map((line, at) => ({ at, m: /^(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line) })).filter(h => h.m)
|
|
45672
|
+
.map(({ at, m }) => ({ at, level: m[1].length, text: m[2].trim() }));
|
|
45673
|
+
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);
|
|
45674
|
+
const top = Math.min(...headings.map(h => h.level));
|
|
45675
|
+
const tops = headings.filter(h => h.level === top);
|
|
45676
|
+
// One top heading, a title (#), names the document; its children are the epics.
|
|
45677
|
+
const named = top === 1 && tops.length === 1 && !kindOf(tops[0]) && headings.length > 1;
|
|
45678
|
+
const epicLevel = named ? top + 1 : top;
|
|
45679
|
+
const strip = (t) => t.replace(/^(epic|(user\s+)?story)\s*[:\-–—]?\s*/i, '').trim();
|
|
45680
|
+
|
|
45681
|
+
const epics = [], stories = [];
|
|
45682
|
+
const ids = new Set();
|
|
45683
|
+
const idFor = (name, kind) => {
|
|
45684
|
+
const base = `${this.prefix}${String(name).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || kind}`;
|
|
45685
|
+
let id = base;
|
|
45686
|
+
for (let n = 2; ids.has(id); n++) id = `${base}-${n}`;
|
|
45687
|
+
ids.add(id);
|
|
45688
|
+
return id;
|
|
45689
|
+
};
|
|
45690
|
+
const bodyOf = (h, i) => lines.slice(h.at + 1, headings[i + 1]?.at ?? lines.length).join('\n');
|
|
45691
|
+
let epic = null;
|
|
45692
|
+
headings.forEach((h, i) => {
|
|
45693
|
+
if (named && h === tops[0]) return;
|
|
45694
|
+
const kind = kindOf(h) ?? (h.level === epicLevel ? 'epic' : h.level === epicLevel + 1 ? 'story' : null);
|
|
45695
|
+
const body = bodyOf(h, i);
|
|
45696
|
+
if (kind === 'epic') {
|
|
45697
|
+
epic = { id: idFor(strip(h.text), 'epic'), name: strip(h.text), description: '' };
|
|
45698
|
+
epics.push(epic);
|
|
45699
|
+
const listed = this._listedStories(body);
|
|
45700
|
+
epic.description = listed.rest;
|
|
45701
|
+
for (const s of listed.stories) stories.push({ ...s, id: idFor(s.name, 'story'), epic: epic.id });
|
|
45702
|
+
} else if (kind === 'story') {
|
|
45703
|
+
stories.push({ ...this._story(strip(h.text), body), id: idFor(strip(h.text), 'story'), epic: epic?.id ?? '' });
|
|
45704
|
+
}
|
|
45705
|
+
});
|
|
45706
|
+
if (!headings.length || (!epics.length && !stories.length)) {
|
|
45707
|
+
for (const s of this._listedStories(lines.join('\n')).stories) stories.push({ ...s, id: idFor(s.name, 'story') });
|
|
45708
|
+
}
|
|
45709
|
+
return { name: named ? tops[0].text : '', epics, stories };
|
|
45710
|
+
}
|
|
45711
|
+
|
|
45712
|
+
/** A story from its heading and what is under it. @private */
|
|
45713
|
+
_story(name, body) {
|
|
45714
|
+
const pictures = [...body.matchAll(/!\[([^\]]*)\]\([^)]*\)/g)].map(m => m[1]).filter(Boolean);
|
|
45715
|
+
const text = body.replace(/!\[([^\]]*)\]\([^)]*\)/g, '').trim();
|
|
45716
|
+
const said = narrativeFrom(text);
|
|
45717
|
+
const { as, want, soThat } = said.want ? said : narrativeFrom(name);
|
|
45718
|
+
const { criteria, rest } = criteriaFrom(text);
|
|
45719
|
+
const description = [withoutNarrative(rest), ...pictures.map(c => `Picture: ${c}`)].filter(Boolean).join('\n');
|
|
45720
|
+
return { name, as, want, soThat, description, criteria };
|
|
45721
|
+
}
|
|
45722
|
+
|
|
45723
|
+
/** "As a … I want …" list items: stories; the rest of the text, kept. @private */
|
|
45724
|
+
_listedStories(body) {
|
|
45725
|
+
const stories = [];
|
|
45726
|
+
const rest = [];
|
|
45727
|
+
for (const line of body.split('\n')) {
|
|
45728
|
+
const item = /^\s*(?:[-*•+]|\d+[.)])\s+(.*)$/.exec(line);
|
|
45729
|
+
const said = item && narrativeFrom(item[1]);
|
|
45730
|
+
if (said?.want) stories.push({ name: said.want.replace(/^to\s+/i, '').replace(/^./, c => c.toUpperCase()), ...said, description: '', criteria: [] });
|
|
45731
|
+
else rest.push(line);
|
|
45732
|
+
}
|
|
45733
|
+
return { stories, rest: rest.join('\n').trim() };
|
|
45734
|
+
}
|
|
45735
|
+
}
|
|
45736
|
+
|
|
45737
|
+
/**
|
|
45738
|
+
* The files a requirements model is read from that another tool wrote — they
|
|
45739
|
+
* import only: a tracker's export (TrackerExport: Jira, Azure DevOps,
|
|
45740
|
+
* GitLab, CSV or JSON) and a storyboard (Storyboard: Markdown). Each reads
|
|
45741
|
+
* the file as a new model; `spec(text)` is what it holds, for a part that
|
|
45742
|
+
* brings it into the model open (RequirementsModel#bringCommand).
|
|
45743
|
+
*/
|
|
45744
|
+
class RequirementsImportFormat extends ModelFileFormat {
|
|
45745
|
+
get exports() { return false; }
|
|
45746
|
+
|
|
45747
|
+
/** What the file holds, as a RequirementsModel spec. @abstract */
|
|
45748
|
+
spec(text) { throw new Error(`${this.constructor.name} must implement spec()`); }
|
|
45749
|
+
|
|
45750
|
+
import(text) {
|
|
45751
|
+
return new RequirementsModel(this.spec(text));
|
|
45752
|
+
}
|
|
45753
|
+
}
|
|
45754
|
+
|
|
45755
|
+
class TrackerExportFileFormat extends RequirementsImportFormat {
|
|
45756
|
+
get label() { return 'Tracker export (Jira, Azure DevOps, GitLab)'; }
|
|
45757
|
+
get accept() { return '.csv,.json'; }
|
|
45758
|
+
get featureName() { return 'TrackerExport'; }
|
|
45759
|
+
|
|
45760
|
+
/** A CSV or JSON export one of the trackers wrote. */
|
|
45761
|
+
recognizes(text) {
|
|
45762
|
+
return !!TrackerExport.detect(text);
|
|
45763
|
+
}
|
|
45764
|
+
|
|
45765
|
+
spec(text) {
|
|
45766
|
+
const { tracker, ...spec } = new TrackerExport().read(text);
|
|
45767
|
+
return { name: `${TrackerExport.label(tracker)} export`, ...spec };
|
|
45768
|
+
}
|
|
45769
|
+
}
|
|
45770
|
+
|
|
45771
|
+
class StoryboardFileFormat extends RequirementsImportFormat {
|
|
45772
|
+
get label() { return 'Storyboard (Markdown)'; }
|
|
45773
|
+
get accept() { return '.md,.markdown,.txt'; }
|
|
45774
|
+
get featureName() { return 'Storyboard'; }
|
|
45775
|
+
|
|
45776
|
+
spec(text) {
|
|
45777
|
+
return new Storyboard().read(text);
|
|
45778
|
+
}
|
|
45779
|
+
}
|
|
45780
|
+
|
|
45781
|
+
/** Text as base64, whatever its characters (btoa takes only Latin-1). */
|
|
45782
|
+
function base64(text) {
|
|
45783
|
+
const bytes = new TextEncoder().encode(String(text));
|
|
45784
|
+
let binary = '';
|
|
45785
|
+
for (const b of bytes) binary += String.fromCharCode(b);
|
|
45786
|
+
return btoa(binary);
|
|
45787
|
+
}
|
|
45788
|
+
|
|
45789
|
+
/**
|
|
45790
|
+
* TrackerApi — a tracker read through its API rather than from an export: the
|
|
45791
|
+
* issues a connection names, page by page, as the JSON `TrackerExport` reads
|
|
45792
|
+
* (so an API's answer and a file saved from it make the same model). A
|
|
45793
|
+
* subclass per tracker (`JiraApi`, `AzureDevOpsApi`, `GitLabApi`) says where
|
|
45794
|
+
* its issues are, how it pages and how it is signed in to; what a connection
|
|
45795
|
+
* asks for is `fields`, which a host builds its form from.
|
|
45796
|
+
*
|
|
45797
|
+
* `fetch` is given (a server's, or a test's): a tracker's token belongs where
|
|
45798
|
+
* the request is made, which is seldom a browser. Nothing here keeps or logs
|
|
45799
|
+
* the token; an error says what the tracker answered, not what was sent.
|
|
45800
|
+
*/
|
|
45801
|
+
class TrackerApi {
|
|
45802
|
+
/** The tracker's key in TRACKERS ('jira'). */
|
|
45803
|
+
static tracker = null;
|
|
45804
|
+
|
|
45805
|
+
/** Most issues read from one connection: a connection naming a whole site is stopped there (`truncated`). */
|
|
45806
|
+
static MAX_ISSUES = 5000;
|
|
45807
|
+
|
|
45808
|
+
/**
|
|
45809
|
+
* What a connection asks for, in order: `{key, label, type?, choices?,
|
|
45810
|
+
* required?, secret?, placeholder?, hint?}` — `secret` written, never
|
|
45811
|
+
* shown back. The tracker's own between its address and its token.
|
|
45812
|
+
*/
|
|
45813
|
+
static get fields() {
|
|
45814
|
+
return [
|
|
45815
|
+
{ key: 'name', label: 'Name', required: true, placeholder: 'What people pick it by, e.g. "Shop backlog"' },
|
|
45816
|
+
{ key: 'baseUrl', label: 'Address', required: true, placeholder: this.baseUrlPlaceholder },
|
|
45817
|
+
...this.ownFields,
|
|
45818
|
+
{ key: 'token', label: 'Token', type: 'password', required: true, secret: true, hint: this.tokenHint },
|
|
45819
|
+
];
|
|
45820
|
+
}
|
|
45821
|
+
|
|
45822
|
+
/** @protected */
|
|
45823
|
+
static get ownFields() { return []; }
|
|
45824
|
+
|
|
45825
|
+
/** @protected */
|
|
45826
|
+
static baseUrlPlaceholder = 'https://…';
|
|
45827
|
+
|
|
45828
|
+
/** @protected */
|
|
45829
|
+
static tokenHint = 'An access token that may read the issues';
|
|
45830
|
+
|
|
45831
|
+
/**
|
|
45832
|
+
* @param {Object} connection - Its `fields`' values (and `columns`, a TrackerExport's own columns)
|
|
45833
|
+
* @param {Object} [options]
|
|
45834
|
+
* @param {Function} [options.fetch] - The fetch to read with (default: the global one)
|
|
45835
|
+
* @param {number} [options.maxIssues] - Most issues read (default MAX_ISSUES)
|
|
45836
|
+
*/
|
|
45837
|
+
constructor(connection, { fetch = globalThis.fetch, maxIssues = new.target.MAX_ISSUES } = {}) {
|
|
45838
|
+
this.connection = connection ?? {};
|
|
45839
|
+
this.fetch = fetch;
|
|
45840
|
+
this.maxIssues = maxIssues;
|
|
45841
|
+
/** Whether the last read stopped at maxIssues. */
|
|
45842
|
+
this.truncated = false;
|
|
45843
|
+
}
|
|
45844
|
+
|
|
45845
|
+
get tracker() { return this.constructor.tracker; }
|
|
45846
|
+
|
|
45847
|
+
/** What the tracker is called ('Jira'). */
|
|
45848
|
+
get label() { return TrackerExport.label(this.tracker); }
|
|
45849
|
+
|
|
45850
|
+
/** What is wrong with the connection, before anything is asked of the tracker: messages, none when it will do. */
|
|
45851
|
+
problems() {
|
|
45852
|
+
const c = this.connection;
|
|
45853
|
+
const out = this.constructor.fields.filter(f => f.required && !String(c[f.key] ?? '').trim()).map(f => `${f.label} is needed.`);
|
|
45854
|
+
if (c.baseUrl && !/^https?:\/\/[^\s/]+/i.test(String(c.baseUrl).trim())) out.push(`Address must start http:// or https:// ("${c.baseUrl}").`);
|
|
45855
|
+
return out;
|
|
45856
|
+
}
|
|
45857
|
+
|
|
45858
|
+
/** The issues, as the tracker's API gives them (the JSON TrackerExport reads). */
|
|
45859
|
+
async read() {
|
|
45860
|
+
const problems = this.problems();
|
|
45861
|
+
if (problems.length) throw new Error(`${this.label} connection "${this.connection.name ?? ''}": ${problems.join(' ')}`);
|
|
45862
|
+
this.truncated = false;
|
|
45863
|
+
return this._read();
|
|
45864
|
+
}
|
|
45865
|
+
|
|
45866
|
+
/** The issues as a requirements model's spec, named as the connection is. */
|
|
45867
|
+
async model() {
|
|
45868
|
+
const json = await this.read();
|
|
45869
|
+
const { tracker, ...spec } = new TrackerExport({ tracker: this.tracker, columns: this.connection.columns ?? {} }).read(json);
|
|
45870
|
+
return { name: this.connection.name || `${this.label} issues`, ...spec };
|
|
45871
|
+
}
|
|
45872
|
+
|
|
45873
|
+
/** @protected @abstract */
|
|
45874
|
+
async _read() {
|
|
45875
|
+
throw new Error(`${this.constructor.name} does not say how to read ${this.label}`);
|
|
45876
|
+
}
|
|
45877
|
+
|
|
45878
|
+
/** The headers that sign in. @protected @abstract */
|
|
45879
|
+
headers() {
|
|
45880
|
+
return {};
|
|
45881
|
+
}
|
|
45882
|
+
|
|
45883
|
+
/** A path on the tracker's address. @protected */
|
|
45884
|
+
url(path) {
|
|
45885
|
+
return `${String(this.connection.baseUrl).trim().replace(/\/+$/, '')}${path}`;
|
|
45886
|
+
}
|
|
45887
|
+
|
|
45888
|
+
/**
|
|
45889
|
+
* A request to the tracker, its answer's JSON and headers; an answer
|
|
45890
|
+
* other than OK an error saying what the tracker said. @protected
|
|
45891
|
+
*/
|
|
45892
|
+
async request(url, { method = 'GET', body } = {}) {
|
|
45893
|
+
if (typeof this.fetch !== 'function') throw new Error('No fetch to read the tracker with');
|
|
45894
|
+
const res = await this.fetch(url, {
|
|
45895
|
+
method,
|
|
45896
|
+
headers: { Accept: 'application/json', ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}), ...this.headers() },
|
|
45897
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
45898
|
+
});
|
|
45899
|
+
if (!res.ok) {
|
|
45900
|
+
const text = await res.text().catch(() => '');
|
|
45901
|
+
throw new Error(`${this.label} answered ${res.status}${res.statusText ? ` ${res.statusText}` : ''}${text ? `: ${text.slice(0, 300)}` : ''}`);
|
|
45902
|
+
}
|
|
45903
|
+
return { json: await res.json(), headers: res.headers };
|
|
45904
|
+
}
|
|
45905
|
+
|
|
45906
|
+
/** Issues kept up to maxIssues; true once it is reached. @protected */
|
|
45907
|
+
keep(into, more) {
|
|
45908
|
+
for (const issue of more) {
|
|
45909
|
+
if (into.length >= this.maxIssues) { this.truncated = true; return true; }
|
|
45910
|
+
into.push(issue);
|
|
45911
|
+
}
|
|
45912
|
+
return into.length >= this.maxIssues;
|
|
45913
|
+
}
|
|
45914
|
+
|
|
45915
|
+
/** @protected */
|
|
45916
|
+
static base64 = base64;
|
|
45917
|
+
|
|
45918
|
+
/** @protected — the tracker's issue types not read (TRACKERS: its sub-tasks). */
|
|
45919
|
+
get skipTypes() { return TRACKERS[this.tracker]?.skipTypes ?? []; }
|
|
45920
|
+
}
|
|
45921
|
+
|
|
45922
|
+
/**
|
|
45923
|
+
* Jira through its REST API: the issues a JQL query finds (a project's, by
|
|
45924
|
+
* default), every field (custom ones too: story points, acceptance criteria).
|
|
45925
|
+
* Jira Cloud signs in with an email and an API token and pages by token
|
|
45926
|
+
* (`/rest/api/3/search/jql`); Jira Server and Data Center with a personal
|
|
45927
|
+
* access token (no email), paging by offset (`/rest/api/2/search`).
|
|
45928
|
+
*/
|
|
45929
|
+
class JiraApi extends TrackerApi {
|
|
45930
|
+
static tracker = 'jira';
|
|
45931
|
+
|
|
45932
|
+
static PAGE = 100;
|
|
45933
|
+
|
|
45934
|
+
static baseUrlPlaceholder = 'https://your-site.atlassian.net';
|
|
45935
|
+
|
|
45936
|
+
static tokenHint = 'Jira Cloud: an API token (with the email above). Server / Data Center: a personal access token (no email).';
|
|
45937
|
+
|
|
45938
|
+
static get ownFields() {
|
|
45939
|
+
return [
|
|
45940
|
+
{ key: 'edition', label: 'Jira', type: 'select', choices: [['cloud', 'Cloud'], ['server', 'Server / Data Center']], value: 'cloud' },
|
|
45941
|
+
{ key: 'user', label: 'Email', placeholder: 'The account the API token is for (Cloud)' },
|
|
45942
|
+
{ key: 'project', label: 'Project key', placeholder: 'SHOP — or leave it and give a JQL query' },
|
|
45943
|
+
{ key: 'query', label: 'JQL', placeholder: 'project = SHOP AND issuetype != Sub-task (instead of the project)' },
|
|
45944
|
+
];
|
|
45945
|
+
}
|
|
45946
|
+
|
|
45947
|
+
problems() {
|
|
45948
|
+
const out = super.problems();
|
|
45949
|
+
if (!String(this.connection.project ?? '').trim() && !String(this.connection.query ?? '').trim()) out.push('A project key or a JQL query is needed.');
|
|
45950
|
+
return out;
|
|
45951
|
+
}
|
|
45952
|
+
|
|
45953
|
+
/** The JQL: the one given, else the project's issues in key order. */
|
|
45954
|
+
get jql() {
|
|
45955
|
+
const { query, project } = this.connection;
|
|
45956
|
+
if (String(query ?? '').trim()) return String(query).trim();
|
|
45957
|
+
return `project = "${String(project).trim().replace(/"/g, '\\"')}" ORDER BY key`;
|
|
45958
|
+
}
|
|
45959
|
+
|
|
45960
|
+
headers() {
|
|
45961
|
+
const { user, token } = this.connection;
|
|
45962
|
+
return { Authorization: String(user ?? '').trim() ? `Basic ${TrackerApi.base64(`${String(user).trim()}:${token}`)}` : `Bearer ${token}` };
|
|
45963
|
+
}
|
|
45964
|
+
|
|
45965
|
+
async _read() {
|
|
45966
|
+
const issues = [];
|
|
45967
|
+
const page = this.constructor.PAGE;
|
|
45968
|
+
const query = (extra) => new URLSearchParams({ jql: this.jql, fields: '*all', maxResults: String(page), ...extra }).toString();
|
|
45969
|
+
if (this.connection.edition === 'server') {
|
|
45970
|
+
for (let startAt = 0; ; startAt += page) {
|
|
45971
|
+
const { json } = await this.request(this.url(`/rest/api/2/search?${query({ startAt: String(startAt) })}`));
|
|
45972
|
+
const got = json.issues ?? [];
|
|
45973
|
+
const more = got.length > 0 && startAt + got.length < (json.total ?? 0);
|
|
45974
|
+
if (this.keep(issues, got)) { this.truncated ||= more; break; }
|
|
45975
|
+
if (!more) break;
|
|
45976
|
+
}
|
|
45977
|
+
} else {
|
|
45978
|
+
for (let token = null; ;) {
|
|
45979
|
+
const { json } = await this.request(this.url(`/rest/api/3/search/jql?${query(token ? { nextPageToken: token } : {})}`));
|
|
45980
|
+
const got = json.issues ?? [];
|
|
45981
|
+
token = json.isLast ? null : json.nextPageToken ?? null;
|
|
45982
|
+
if (this.keep(issues, got)) { this.truncated ||= !!token; break; }
|
|
45983
|
+
if (!token || !got.length) break;
|
|
45984
|
+
}
|
|
45985
|
+
}
|
|
45986
|
+
return { issues };
|
|
45987
|
+
}
|
|
45988
|
+
}
|
|
45989
|
+
|
|
45990
|
+
/**
|
|
45991
|
+
* Azure DevOps (Boards) through its REST API: a WIQL query for a project's
|
|
45992
|
+
* work items (but its tasks — TRACKERS), then the items, 200 at a time. Signs
|
|
45993
|
+
* in with a personal access token. Each item's link is its page in Boards.
|
|
45994
|
+
*/
|
|
45995
|
+
class AzureDevOpsApi extends TrackerApi {
|
|
45996
|
+
static tracker = 'azure';
|
|
45997
|
+
|
|
45998
|
+
/** Most ids one work-items request takes. */
|
|
45999
|
+
static BATCH = 200;
|
|
46000
|
+
|
|
46001
|
+
static API_VERSION = '7.1';
|
|
46002
|
+
|
|
46003
|
+
static baseUrlPlaceholder = 'https://dev.azure.com/your-organisation';
|
|
46004
|
+
|
|
46005
|
+
static tokenHint = 'A personal access token with Work Items (Read)';
|
|
46006
|
+
|
|
46007
|
+
static get ownFields() {
|
|
46008
|
+
return [
|
|
46009
|
+
{ key: 'project', label: 'Project', required: true, placeholder: 'The project, e.g. Shop' },
|
|
46010
|
+
{ key: 'query', label: 'WIQL condition', placeholder: "[System.AreaPath] UNDER 'Shop\\Checkout' (and the project's)" },
|
|
46011
|
+
];
|
|
46012
|
+
}
|
|
46013
|
+
|
|
46014
|
+
headers() {
|
|
46015
|
+
return { Authorization: `Basic ${TrackerApi.base64(`:${this.connection.token}`)}` };
|
|
46016
|
+
}
|
|
46017
|
+
|
|
46018
|
+
/** The WIQL: the project's work items, not its tasks, and the condition given. */
|
|
46019
|
+
get wiql() {
|
|
46020
|
+
const types = this.skipTypes.map(t => `'${t.replace(/'/g, "''")}'`).join(', ');
|
|
46021
|
+
const condition = String(this.connection.query ?? '').trim();
|
|
46022
|
+
return `SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project`
|
|
46023
|
+
+ (types ? ` AND [System.WorkItemType] NOT IN (${types})` : '')
|
|
46024
|
+
+ (condition ? ` AND (${condition})` : '')
|
|
46025
|
+
+ ' ORDER BY [System.Id]';
|
|
46026
|
+
}
|
|
46027
|
+
|
|
46028
|
+
async _read() {
|
|
46029
|
+
const project = encodeURIComponent(String(this.connection.project).trim());
|
|
46030
|
+
const version = `api-version=${this.constructor.API_VERSION}`;
|
|
46031
|
+
const { json } = await this.request(this.url(`/${project}/_apis/wit/wiql?${version}`), { method: 'POST', body: { query: this.wiql } });
|
|
46032
|
+
const ids = (json.workItems ?? []).map(w => w.id);
|
|
46033
|
+
const value = [];
|
|
46034
|
+
for (let i = 0; i < ids.length; i += this.constructor.BATCH) {
|
|
46035
|
+
const batch = ids.slice(i, i + this.constructor.BATCH);
|
|
46036
|
+
const { json: items } = await this.request(this.url(`/${project}/_apis/wit/workitems?ids=${batch.join(',')}&${version}`));
|
|
46037
|
+
const got = (items.value ?? []).map(w => ({ ...w, url: this.url(`/${project}/_workitems/edit/${w.id}`) }));
|
|
46038
|
+
if (this.keep(value, got)) { this.truncated ||= i + batch.length < ids.length; break; }
|
|
46039
|
+
}
|
|
46040
|
+
return { value };
|
|
46041
|
+
}
|
|
46042
|
+
}
|
|
46043
|
+
|
|
46044
|
+
/**
|
|
46045
|
+
* GitLab through its REST API: a project's issues, open and closed, 100 a
|
|
46046
|
+
* page; filtered as its issues API filters (`labels=…&milestone=…`). Signs
|
|
46047
|
+
* in with a personal, project or group access token. An issue's epic
|
|
46048
|
+
* (GitLab Premium) is its epic.
|
|
46049
|
+
*/
|
|
46050
|
+
class GitLabApi extends TrackerApi {
|
|
46051
|
+
static tracker = 'gitlab';
|
|
46052
|
+
|
|
46053
|
+
static PAGE = 100;
|
|
46054
|
+
|
|
46055
|
+
static baseUrlPlaceholder = 'https://gitlab.com';
|
|
46056
|
+
|
|
46057
|
+
static tokenHint = 'An access token with read_api';
|
|
46058
|
+
|
|
46059
|
+
static get ownFields() {
|
|
46060
|
+
return [
|
|
46061
|
+
{ key: 'project', label: 'Project', required: true, placeholder: 'group/project, or its id' },
|
|
46062
|
+
{ key: 'query', label: 'Filter', placeholder: 'labels=requirements&milestone=Release 2 (as the issues API takes)' },
|
|
46063
|
+
];
|
|
46064
|
+
}
|
|
46065
|
+
|
|
46066
|
+
headers() {
|
|
46067
|
+
return { 'PRIVATE-TOKEN': String(this.connection.token) };
|
|
46068
|
+
}
|
|
46069
|
+
|
|
46070
|
+
async _read() {
|
|
46071
|
+
const project = encodeURIComponent(String(this.connection.project).trim());
|
|
46072
|
+
const issues = [];
|
|
46073
|
+
for (let page = 1; page;) {
|
|
46074
|
+
const params = new URLSearchParams(String(this.connection.query ?? '').trim());
|
|
46075
|
+
for (const [k, v] of Object.entries({ state: params.get('state') ?? 'all', per_page: String(this.constructor.PAGE), page: String(page) })) params.set(k, v);
|
|
46076
|
+
const { json, headers } = await this.request(this.url(`/api/v4/projects/${project}/issues?${params}`));
|
|
46077
|
+
const got = Array.isArray(json) ? json : [];
|
|
46078
|
+
const next = Number(headers?.get?.('x-next-page')) || (got.length === this.constructor.PAGE && !headers?.has?.('x-next-page') ? page + 1 : 0);
|
|
46079
|
+
if (this.keep(issues, got)) { this.truncated ||= !!next; break; }
|
|
46080
|
+
page = got.length ? next : 0;
|
|
46081
|
+
}
|
|
46082
|
+
return issues;
|
|
46083
|
+
}
|
|
46084
|
+
}
|
|
46085
|
+
|
|
46086
|
+
/** Each tracker's API, by its key in TRACKERS. */
|
|
46087
|
+
const TRACKER_APIS = Object.freeze({ jira: JiraApi, azure: AzureDevOpsApi, gitlab: GitLabApi });
|
|
46088
|
+
|
|
46089
|
+
/**
|
|
46090
|
+
* The API reader for a connection (`connection.tracker`: 'jira', 'azure',
|
|
46091
|
+
* 'gitlab'); `options` as TrackerApi takes them.
|
|
46092
|
+
*/
|
|
46093
|
+
function trackerApi(connection, options) {
|
|
46094
|
+
const Api = TRACKER_APIS[connection?.tracker];
|
|
46095
|
+
if (!Api) throw new Error(`No API reader for tracker "${connection?.tracker ?? ''}" (${Object.keys(TRACKER_APIS).join(', ')})`);
|
|
46096
|
+
return new Api(connection, options);
|
|
46097
|
+
}
|
|
46098
|
+
|
|
46099
|
+
/** What each tracker's connection asks for: `[{tracker, label, fields}]`, for a host's form. */
|
|
46100
|
+
function trackerConnectionFields() {
|
|
46101
|
+
return Object.entries(TRACKER_APIS).map(([tracker, Api]) => ({ tracker, label: new Api({}).label, fields: Api.fields }));
|
|
46102
|
+
}
|
|
46103
|
+
|
|
44771
46104
|
/**
|
|
44772
46105
|
* propertiesDialog — the "Show/Edit Properties" dialog, as a shipped feature.
|
|
44773
46106
|
*
|
|
@@ -47677,8 +49010,10 @@
|
|
|
47677
49010
|
exports.ArrowMarker = ArrowMarker;
|
|
47678
49011
|
exports.AssociationKind = AssociationKind;
|
|
47679
49012
|
exports.AttributedLevel = AttributedLevel;
|
|
49013
|
+
exports.AzureDevOpsApi = AzureDevOpsApi;
|
|
47680
49014
|
exports.BackgroundCommand = BackgroundCommand;
|
|
47681
49015
|
exports.BackgroundGroup = BackgroundGroup;
|
|
49016
|
+
exports.Benchmark = Benchmark;
|
|
47682
49017
|
exports.BianActionTerm = BianActionTerm;
|
|
47683
49018
|
exports.BianFunctionalPattern = BianFunctionalPattern;
|
|
47684
49019
|
exports.BianLandscapeReader = BianLandscapeReader;
|
|
@@ -47756,6 +49091,7 @@
|
|
|
47756
49091
|
exports.EditHintCommand = EditHintCommand;
|
|
47757
49092
|
exports.EditPropertyCommand = EditPropertyCommand;
|
|
47758
49093
|
exports.EditViewItemCommand = EditViewItemCommand;
|
|
49094
|
+
exports.EditableModel = EditableModel;
|
|
47759
49095
|
exports.EditorShell = EditorShell;
|
|
47760
49096
|
exports.ElementFilterRule = ElementFilterRule;
|
|
47761
49097
|
exports.ElementInstance = ElementInstance;
|
|
@@ -47769,6 +49105,7 @@
|
|
|
47769
49105
|
exports.FormDialog = FormDialog;
|
|
47770
49106
|
exports.GLYPHS = GLYPHS;
|
|
47771
49107
|
exports.GeneralizationKind = GeneralizationKind;
|
|
49108
|
+
exports.GitLabApi = GitLabApi;
|
|
47772
49109
|
exports.Glyph = Glyph;
|
|
47773
49110
|
exports.GlyphTemplate = GlyphTemplate;
|
|
47774
49111
|
exports.GraphDataWindow = GraphDataWindow;
|
|
@@ -47783,6 +49120,7 @@
|
|
|
47783
49120
|
exports.ITIconNotation = ITIconNotation;
|
|
47784
49121
|
exports.ImageSelector = ImageSelector;
|
|
47785
49122
|
exports.ItemGroup = ItemGroup;
|
|
49123
|
+
exports.JiraApi = JiraApi;
|
|
47786
49124
|
exports.KIND_PREFERENCE = KIND_PREFERENCE;
|
|
47787
49125
|
exports.LAYER_CHOICES = LAYER_CHOICES;
|
|
47788
49126
|
exports.LINE_ONLY_PROPERTIES = LINE_ONLY_PROPERTIES;
|
|
@@ -47800,6 +49138,7 @@
|
|
|
47800
49138
|
exports.Mat4 = Mat4;
|
|
47801
49139
|
exports.MeasuredCategory = MeasuredCategory;
|
|
47802
49140
|
exports.MenuBar = MenuBar;
|
|
49141
|
+
exports.ModelEditCommand = ModelEditCommand;
|
|
47803
49142
|
exports.ModelFileFormat = ModelFileFormat;
|
|
47804
49143
|
exports.ModelScorecard = ModelScorecard;
|
|
47805
49144
|
exports.ModelTree = ModelTree;
|
|
@@ -47834,6 +49173,9 @@
|
|
|
47834
49173
|
exports.RemoveLineCommand = RemoveLineCommand;
|
|
47835
49174
|
exports.RenderEngine = RenderEngine;
|
|
47836
49175
|
exports.ReorderCommand = ReorderCommand;
|
|
49176
|
+
exports.RequirementsChecker = RequirementsChecker;
|
|
49177
|
+
exports.RequirementsFileFormat = RequirementsFileFormat;
|
|
49178
|
+
exports.RequirementsModel = RequirementsModel;
|
|
47837
49179
|
exports.ResizeCommand = ResizeCommand;
|
|
47838
49180
|
exports.RotateCommand = RotateCommand;
|
|
47839
49181
|
exports.RoundedRectangleTemplate = RoundedRectangleTemplate;
|
|
@@ -47850,8 +49192,12 @@
|
|
|
47850
49192
|
exports.SqlDdlFileFormat = SqlDdlFileFormat;
|
|
47851
49193
|
exports.StadiumTemplate = StadiumTemplate;
|
|
47852
49194
|
exports.StarTemplate = StarTemplate;
|
|
49195
|
+
exports.Storyboard = Storyboard;
|
|
49196
|
+
exports.StoryboardFileFormat = StoryboardFileFormat;
|
|
47853
49197
|
exports.StructuredType = StructuredType;
|
|
47854
49198
|
exports.SwimlaneTemplate = SwimlaneTemplate;
|
|
49199
|
+
exports.TRACKERS = TRACKERS;
|
|
49200
|
+
exports.TRACKER_APIS = TRACKER_APIS;
|
|
47855
49201
|
exports.TREE_LAYERS = TREE_LAYERS;
|
|
47856
49202
|
exports.TREE_MODE_CATALOG = TREE_MODE_CATALOG;
|
|
47857
49203
|
exports.Template = Template;
|
|
@@ -47865,6 +49211,9 @@
|
|
|
47865
49211
|
exports.ToolbarSelect = ToolbarSelect;
|
|
47866
49212
|
exports.ToolbarSeparator = ToolbarSeparator;
|
|
47867
49213
|
exports.ToolbarText = ToolbarText;
|
|
49214
|
+
exports.TrackerApi = TrackerApi;
|
|
49215
|
+
exports.TrackerExport = TrackerExport;
|
|
49216
|
+
exports.TrackerExportFileFormat = TrackerExportFileFormat;
|
|
47868
49217
|
exports.TrapezoidTemplate = TrapezoidTemplate;
|
|
47869
49218
|
exports.TreeModeRegistry = TreeModeRegistry;
|
|
47870
49219
|
exports.TriangleTemplate = TriangleTemplate;
|
|
@@ -47947,6 +49296,8 @@
|
|
|
47947
49296
|
exports.toArchimateModel = toArchimateModel;
|
|
47948
49297
|
exports.toExchangeXML = toExchangeXML;
|
|
47949
49298
|
exports.toPngBlob = toPngBlob;
|
|
49299
|
+
exports.trackerApi = trackerApi;
|
|
49300
|
+
exports.trackerConnectionFields = trackerConnectionFields;
|
|
47950
49301
|
exports.treeLayerOf = treeLayerOf;
|
|
47951
49302
|
exports.treeModeRegistry = treeModeRegistry;
|
|
47952
49303
|
exports.viewItemChanges = viewItemChanges;
|