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