storymapper 0.1.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/ARCHITECTURE.md +334 -0
- package/LICENSE +201 -0
- package/NOTICE +6 -0
- package/README.md +239 -0
- package/frontend/css/storymap.css +4637 -0
- package/frontend/js/adapters.js +423 -0
- package/frontend/js/card-animate.js +384 -0
- package/frontend/js/core/graph.js +825 -0
- package/frontend/js/core.js +3908 -0
- package/frontend/js/dialog-ticket-import.js +506 -0
- package/frontend/js/dnd.js +322 -0
- package/frontend/js/filter.js +215 -0
- package/frontend/js/main.js +2499 -0
- package/frontend/js/project-io.js +109 -0
- package/frontend/js/query-autocomplete.js +196 -0
- package/frontend/js/query-engine.js +339 -0
- package/frontend/js/query.js +280 -0
- package/frontend/js/renderer-card.js +639 -0
- package/frontend/js/renderer-dependencies.js +974 -0
- package/frontend/js/renderer-kanban.js +505 -0
- package/frontend/js/renderer-storymap.js +2530 -0
- package/frontend/js/renderer-ticket-editor.js +455 -0
- package/frontend/js/renderer-ticket-modal.js +758 -0
- package/frontend/js/save-pipeline.js +170 -0
- package/frontend/js/smartbar-autocomplete.js +162 -0
- package/frontend/js/store.js +197 -0
- package/frontend/js/ticket-editor-boot.js +24 -0
- package/frontend/js/ticket-form.js +2095 -0
- package/frontend/js/ticket-import.js +477 -0
- package/frontend/js/ui-shell.js +441 -0
- package/frontend/js/view-process-steps.js +233 -0
- package/frontend/js/view-requirements.js +361 -0
- package/frontend/js/view-settings.js +1864 -0
- package/frontend/js/view-table.js +659 -0
- package/frontend/js/wheel-pan.js +65 -0
- package/frontend/storymap.html +87 -0
- package/frontend/ticket-editor.html +29 -0
- package/package.json +76 -0
- package/server/bus.js +16 -0
- package/server/core/graph.js +10 -0
- package/server/core.js +10 -0
- package/server/identity.js +134 -0
- package/server/index.js +283 -0
- package/server/ingest.js +212 -0
- package/server/mcp.js +2510 -0
- package/server/server.js +1599 -0
- package/server/slice.js +103 -0
- package/server/storage.js +571 -0
- package/server/validation.js +225 -0
- package/shared/core/graph.js +825 -0
- package/shared/core.js +3908 -0
- package/shared/project-io.js +109 -0
- package/shared/query-autocomplete.js +196 -0
- package/shared/query-engine.js +339 -0
- package/shared/query.js +280 -0
- package/shared/ticket-import.js +477 -0
- package/skill/SKILL.md +458 -0
- package/skill/reference/anatomy.md +196 -0
- package/skill/reference/spec-evolution.md +52 -0
- package/skill/reference/tools.md +156 -0
- package/skill/reference/workflows.md +203 -0
package/shared/core.js
ADDED
|
@@ -0,0 +1,3908 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* storymap — core data model (single source of truth)
|
|
3
|
+
*
|
|
4
|
+
* UMD single source of truth (shared/). Consumed two ways from ONE file:
|
|
5
|
+
* - Node: require() — server/core.js is a 1-line shim, tests require directly.
|
|
6
|
+
* - Browser: <script src> attaches to window.STORYMAP.core.
|
|
7
|
+
* Pure functions only — no I/O, no DOM. Edit ONLY this file; the server shim
|
|
8
|
+
* and the frontend symlink both resolve here, so drift is impossible.
|
|
9
|
+
*/
|
|
10
|
+
(function (root, factory) {
|
|
11
|
+
if (typeof module === "object" && module.exports) module.exports = factory();
|
|
12
|
+
else (root.STORYMAP = root.STORYMAP || {}).core = factory();
|
|
13
|
+
}(typeof self !== "undefined" ? self : this, function () {
|
|
14
|
+
"use strict";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* storymap — core data model.
|
|
18
|
+
*
|
|
19
|
+
* Pure functions only (no I/O). Single source of truth for the snapshot
|
|
20
|
+
* shape and every mutation. Mirrored 1:1 into `frontend/js/core.js` via
|
|
21
|
+
* UMD wrapper; `tests/test-mirror.js` enforces parity.
|
|
22
|
+
*
|
|
23
|
+
* Snapshot shape:
|
|
24
|
+
* {
|
|
25
|
+
* version: 1,
|
|
26
|
+
* project: { id, name, ..., definitions, ticketPrefix, ticketCounter, ... },
|
|
27
|
+
* tickets: [ { id, projectId, type, ticketKey, ..., definitionOfReady, definitionOfDone, ... } ],
|
|
28
|
+
* releases: [ { id, projectId, name, status, sortOrder, ... } ],
|
|
29
|
+
* processSteps: [ { id, projectId, name, epicId, sortOrder, ... } ]
|
|
30
|
+
* }
|
|
31
|
+
*
|
|
32
|
+
* `ops` are (snapshot, args, actor) → newSnapshot. They never mutate the
|
|
33
|
+
* input; they deep-clone first. Validation is OUT of scope here — see
|
|
34
|
+
* `server/validation.js`. Status-transition checks (DoR/DoD enforcement)
|
|
35
|
+
* live in validation.js too.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
const SCHEMA_VERSION = 1;
|
|
39
|
+
|
|
40
|
+
// Virtual backlog identifiers used by the renderer to group tickets that
|
|
41
|
+
// have no release or no epic assignment yet. Never written to the DB —
|
|
42
|
+
// `position.releaseId === null` / `position.epicId === null` is the
|
|
43
|
+
// canonical "in backlog" state. The IDs exist so views can address the
|
|
44
|
+
// backlog as if it were a regular row.
|
|
45
|
+
const EPIC_BACKLOG_ID = "__epic_backlog__";
|
|
46
|
+
const STORY_BACKLOG_ID = "__story_backlog__";
|
|
47
|
+
|
|
48
|
+
const DEFAULT_TICKET_TYPES = [
|
|
49
|
+
"epic",
|
|
50
|
+
"user-story",
|
|
51
|
+
"technical-task-backend",
|
|
52
|
+
"technical-task-ui",
|
|
53
|
+
"bug",
|
|
54
|
+
// SM-53 / SM-56 — first-class test types.
|
|
55
|
+
// test-definition holds the reusable test script (prerequisites + steps
|
|
56
|
+
// with expectedResult). test-execution is a single run that references
|
|
57
|
+
// a definition + records actualResult/status per step + an outcome.
|
|
58
|
+
"test-definition",
|
|
59
|
+
"test-execution",
|
|
60
|
+
// SM-196 R-1 — spec-layer type. A `requirement` is a SpecObject: one
|
|
61
|
+
// sliced PRD section, an atomic statement. It is NOT a work item — it
|
|
62
|
+
// never appears on the kanban board or as a story-card in a story-map
|
|
63
|
+
// cell, carries no DoR/DoD, and serves only as a link target for
|
|
64
|
+
// `realises` (=DOORS satisfies) / `tests` (=validates).
|
|
65
|
+
"requirement",
|
|
66
|
+
// SM-196 R-2 — the spec-module container. One per imported document
|
|
67
|
+
// (DOORS "module" / ReqIF Specification). Holds its requirements via
|
|
68
|
+
// `contains` links, ordered by sectionPath; carries the source
|
|
69
|
+
// attachment id. Board-excluded like requirements.
|
|
70
|
+
"spec-module"
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
// SM-196 — spec-layer types (SpecObjects + their module container). Distinct
|
|
74
|
+
// from work items: they live in a spec module / the Requirements view, never
|
|
75
|
+
// on a board.
|
|
76
|
+
const SPEC_TYPES = ["requirement", "spec-module"];
|
|
77
|
+
function isSpecType(type) { return SPEC_TYPES.indexOf(type) >= 0; }
|
|
78
|
+
// A ticket that appears as a movable work-card on the kanban board:
|
|
79
|
+
// excludes epics (containers shown specially) AND spec types (requirements).
|
|
80
|
+
function isBoardWorkItem(type) { return type !== "epic" && !isSpecType(type); }
|
|
81
|
+
|
|
82
|
+
const DEFAULT_STATUSES = ["backlog", "ready", "in-progress", "review", "done"];
|
|
83
|
+
|
|
84
|
+
const DEFAULT_RELEASE_STATUSES = ["planning", "active", "completed", "cancelled"];
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Storymapper-bundled DoR/DoD-Vorgaben. `normalizeProject` seedet diese
|
|
88
|
+
* Items, falls ein neues Projekt OHNE eigene Definitions angelegt wird —
|
|
89
|
+
* damit hat jedes neue Projekt direkt eine arbeitsfähige Vorlage. User
|
|
90
|
+
* kann sie pro Projekt überschreiben (REST/MCP via set_definitions, oder
|
|
91
|
+
* direkt via PUT /projects/:pid).
|
|
92
|
+
*/
|
|
93
|
+
const STORYMAPPER_DEFAULT_DEFINITIONS = {
|
|
94
|
+
ready: {
|
|
95
|
+
global: [
|
|
96
|
+
{ id: "dor-acceptance", label: "Acceptance criteria are defined", required: true },
|
|
97
|
+
{ id: "dor-clarity", label: "Scope is clear to the team", required: true }
|
|
98
|
+
],
|
|
99
|
+
byType: {}
|
|
100
|
+
},
|
|
101
|
+
done: {
|
|
102
|
+
global: [
|
|
103
|
+
{ id: "dod-tests", label: "All acceptance criteria are met", required: true },
|
|
104
|
+
{ id: "dod-review", label: "Code reviewed by a peer", required: true }
|
|
105
|
+
],
|
|
106
|
+
byType: {}
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Storymapper-bundled Workflow-Vorgabe. `normalizeProject` seedet diese,
|
|
112
|
+
* falls ein Projekt kein eigenes `workflow`-Feld hat. Bestandsdaten ohne
|
|
113
|
+
* Workflow erben damit das gleiche Verhalten, das vor E13.C hardgekodet
|
|
114
|
+
* war (Gates bei → ready und → done).
|
|
115
|
+
*
|
|
116
|
+
* Shape:
|
|
117
|
+
* statuses: geordnete Liste der erlaubten Status — Reihenfolge
|
|
118
|
+
* bestimmt die "Vorwärts"-Richtung für Sprung-Übergänge.
|
|
119
|
+
* transitions: pro Target-Status optional `requireGate: "DoR"|"DoD"|null`.
|
|
120
|
+
* Bei Vorwärts-Sprung werden ALLE Gates der überquerten
|
|
121
|
+
* Status geprüft (z.B. backlog→done greift DoR UND DoD).
|
|
122
|
+
* byType: Per-Type-Overrides (analog definitions.byType).
|
|
123
|
+
* Pro Type kann `transitions` ganz oder teilweise überschrieben
|
|
124
|
+
* werden; fehlende Schlüssel fallen auf das globale Workflow
|
|
125
|
+
* zurück.
|
|
126
|
+
*/
|
|
127
|
+
// Status-Kategorien (Jira-Modell): treiben Lane-Farben + Board-Column-
|
|
128
|
+
// Mapping-Defaults. Erweiterbar bewusst NICHT gehalten — diese vier deckt
|
|
129
|
+
// den großen Teil realistischer Workflows ab, weitere Sub-Kategorien
|
|
130
|
+
// können sinngemäß rein als Display-Variante kommen.
|
|
131
|
+
// SM-242: `cancelled` is the fifth, TERMINAL category alongside done — a
|
|
132
|
+
// deliberate non-implementation (scope reduction), distinct from done.
|
|
133
|
+
const STATUS_CATEGORIES = ["todo", "doing", "blocked", "done", "cancelled"];
|
|
134
|
+
|
|
135
|
+
// Heuristik beim Migrieren alter String-Status-Listen: gleicht den Status-
|
|
136
|
+
// Identifier mit bekannten Mustern ab und liefert eine sinnvolle Kategorie.
|
|
137
|
+
// Unbekannte IDs fallen auf "doing" — der häufigste Fall in der Praxis.
|
|
138
|
+
function defaultCategoryForStatusId(id) {
|
|
139
|
+
if (typeof id !== "string") return "doing";
|
|
140
|
+
const s = id.toLowerCase();
|
|
141
|
+
if (s === "backlog" || s === "todo" || s === "to-do" || s === "open"
|
|
142
|
+
|| s === "new" || s === "ready") return "todo";
|
|
143
|
+
if (s === "blocked" || s === "on-hold" || s === "hold" || s === "waiting"
|
|
144
|
+
|| s === "deferred" || s === "paused") return "blocked";
|
|
145
|
+
// SM-242: cancelled-ish ids map to the cancelled category, NOT done.
|
|
146
|
+
if (s === "cancelled" || s === "canceled" || s === "wontdo" || s === "won't-do"
|
|
147
|
+
|| s === "wont-do" || s === "rejected" || s === "abandoned") return "cancelled";
|
|
148
|
+
if (s === "done" || s === "closed" || s === "complete" || s === "completed"
|
|
149
|
+
|| s === "shipped") return "done";
|
|
150
|
+
return "doing";
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Sehr leichte Humanisierung: kebab-case / snake_case → Title Case.
|
|
154
|
+
// "in-progress" → "In Progress", "code_review" → "Code Review".
|
|
155
|
+
function humanizeStatusId(id) {
|
|
156
|
+
if (typeof id !== "string" || id.length === 0) return "";
|
|
157
|
+
return id.replace(/[-_]+/g, " ")
|
|
158
|
+
.split(" ")
|
|
159
|
+
.filter(Boolean)
|
|
160
|
+
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
|
|
161
|
+
.join(" ");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function normalizeStatusItem(s) {
|
|
165
|
+
if (typeof s === "string") {
|
|
166
|
+
return { id: s, name: humanizeStatusId(s), category: defaultCategoryForStatusId(s) };
|
|
167
|
+
}
|
|
168
|
+
if (!s || typeof s !== "object") return null;
|
|
169
|
+
const id = typeof s.id === "string" ? s.id : null;
|
|
170
|
+
if (!id) return null;
|
|
171
|
+
const name = typeof s.name === "string" && s.name.length > 0 ? s.name : humanizeStatusId(id);
|
|
172
|
+
const category = STATUS_CATEGORIES.includes(s.category) ? s.category : defaultCategoryForStatusId(id);
|
|
173
|
+
return { id: id, name: name, category: category };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const STORYMAPPER_DEFAULT_WORKFLOW = {
|
|
177
|
+
statuses: [
|
|
178
|
+
{ id: "backlog", name: "Backlog", category: "todo" },
|
|
179
|
+
{ id: "ready", name: "Ready", category: "todo" },
|
|
180
|
+
{ id: "in-progress", name: "In Progress", category: "doing" },
|
|
181
|
+
{ id: "review", name: "Review", category: "doing" },
|
|
182
|
+
{ id: "done", name: "Done", category: "done" },
|
|
183
|
+
// SM-242: terminal cancel state — a deliberate non-implementation.
|
|
184
|
+
{ id: "cancelled", name: "Cancelled", category: "cancelled" }
|
|
185
|
+
],
|
|
186
|
+
// Jira-style named transitions: every status reachable as allowFromAny
|
|
187
|
+
// (= forward moves preserved by default). Gates live on 'ready' (DoR)
|
|
188
|
+
// and 'done' (DoD). Project owners may replace this list with stricter
|
|
189
|
+
// source-restrictions via the Settings UI (E21.F).
|
|
190
|
+
transitions: [
|
|
191
|
+
{ id: "to-backlog", name: "→ Backlog", fromStatuses: [], toStatus: "backlog", requireGate: null, allowFromAny: true },
|
|
192
|
+
{ id: "to-ready", name: "→ Ready", fromStatuses: [], toStatus: "ready", requireGate: "DoR", allowFromAny: true },
|
|
193
|
+
{ id: "to-in-progress", name: "→ In Progress", fromStatuses: [], toStatus: "in-progress", requireGate: null, allowFromAny: true },
|
|
194
|
+
{ id: "to-review", name: "→ Review", fromStatuses: [], toStatus: "review", requireGate: null, allowFromAny: true },
|
|
195
|
+
{ id: "to-done", name: "→ Done", fromStatuses: [], toStatus: "done", requireGate: "DoD", allowFromAny: true },
|
|
196
|
+
// SM-242: gate-free cancel from any status (Cancel ≠ Delete — keeps history).
|
|
197
|
+
{ id: "cancel", name: "Cancel", fromStatuses: [], toStatus: "cancelled", requireGate: null, allowFromAny: true }
|
|
198
|
+
],
|
|
199
|
+
byType: {}
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const LIMITS = {
|
|
203
|
+
maxTicketsPerProject: 5000,
|
|
204
|
+
maxReleases: 200,
|
|
205
|
+
maxProcessSteps: 200,
|
|
206
|
+
maxChecklistItems: 100,
|
|
207
|
+
maxLabelLength: 200,
|
|
208
|
+
maxTitleLength: 300,
|
|
209
|
+
maxDescriptionLength: 50000,
|
|
210
|
+
maxCommentBody: 50000
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
// Helpers
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
function uid(prefix) {
|
|
218
|
+
return (prefix || "") + Math.random().toString(36).slice(2, 7) + Date.now().toString(36).slice(-3);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function now() {
|
|
222
|
+
return Date.now();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function clone(x) {
|
|
226
|
+
return JSON.parse(JSON.stringify(x));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
// SM-240 — Copy-on-Write snapshot machinery.
|
|
231
|
+
//
|
|
232
|
+
// Every op used to start with a FULL deep clone of the snapshot (the real
|
|
233
|
+
// `commit` hotspot, ~11ms at N=1000) — destroying structural sharing for the
|
|
234
|
+
// undo stack and the renderers' reference-equality fast-paths. cowSnap makes
|
|
235
|
+
// a SHALLOW copy (project + the entity arrays are fresh; the entity OBJECTS
|
|
236
|
+
// stay shared); cowTicket/cowRelease/cowProcessStep deep-clone exactly the
|
|
237
|
+
// one entity about to be mutated and swap it into the array.
|
|
238
|
+
//
|
|
239
|
+
// The `__cowFresh` WeakSet (non-enumerable — invisible to JSON/Object.keys/
|
|
240
|
+
// deep-equal) tracks objects that are already private to this op run, making
|
|
241
|
+
// the cow* helpers idempotent: a second cow of the same entity returns the
|
|
242
|
+
// SAME object, so helpers that hold a reference never go stale. Freshly
|
|
243
|
+
// created entities are registered via markCowFresh for the same reason.
|
|
244
|
+
//
|
|
245
|
+
// Guardrails live in tests/test-core-cow.js: (a) the input snapshot is
|
|
246
|
+
// deep-frozen — any in-place mutation of a shared object throws; (b) untouched
|
|
247
|
+
// tickets must come out reference-equal; (c) every op output must be
|
|
248
|
+
// normalize-invariant (the store skips the full normalize on the op path).
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
function cowSnap(snap) {
|
|
252
|
+
const s = Object.assign({}, snap);
|
|
253
|
+
s.project = Object.assign({}, snap.project);
|
|
254
|
+
s.tickets = (snap.tickets || []).slice();
|
|
255
|
+
s.releases = (snap.releases || []).slice();
|
|
256
|
+
s.processSteps = (snap.processSteps || []).slice();
|
|
257
|
+
Object.defineProperty(s, "__cowFresh", {
|
|
258
|
+
value: new WeakSet(), enumerable: false, configurable: true
|
|
259
|
+
});
|
|
260
|
+
return s;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function markCowFresh(s, obj) {
|
|
264
|
+
if (s && s.__cowFresh && obj && typeof obj === "object") s.__cowFresh.add(obj);
|
|
265
|
+
return obj;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function cowEntity(s, arrName, id) {
|
|
269
|
+
const arr = s[arrName] || [];
|
|
270
|
+
const i = arr.findIndex(e => e && e.id === id);
|
|
271
|
+
if (i < 0) return null;
|
|
272
|
+
const cur = arr[i];
|
|
273
|
+
if (s.__cowFresh && s.__cowFresh.has(cur)) return cur; // already private
|
|
274
|
+
const next = clone(cur);
|
|
275
|
+
arr[i] = next;
|
|
276
|
+
if (s.__cowFresh) s.__cowFresh.add(next);
|
|
277
|
+
return next;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function cowTicket(s, id) { return cowEntity(s, "tickets", id); }
|
|
281
|
+
function cowRelease(s, id) { return cowEntity(s, "releases", id); }
|
|
282
|
+
function cowProcessStep(s, id) { return cowEntity(s, "processSteps", id); }
|
|
283
|
+
|
|
284
|
+
function isObject(x) {
|
|
285
|
+
return x !== null && typeof x === "object" && !Array.isArray(x);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function normalizeActor(actor) {
|
|
289
|
+
if (!isObject(actor)) return { type: "human", id: "unknown", name: "Unknown" };
|
|
290
|
+
const out = {
|
|
291
|
+
type: actor.type === "ai" ? "ai" : "human",
|
|
292
|
+
id: typeof actor.id === "string" ? actor.id : "unknown",
|
|
293
|
+
name: typeof actor.name === "string" ? actor.name : "Unknown"
|
|
294
|
+
};
|
|
295
|
+
if (typeof actor.sessionId === "string") out.sessionId = actor.sessionId;
|
|
296
|
+
return out;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
300
|
+
// normalize* — fill defaults, freeze shape
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
function normalizeChecklistItem(item) {
|
|
304
|
+
return {
|
|
305
|
+
id: typeof item.id === "string" ? item.id : uid("ci-"),
|
|
306
|
+
label: typeof item.label === "string" ? item.label : "",
|
|
307
|
+
required: item.required !== false,
|
|
308
|
+
checked: item.checked === true,
|
|
309
|
+
checkedAt: typeof item.checkedAt === "number" ? item.checkedAt : null,
|
|
310
|
+
checkedBy: isObject(item.checkedBy) ? normalizeActor(item.checkedBy) : null
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function normalizeChecklist(cl) {
|
|
315
|
+
const items = Array.isArray(cl && cl.items) ? cl.items.map(normalizeChecklistItem) : [];
|
|
316
|
+
return { items };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function normalizeDefinitionItem(item) {
|
|
320
|
+
return {
|
|
321
|
+
id: typeof item.id === "string" ? item.id : uid("def-"),
|
|
322
|
+
label: typeof item.label === "string" ? item.label : "",
|
|
323
|
+
required: item.required !== false
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function normalizeDefinitionBlock(block) {
|
|
328
|
+
const out = {
|
|
329
|
+
global: Array.isArray(block && block.global) ? block.global.map(normalizeDefinitionItem) : [],
|
|
330
|
+
byType: {}
|
|
331
|
+
};
|
|
332
|
+
const byType = (block && block.byType) || {};
|
|
333
|
+
for (const key of Object.keys(byType)) {
|
|
334
|
+
const entry = byType[key] || {};
|
|
335
|
+
const norm = {};
|
|
336
|
+
if (Array.isArray(entry.appended)) norm.appended = entry.appended.map(normalizeDefinitionItem);
|
|
337
|
+
if (Array.isArray(entry.overridden)) norm.overridden = entry.overridden.map(normalizeDefinitionItem);
|
|
338
|
+
out.byType[key] = norm;
|
|
339
|
+
}
|
|
340
|
+
return out;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function normalizeDefinitions(defs) {
|
|
344
|
+
defs = defs || {};
|
|
345
|
+
return {
|
|
346
|
+
ready: normalizeDefinitionBlock(defs.ready),
|
|
347
|
+
done: normalizeDefinitionBlock(defs.done)
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function isEmptyDefinitions(defs) {
|
|
352
|
+
if (!defs) return true;
|
|
353
|
+
const ready = (defs.ready && defs.ready.global) || [];
|
|
354
|
+
const done = (defs.done && defs.done.global) || [];
|
|
355
|
+
return ready.length === 0 && done.length === 0;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Legacy form: { [target]: {requireGate} }. New form: array of named
|
|
359
|
+
// transitions (Jira-style). normalizeWorkflowTransitions accepts either
|
|
360
|
+
// input form and always emits the array form. The conversion needs the
|
|
361
|
+
// status list because legacy migration generates one allowFromAny entry
|
|
362
|
+
// per known status so existing forward-step behavior is preserved.
|
|
363
|
+
function normalizeTransitionItem(raw, knownStatusIds) {
|
|
364
|
+
if (!raw || typeof raw !== "object") return null;
|
|
365
|
+
const toStatus = typeof raw.toStatus === "string" ? raw.toStatus : null;
|
|
366
|
+
if (!toStatus) return null;
|
|
367
|
+
if (knownStatusIds && !knownStatusIds.includes(toStatus)) return null;
|
|
368
|
+
const gate = raw.requireGate;
|
|
369
|
+
const fromStatuses = Array.isArray(raw.fromStatuses)
|
|
370
|
+
? raw.fromStatuses.filter(s => typeof s === "string" && (!knownStatusIds || knownStatusIds.includes(s)))
|
|
371
|
+
: [];
|
|
372
|
+
// allowFromAny is whatever the caller said. Don't infer it from
|
|
373
|
+
// fromStatuses.length === 0 — that would override a user who's mid-edit
|
|
374
|
+
// (just unchecked "From any", hasn't picked sources yet) by silently
|
|
375
|
+
// re-checking it. A transition with allowFromAny=false AND empty
|
|
376
|
+
// fromStatuses is "unreachable" at validation time, which is fine as a
|
|
377
|
+
// transient state.
|
|
378
|
+
return {
|
|
379
|
+
id: typeof raw.id === "string" && raw.id.length > 0 ? raw.id : uid("tr-"),
|
|
380
|
+
name: typeof raw.name === "string" && raw.name.length > 0 ? raw.name : ("→ " + humanizeStatusId(toStatus)),
|
|
381
|
+
fromStatuses: fromStatuses,
|
|
382
|
+
toStatus: toStatus,
|
|
383
|
+
requireGate: (gate === "DoR" || gate === "DoD") ? gate : null,
|
|
384
|
+
allowFromAny: raw.allowFromAny === true
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function normalizeWorkflowTransitions(t, knownStatusIds) {
|
|
389
|
+
// New form: array of named transitions.
|
|
390
|
+
if (Array.isArray(t)) {
|
|
391
|
+
const out = [];
|
|
392
|
+
const seenIds = new Set();
|
|
393
|
+
for (const raw of t) {
|
|
394
|
+
const tr = normalizeTransitionItem(raw, knownStatusIds);
|
|
395
|
+
if (!tr) continue;
|
|
396
|
+
if (seenIds.has(tr.id)) continue;
|
|
397
|
+
seenIds.add(tr.id);
|
|
398
|
+
out.push(tr);
|
|
399
|
+
}
|
|
400
|
+
return out;
|
|
401
|
+
}
|
|
402
|
+
// Legacy form: object keyed by target status. Build one allowFromAny
|
|
403
|
+
// transition per known status; entries in the legacy object inject the
|
|
404
|
+
// gate where defined, missing keys default to no gate.
|
|
405
|
+
if (t && typeof t === "object") {
|
|
406
|
+
const ids = Array.isArray(knownStatusIds) ? knownStatusIds : [];
|
|
407
|
+
const legacy = {};
|
|
408
|
+
for (const k of Object.keys(t)) {
|
|
409
|
+
const v = t[k];
|
|
410
|
+
if (!v || typeof v !== "object") continue;
|
|
411
|
+
const gate = v.requireGate;
|
|
412
|
+
legacy[k] = { requireGate: (gate === "DoR" || gate === "DoD") ? gate : null };
|
|
413
|
+
}
|
|
414
|
+
const out = [];
|
|
415
|
+
for (const id of ids) {
|
|
416
|
+
const entry = legacy[id];
|
|
417
|
+
out.push({
|
|
418
|
+
id: "to-" + id,
|
|
419
|
+
name: "→ " + humanizeStatusId(id),
|
|
420
|
+
fromStatuses: [],
|
|
421
|
+
toStatus: id,
|
|
422
|
+
requireGate: entry ? entry.requireGate : null,
|
|
423
|
+
allowFromAny: true
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
return out;
|
|
427
|
+
}
|
|
428
|
+
return [];
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function normalizeStatusList(list) {
|
|
432
|
+
if (!Array.isArray(list)) return [];
|
|
433
|
+
const out = [];
|
|
434
|
+
const seen = new Set();
|
|
435
|
+
for (const raw of list) {
|
|
436
|
+
const s = normalizeStatusItem(raw);
|
|
437
|
+
if (!s) continue;
|
|
438
|
+
if (seen.has(s.id)) continue; // dedupe by id
|
|
439
|
+
seen.add(s.id);
|
|
440
|
+
out.push(s);
|
|
441
|
+
}
|
|
442
|
+
return out;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// SM-242: additive migration — a workflow with NO cancelled-category status
|
|
446
|
+
// gets a `cancelled` status + a gate-free `cancel` transition appended. Never
|
|
447
|
+
// touches existing statuses/transitions; a project that already owns a
|
|
448
|
+
// cancelled-category status (any id) is left byte-identical. Idempotent.
|
|
449
|
+
function ensureCancelledStatus(out) {
|
|
450
|
+
// An empty workflow stays empty so normalizeProject falls back to the default
|
|
451
|
+
// (which already carries cancelled). Only migrate REAL workflows.
|
|
452
|
+
if (!out.statuses.length) return out;
|
|
453
|
+
// SM-244 self-heal: an OLD (pre-SM-242) server that received a snapshot with
|
|
454
|
+
// a cancelled status normalizes its unknown "cancelled" category DOWN to
|
|
455
|
+
// "done" and persists that. After the server is upgraded the corruption
|
|
456
|
+
// survives (done is a valid category). Repair a status whose id is
|
|
457
|
+
// "cancelled" back to the cancelled category so cancel works again.
|
|
458
|
+
for (const s of out.statuses) {
|
|
459
|
+
if (s && s.id === "cancelled" && s.category !== "cancelled") s.category = "cancelled";
|
|
460
|
+
}
|
|
461
|
+
const hasCancelledCat = out.statuses.some(s => s.category === "cancelled");
|
|
462
|
+
const hasCancelledId = out.statuses.some(s => s.id === "cancelled");
|
|
463
|
+
if (hasCancelledCat || hasCancelledId) return out;
|
|
464
|
+
out.statuses = out.statuses.concat([{ id: "cancelled", name: "Cancelled", category: "cancelled" }]);
|
|
465
|
+
// Guard on BOTH toStatus AND id so the append is idempotent even when the
|
|
466
|
+
// workflow already owns a transition with id "cancel" pointing elsewhere
|
|
467
|
+
// (otherwise normalize² would dedupe the duplicate id and diverge).
|
|
468
|
+
if (!out.transitions.some(t => t.toStatus === "cancelled" || t.id === "cancel")) {
|
|
469
|
+
out.transitions = out.transitions.concat([
|
|
470
|
+
{ id: "cancel", name: "Cancel", fromStatuses: [], toStatus: "cancelled", requireGate: null, allowFromAny: true }
|
|
471
|
+
]);
|
|
472
|
+
}
|
|
473
|
+
return out;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function normalizeWorkflow(wf) {
|
|
477
|
+
if (!wf || typeof wf !== "object") return null;
|
|
478
|
+
const statuses = normalizeStatusList(wf.statuses);
|
|
479
|
+
const statusIds = statuses.map(s => s.id);
|
|
480
|
+
const out = ensureCancelledStatus({
|
|
481
|
+
statuses: statuses,
|
|
482
|
+
transitions: normalizeWorkflowTransitions(wf.transitions, statusIds),
|
|
483
|
+
byType: {}
|
|
484
|
+
});
|
|
485
|
+
if (wf.byType && typeof wf.byType === "object") {
|
|
486
|
+
for (const type of Object.keys(wf.byType)) {
|
|
487
|
+
const v = wf.byType[type];
|
|
488
|
+
if (!v || typeof v !== "object") continue;
|
|
489
|
+
const sub = {};
|
|
490
|
+
if (Array.isArray(v.statuses)) sub.statuses = normalizeStatusList(v.statuses);
|
|
491
|
+
if (v.transitions) {
|
|
492
|
+
// Per-type-Override darf entweder array (new) oder object (legacy)
|
|
493
|
+
// sein. Bei legacy-Form generieren wir NICHT die volle Liste pro
|
|
494
|
+
// Status — nur die expliziten Einträge, damit der Merge in
|
|
495
|
+
// getWorkflowForType per-Target gezielt ersetzt.
|
|
496
|
+
if (Array.isArray(v.transitions)) {
|
|
497
|
+
sub.transitions = normalizeWorkflowTransitions(v.transitions, statusIds);
|
|
498
|
+
} else if (typeof v.transitions === "object") {
|
|
499
|
+
// Bauen explizit, nur für die im Legacy-Override genannten Targets.
|
|
500
|
+
const overriddenTargets = Object.keys(v.transitions);
|
|
501
|
+
sub.transitions = [];
|
|
502
|
+
for (const target of overriddenTargets) {
|
|
503
|
+
if (!statusIds.includes(target)) continue;
|
|
504
|
+
const entry = v.transitions[target];
|
|
505
|
+
if (!entry || typeof entry !== "object") continue;
|
|
506
|
+
const gate = entry.requireGate;
|
|
507
|
+
sub.transitions.push({
|
|
508
|
+
id: "to-" + target,
|
|
509
|
+
name: "→ " + humanizeStatusId(target),
|
|
510
|
+
fromStatuses: [],
|
|
511
|
+
toStatus: target,
|
|
512
|
+
requireGate: (gate === "DoR" || gate === "DoD") ? gate : null,
|
|
513
|
+
allowFromAny: true
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
out.byType[type] = sub;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
return out;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Resolve the effective workflow for a given ticket type. Layering:
|
|
526
|
+
* project.workflow.byType[type] (statuses/transitions)
|
|
527
|
+
* → overrides project.workflow.{statuses, transitions}
|
|
528
|
+
* → overrides STORYMAPPER_DEFAULT_WORKFLOW.
|
|
529
|
+
* Missing per-type fields fall back to the project-level workflow.
|
|
530
|
+
*/
|
|
531
|
+
// Merge base transition list with per-type overrides. Override semantics:
|
|
532
|
+
// when a per-type transition has the same toStatus as a base transition,
|
|
533
|
+
// the per-type one replaces it. Other base transitions stay. This keeps
|
|
534
|
+
// the legacy behavior where `byType[type].transitions["ready"]={gate:null}`
|
|
535
|
+
// disables the DoR gate for that type only.
|
|
536
|
+
function mergeTransitionLists(base, override) {
|
|
537
|
+
if (!Array.isArray(override) || override.length === 0) return (base || []).slice();
|
|
538
|
+
const result = (base || []).slice();
|
|
539
|
+
for (const tr of override) {
|
|
540
|
+
const idx = result.findIndex(b => b.toStatus === tr.toStatus);
|
|
541
|
+
if (idx >= 0) result[idx] = tr;
|
|
542
|
+
else result.push(tr);
|
|
543
|
+
}
|
|
544
|
+
return result;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function getWorkflowForType(project, type) {
|
|
548
|
+
const base = (project && project.workflow) || STORYMAPPER_DEFAULT_WORKFLOW;
|
|
549
|
+
const baseStatuses = (base.statuses && base.statuses.length > 0) ? base.statuses : STORYMAPPER_DEFAULT_WORKFLOW.statuses;
|
|
550
|
+
const baseTransitions = Array.isArray(base.transitions) ? base.transitions : STORYMAPPER_DEFAULT_WORKFLOW.transitions;
|
|
551
|
+
const perType = (base.byType && base.byType[type]) || null;
|
|
552
|
+
if (!perType) {
|
|
553
|
+
return { statuses: baseStatuses.slice(), transitions: baseTransitions.slice() };
|
|
554
|
+
}
|
|
555
|
+
return {
|
|
556
|
+
statuses: (perType.statuses && perType.statuses.length > 0) ? perType.statuses.slice() : baseStatuses.slice(),
|
|
557
|
+
transitions: mergeTransitionLists(baseTransitions, perType.transitions || [])
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function normalizeLabel(l) {
|
|
562
|
+
// Accept either a string (legacy: ticket.labels and old project.labels were
|
|
563
|
+
// string[]; treat the value as the label name) or an object {id?, name, color?}.
|
|
564
|
+
if (typeof l === "string") {
|
|
565
|
+
return { id: uid("lbl-"), name: l, color: "#999999" };
|
|
566
|
+
}
|
|
567
|
+
l = l || {};
|
|
568
|
+
return {
|
|
569
|
+
id: typeof l.id === "string" ? l.id : uid("lbl-"),
|
|
570
|
+
name: typeof l.name === "string" ? l.name : "",
|
|
571
|
+
color: typeof l.color === "string" ? l.color : "#999999"
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// E21.C: Kanban-Board-Config. Eine Liste von Columns; jede Column bündelt
|
|
576
|
+
// einen oder mehrere Statuses zu einer Lane. Default-Mapping ist 1:1
|
|
577
|
+
// (eine Column pro Status, gleiche Reihenfolge) — Verhalten bleibt
|
|
578
|
+
// rückwärtskompatibel.
|
|
579
|
+
function normalizeKanbanColumn(c, knownStatusIds) {
|
|
580
|
+
if (!c || typeof c !== "object") return null;
|
|
581
|
+
const id = typeof c.id === "string" && c.id.length > 0 ? c.id : null;
|
|
582
|
+
const name = typeof c.name === "string" ? c.name : "";
|
|
583
|
+
const statusIds = Array.isArray(c.statusIds)
|
|
584
|
+
? c.statusIds.filter(s => typeof s === "string" && knownStatusIds.includes(s))
|
|
585
|
+
: [];
|
|
586
|
+
if (!id) return null;
|
|
587
|
+
return { id: id, name: name, statusIds: statusIds };
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function defaultKanbanColumns(statuses) {
|
|
591
|
+
return (statuses || []).map(s => ({
|
|
592
|
+
id: "col-" + s.id,
|
|
593
|
+
name: s.name,
|
|
594
|
+
statusIds: [s.id]
|
|
595
|
+
}));
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function normalizeKanbanBoard(board, statuses) {
|
|
599
|
+
const knownStatusIds = statuses.map(s => s.id);
|
|
600
|
+
if (!board || typeof board !== "object" || !Array.isArray(board.columns)) {
|
|
601
|
+
return { columns: defaultKanbanColumns(statuses) };
|
|
602
|
+
}
|
|
603
|
+
const cols = [];
|
|
604
|
+
const seenIds = new Set();
|
|
605
|
+
for (const raw of board.columns) {
|
|
606
|
+
const c = normalizeKanbanColumn(raw, knownStatusIds);
|
|
607
|
+
if (!c) continue;
|
|
608
|
+
if (seenIds.has(c.id)) continue;
|
|
609
|
+
seenIds.add(c.id);
|
|
610
|
+
cols.push(c);
|
|
611
|
+
}
|
|
612
|
+
// Empty columns are allowed (user just created one, or every status was
|
|
613
|
+
// dragged elsewhere). If the board has NO columns at all → fall back to
|
|
614
|
+
// the default 1:1 mapping so the kanban view never goes completely blank.
|
|
615
|
+
if (cols.length === 0) return { columns: defaultKanbanColumns(statuses) };
|
|
616
|
+
return { columns: cols };
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function normalizeProjectBoards(boards, statuses) {
|
|
620
|
+
return { kanban: normalizeKanbanBoard(boards && boards.kanban, statuses) };
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// SM-100: test-flags are type-bound. The renderer + editor only honour them
|
|
624
|
+
// on the test types; storing them for other types is what created the
|
|
625
|
+
// "Epic shows Prerequisites/Steps/Outcome" leak.
|
|
626
|
+
const TEST_FLAGS_FOR_TYPE = {
|
|
627
|
+
"test-definition": ["showPrerequisites", "showSteps"],
|
|
628
|
+
"test-execution": ["showExecutionSteps", "showTestOutcome"]
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
function normalizeEntityTypeConfig(cfg) {
|
|
632
|
+
if (!cfg || typeof cfg !== "object") return {};
|
|
633
|
+
const out = {};
|
|
634
|
+
for (const k of Object.keys(cfg)) {
|
|
635
|
+
const v = cfg[k];
|
|
636
|
+
if (!v || typeof v !== "object") continue;
|
|
637
|
+
const entry = {
|
|
638
|
+
showAcceptanceCriteria: v.showAcceptanceCriteria !== false,
|
|
639
|
+
showDefinitionOfReady: v.showDefinitionOfReady !== false,
|
|
640
|
+
showDefinitionOfDone: v.showDefinitionOfDone !== false,
|
|
641
|
+
allowParentEpic: v.allowParentEpic !== false,
|
|
642
|
+
showProcessStep: v.showProcessStep !== false,
|
|
643
|
+
showRelease: v.showRelease !== false,
|
|
644
|
+
showLinks: v.showLinks !== false
|
|
645
|
+
};
|
|
646
|
+
// SM-100 migration: only carry the test-flags that apply to this type.
|
|
647
|
+
// Stale flags from prior writes are dropped — the snapshot becomes clean
|
|
648
|
+
// on the next save. Non-test types never store test-flags at all.
|
|
649
|
+
const allowed = TEST_FLAGS_FOR_TYPE[k] || [];
|
|
650
|
+
for (const flag of allowed) entry[flag] = v[flag] !== false;
|
|
651
|
+
out[k] = entry;
|
|
652
|
+
}
|
|
653
|
+
return out;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Resolve the effective config for a given ticket type on a project.
|
|
658
|
+
* Defaults: all sections shown, allowParentEpic=true. Special-case types:
|
|
659
|
+
* - "epic" hard-codes allowParentEpic=false
|
|
660
|
+
* - "test-definition" hides AC (they belong on the story) but shows
|
|
661
|
+
* prerequisites + steps
|
|
662
|
+
* - "test-execution" hides AC/DoR/DoD (outcome replaces the gate) and
|
|
663
|
+
* shows executionSteps + testOutcome
|
|
664
|
+
* Per-type overrides from `project.entityTypeConfig` take precedence.
|
|
665
|
+
*/
|
|
666
|
+
function getEntityTypeConfig(project, type) {
|
|
667
|
+
const isTestDef = type === "test-definition";
|
|
668
|
+
const isTestExec = type === "test-execution";
|
|
669
|
+
const isAnyTest = isTestDef || isTestExec;
|
|
670
|
+
// SM-196: spec-layer types (requirement + its spec-module container) are
|
|
671
|
+
// SpecObjects — no AC/DoR/DoD, no board position, no parent epic. Their own
|
|
672
|
+
// text IS the spec. Links stay on (realises/tests point AT a requirement;
|
|
673
|
+
// a module contains its requirements; the modal may still surface them).
|
|
674
|
+
const isReq = isSpecType(type);
|
|
675
|
+
// SM-237: an epic's status is DERIVED from its stories (roll-up), so the
|
|
676
|
+
// DoR/DoD gates never fire on an epic — the checklist UI is N/A (Lockstep:
|
|
677
|
+
// hidden field ⇒ rule not applicable).
|
|
678
|
+
const isEpic = type === "epic";
|
|
679
|
+
const defaults = {
|
|
680
|
+
showAcceptanceCriteria: !isAnyTest && !isReq,
|
|
681
|
+
// SM-54-followup: tests have their OWN completeness criteria —
|
|
682
|
+
// prerequisites + steps for a definition, outcome for an execution.
|
|
683
|
+
// The generic DoR/DoD checklist doesn't apply.
|
|
684
|
+
showDefinitionOfReady: !isAnyTest && !isReq && !isEpic,
|
|
685
|
+
showDefinitionOfDone: !isAnyTest && !isReq && !isEpic,
|
|
686
|
+
allowParentEpic: type !== "epic" && !isReq,
|
|
687
|
+
showProcessStep: !isReq,
|
|
688
|
+
showRelease: !isReq,
|
|
689
|
+
showLinks: true,
|
|
690
|
+
// Test-type defaults.
|
|
691
|
+
showPrerequisites: isTestDef,
|
|
692
|
+
showSteps: isTestDef,
|
|
693
|
+
showExecutionSteps: isTestExec,
|
|
694
|
+
showTestOutcome: isTestExec
|
|
695
|
+
};
|
|
696
|
+
const override = (project && project.entityTypeConfig && project.entityTypeConfig[type]) || null;
|
|
697
|
+
if (!override) return defaults;
|
|
698
|
+
return Object.assign({}, defaults, override, {
|
|
699
|
+
// Hard override: epic + requirement never have a parent epic regardless of config.
|
|
700
|
+
allowParentEpic: (type === "epic" || isReq) ? false : (override.allowParentEpic !== false),
|
|
701
|
+
// SM-237 hard-lock: epics never show DoR/DoD (derived status ⇒ gates N/A),
|
|
702
|
+
// no matter what a stale per-type override carries.
|
|
703
|
+
showDefinitionOfReady: isEpic ? false : (Object.assign({}, defaults, override).showDefinitionOfReady),
|
|
704
|
+
showDefinitionOfDone: isEpic ? false : (Object.assign({}, defaults, override).showDefinitionOfDone),
|
|
705
|
+
// SM-100 hard-lock: test-flags are bound to their type. An override that
|
|
706
|
+
// (legacy or otherwise) carries a `true` for the wrong type must not be
|
|
707
|
+
// honoured — otherwise epics/stories sprout Prereqs / Steps / Outcome.
|
|
708
|
+
showPrerequisites: isTestDef ? (override.showPrerequisites !== false) : false,
|
|
709
|
+
showSteps: isTestDef ? (override.showSteps !== false) : false,
|
|
710
|
+
showExecutionSteps: isTestExec ? (override.showExecutionSteps !== false) : false,
|
|
711
|
+
showTestOutcome: isTestExec ? (override.showTestOutcome !== false) : false
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function normalizeProject(p) {
|
|
716
|
+
p = p || {};
|
|
717
|
+
const ticketTypes = Array.isArray(p.ticketTypes) && p.ticketTypes.length > 0
|
|
718
|
+
? p.ticketTypes.slice()
|
|
719
|
+
: DEFAULT_TICKET_TYPES.slice();
|
|
720
|
+
// Definitions: nur seeden, wenn das Projekt GAR KEINE definitions mitbringt
|
|
721
|
+
// (absent → neues Projekt erbt die Bundled-Defaults). Ein EXPLIZIT leeres
|
|
722
|
+
// definitions-Objekt ist eine bewusste User-Wahl und wird respektiert —
|
|
723
|
+
// sonst ließe sich eine geleerte DoR/DoD-Checkliste nie persistieren, weil
|
|
724
|
+
// normalizeProject auf jedem Save läuft. (SM-150)
|
|
725
|
+
const definitions = (p.definitions === undefined || p.definitions === null)
|
|
726
|
+
? normalizeDefinitions(STORYMAPPER_DEFAULT_DEFINITIONS)
|
|
727
|
+
: normalizeDefinitions(p.definitions);
|
|
728
|
+
// Workflow: project.workflow überschreibt; sonst Default-Workflow.
|
|
729
|
+
const wf = normalizeWorkflow(p.workflow);
|
|
730
|
+
const workflow = (wf && wf.statuses.length > 0)
|
|
731
|
+
? wf
|
|
732
|
+
: normalizeWorkflow(STORYMAPPER_DEFAULT_WORKFLOW);
|
|
733
|
+
const out = {
|
|
734
|
+
id: typeof p.id === "string" ? p.id : uid("p-"),
|
|
735
|
+
name: typeof p.name === "string" ? p.name : "",
|
|
736
|
+
description: typeof p.description === "string" ? p.description : "",
|
|
737
|
+
ticketPrefix: typeof p.ticketPrefix === "string" && p.ticketPrefix.length > 0 ? p.ticketPrefix : "P",
|
|
738
|
+
ticketCounter: typeof p.ticketCounter === "number" ? p.ticketCounter : 0,
|
|
739
|
+
definitions: definitions,
|
|
740
|
+
workflow: workflow,
|
|
741
|
+
boards: normalizeProjectBoards(p.boards, workflow.statuses),
|
|
742
|
+
ticketTypes: ticketTypes,
|
|
743
|
+
entityTypeConfig: normalizeEntityTypeConfig(p.entityTypeConfig),
|
|
744
|
+
// SM-45: project.linkTypes — seeded with defaults if empty so new
|
|
745
|
+
// projects come with a workable catalogue. Custom lists completely
|
|
746
|
+
// replace the defaults (no merge — explicit project-owner control).
|
|
747
|
+
linkTypes: _ensureContainsLinkType(
|
|
748
|
+
(Array.isArray(p.linkTypes) && p.linkTypes.length > 0)
|
|
749
|
+
? p.linkTypes.map(normalizeLinkType)
|
|
750
|
+
: STORYMAPPER_DEFAULT_LINK_TYPES.map(normalizeLinkType)
|
|
751
|
+
),
|
|
752
|
+
labels: Array.isArray(p.labels) ? p.labels.map(normalizeLabel) : [],
|
|
753
|
+
// SM-93 — Governance config (predicates + messages + tool_actions).
|
|
754
|
+
// Seeded with STORYMAPPER_DEFAULT_GOVERNANCE if missing/incomplete so
|
|
755
|
+
// every project gets a workable gate-set out of the box. Per-project
|
|
756
|
+
// overrides supported via updateProject({governance: ...}).
|
|
757
|
+
governance: normalizeGovernance(p.governance),
|
|
758
|
+
isDeleted: p.isDeleted === true,
|
|
759
|
+
deletedAt: typeof p.deletedAt === "number" ? p.deletedAt : null,
|
|
760
|
+
deletedBy: isObject(p.deletedBy) ? normalizeActor(p.deletedBy) : null,
|
|
761
|
+
createdAt: typeof p.createdAt === "number" ? p.createdAt : now(),
|
|
762
|
+
createdBy: isObject(p.createdBy) ? normalizeActor(p.createdBy) : { type: "human", id: "unknown", name: "Unknown" },
|
|
763
|
+
updatedAt: typeof p.updatedAt === "number" ? p.updatedAt : (typeof p.createdAt === "number" ? p.createdAt : now()),
|
|
764
|
+
updatedBy: isObject(p.updatedBy) ? normalizeActor(p.updatedBy) : (isObject(p.createdBy) ? normalizeActor(p.createdBy) : { type: "human", id: "unknown", name: "Unknown" }),
|
|
765
|
+
version: typeof p.version === "number" ? p.version : 1
|
|
766
|
+
};
|
|
767
|
+
return out;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function normalizePosition(pos) {
|
|
771
|
+
pos = pos || {};
|
|
772
|
+
return {
|
|
773
|
+
releaseId: typeof pos.releaseId === "string" ? pos.releaseId : null,
|
|
774
|
+
epicId: typeof pos.epicId === "string" ? pos.epicId : null,
|
|
775
|
+
processStepId: typeof pos.processStepId === "string" ? pos.processStepId : null,
|
|
776
|
+
sortOrder: typeof pos.sortOrder === "number" ? pos.sortOrder : 0
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function normalizeAcceptanceCriterion(ac) {
|
|
781
|
+
return {
|
|
782
|
+
id: typeof ac.id === "string" ? ac.id : uid("ac-"),
|
|
783
|
+
text: typeof ac.text === "string" ? ac.text : "",
|
|
784
|
+
completed: ac.completed === true
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function normalizeComment(c) {
|
|
789
|
+
return {
|
|
790
|
+
id: typeof c.id === "string" ? c.id : uid("c-"),
|
|
791
|
+
body: typeof c.body === "string" ? c.body : "",
|
|
792
|
+
actor: isObject(c.actor) ? normalizeActor(c.actor) : { type: "human", id: "unknown", name: "Unknown" },
|
|
793
|
+
timestamp: typeof c.timestamp === "number" ? c.timestamp : now()
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// SM-45: link-type semantics. The semantic determines runtime behaviour
|
|
798
|
+
// (cycle-check, inverse-display, status-hooks). Hardcoded set; per-type
|
|
799
|
+
// `linkType.semantic` references one of these names.
|
|
800
|
+
// - precedence: predecessor/successor (cycle-checked)
|
|
801
|
+
// - blocking: blocks/blocked-by (cycle-checked)
|
|
802
|
+
// - sequence: follows-on/precedes (no cycle check; linear chain)
|
|
803
|
+
// - containment: contains/contained-by (cycle-checked)
|
|
804
|
+
// - validation: tests/tested-by (no cycle check; cross-domain)
|
|
805
|
+
// - freeform: relates-to (symmetric, no cycle check)
|
|
806
|
+
// - supersession: supersedes/replaces (cycle-checked; temporal — A obsoletes
|
|
807
|
+
// B). Drives the cross-release feature fold (SM-180): the superseding epic
|
|
808
|
+
// is the current version, the superseded chain is its history.
|
|
809
|
+
const LINK_SEMANTICS = ["precedence", "blocking", "sequence", "containment", "validation", "supersession", "freeform"];
|
|
810
|
+
const CYCLE_CHECKED_SEMANTICS = ["precedence", "blocking", "containment", "supersession"];
|
|
811
|
+
|
|
812
|
+
// SM-45: default link-type catalogue seeded into normalizeProject if the
|
|
813
|
+
// project doesn't bring its own. Covers the standard Jira-style relations
|
|
814
|
+
// every team needs out of the box. Projects can override the list via
|
|
815
|
+
// `project.linkTypes` (full replace, no merge).
|
|
816
|
+
const STORYMAPPER_DEFAULT_LINK_TYPES = [
|
|
817
|
+
{ id: "predecessor-of", label: "Predecessor of", inverseLabel: "Successor of", semantic: "precedence" },
|
|
818
|
+
{ id: "blocks", label: "Blocks", inverseLabel: "Blocked by", semantic: "blocking" },
|
|
819
|
+
{ id: "follows-on", label: "Follows on from", inverseLabel: "Precedes", semantic: "sequence" },
|
|
820
|
+
{ id: "contains", label: "Contains", inverseLabel: "Contained by", semantic: "containment" },
|
|
821
|
+
{ id: "relates-to", label: "Relates to", inverseLabel: "Relates to", semantic: "freeform" },
|
|
822
|
+
// SM-56: test-execution → test-definition. Source = the run, target = the
|
|
823
|
+
// reusable spec. Inverse "Executed by" surfaces all runs on a definition.
|
|
824
|
+
{ id: "executes", label: "Executes", inverseLabel: "Executed by", semantic: "validation" },
|
|
825
|
+
// SM-54-followup: test-definition → user-story/bug/task. A test-definition
|
|
826
|
+
// MUST have at least one outbound `tests` link before leaving backlog —
|
|
827
|
+
// enforced in validateStatusTransition.
|
|
828
|
+
{ id: "tests", label: "Tests", inverseLabel: "Tested by", semantic: "validation" },
|
|
829
|
+
// SM-94: spec-evolution. `A modifies B` means ticket A is a follow-up
|
|
830
|
+
// change that modifies the spec/behaviour of B. Open modifies-tickets
|
|
831
|
+
// make the target's linked test-definitions show derivedHealth=stale and
|
|
832
|
+
// block complete_ticket (OPEN_MODIFIES gate). cycleCheck=true so we don't
|
|
833
|
+
// accidentally build A→mod→B→mod→A loops.
|
|
834
|
+
{ id: "modifies", label: "Modifies", inverseLabel: "Modified by", semantic: "freeform", cycleCheck: true },
|
|
835
|
+
// SM-180: temporal / spec links for PRD-traceability (direction A + the
|
|
836
|
+
// cross-release fold). `A supersedes B` / `A replaces B` → B is the older
|
|
837
|
+
// version; the fold treats the superseding epic as the current feature and
|
|
838
|
+
// folds the superseded chain into its history. refines = adds detail (not
|
|
839
|
+
// obsoletes). realises = a ticket realises a PRD requirement/anchor.
|
|
840
|
+
{ id: "supersedes", label: "Supersedes", inverseLabel: "Superseded by", semantic: "supersession" },
|
|
841
|
+
{ id: "replaces", label: "Replaces", inverseLabel: "Replaced by", semantic: "supersession" },
|
|
842
|
+
{ id: "refines", label: "Refines", inverseLabel: "Refined by", semantic: "freeform", cycleCheck: true },
|
|
843
|
+
{ id: "realises", label: "Realises", inverseLabel: "Realised by", semantic: "freeform" }
|
|
844
|
+
];
|
|
845
|
+
|
|
846
|
+
// SM-44 backwards-compat: the hardcoded list of link-type IDs that get
|
|
847
|
+
// cycle-checked when project.linkTypes is missing or doesn't define them.
|
|
848
|
+
// Used as a fallback by validateLink — SM-45's project.linkTypes-derived
|
|
849
|
+
// resolution takes precedence when available.
|
|
850
|
+
const CYCLE_CHECKED_LINK_TYPES = ["blocks", "predecessor-of", "contains", "supersedes", "replaces", "refines"];
|
|
851
|
+
|
|
852
|
+
// SM-52: Epic-Story containment is canonical via this linkType. The legacy
|
|
853
|
+
// `ticket.position.epicId` field is migrated to a `contains` link in
|
|
854
|
+
// normalizeSnapshot; all read/write paths use the link as the source of truth.
|
|
855
|
+
const CONTAINS_LINK_TYPE_ID = "contains";
|
|
856
|
+
|
|
857
|
+
// SM-54-followup: the linkType a test-definition uses to point at the
|
|
858
|
+
// ticket(s) it validates ("this test exercises user-story X"). Forward
|
|
859
|
+
// transitions out of backlog require ≥1 outbound link of this type — see
|
|
860
|
+
// validateStatusTransition.
|
|
861
|
+
const TEST_TARGET_LINK_TYPE_ID = "tests";
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* Make sure `contains` is in the project linkType catalogue. The migration
|
|
865
|
+
* pass depends on it being there — if the user customized linkTypes and
|
|
866
|
+
* removed it, we re-add the default. Pure helper, returns a new array.
|
|
867
|
+
*/
|
|
868
|
+
function _ensureContainsLinkType(linkTypes) {
|
|
869
|
+
const list = Array.isArray(linkTypes) ? linkTypes.slice() : [];
|
|
870
|
+
if (list.some(lt => lt.id === CONTAINS_LINK_TYPE_ID)) return list;
|
|
871
|
+
const def = STORYMAPPER_DEFAULT_LINK_TYPES.find(lt => lt.id === CONTAINS_LINK_TYPE_ID);
|
|
872
|
+
if (def) list.push(normalizeLinkType(def));
|
|
873
|
+
return list;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function normalizeLinkType(lt) {
|
|
877
|
+
lt = lt || {};
|
|
878
|
+
const semantic = LINK_SEMANTICS.indexOf(lt.semantic) >= 0 ? lt.semantic : "freeform";
|
|
879
|
+
const out = {
|
|
880
|
+
id: typeof lt.id === "string" && lt.id.length > 0 ? lt.id : "relates-to",
|
|
881
|
+
label: typeof lt.label === "string" && lt.label.length > 0 ? lt.label : lt.id || "Relates to",
|
|
882
|
+
inverseLabel: typeof lt.inverseLabel === "string" && lt.inverseLabel.length > 0 ? lt.inverseLabel : (lt.label || lt.id || "Relates to"),
|
|
883
|
+
semantic: semantic
|
|
884
|
+
};
|
|
885
|
+
if (typeof lt.icon === "string" && lt.icon.length > 0) out.icon = lt.icon;
|
|
886
|
+
if (typeof lt.color === "string" && lt.color.length > 0) out.color = lt.color;
|
|
887
|
+
// SM-94: explicit per-linkType cycle-check opt-in. Overrides semantic
|
|
888
|
+
// resolution — useful for "modifies" which is semantically freeform but
|
|
889
|
+
// still wants the no-cycles guarantee.
|
|
890
|
+
if (lt.cycleCheck === true) out.cycleCheck = true;
|
|
891
|
+
return out;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function normalizeLink(l) {
|
|
895
|
+
l = l || {};
|
|
896
|
+
// Accept either the new `linkTypeId` or the legacy `type` field. Default
|
|
897
|
+
// to a freeform "relates-to" so legacy snapshots without a linkTypeId
|
|
898
|
+
// still round-trip cleanly.
|
|
899
|
+
const linkTypeId = typeof l.linkTypeId === "string" ? l.linkTypeId
|
|
900
|
+
: typeof l.type === "string" ? l.type
|
|
901
|
+
: "relates-to";
|
|
902
|
+
const out = {
|
|
903
|
+
id: typeof l.id === "string" ? l.id : uid("ln-"),
|
|
904
|
+
linkTypeId: linkTypeId,
|
|
905
|
+
targetTicketId: typeof l.targetTicketId === "string" ? l.targetTicketId : "",
|
|
906
|
+
createdAt: typeof l.createdAt === "number" ? l.createdAt : now(),
|
|
907
|
+
createdBy: isObject(l.createdBy) ? normalizeActor(l.createdBy) : { type: "human", id: "unknown", name: "Unknown" }
|
|
908
|
+
};
|
|
909
|
+
if (typeof l.label === "string" && l.label.length > 0) out.label = l.label;
|
|
910
|
+
return out;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// SM-53: test-definition step shape. Pure data — UI lives elsewhere.
|
|
914
|
+
// { id, step: string, data?: string, expectedResult: string }
|
|
915
|
+
function normalizeTestStep(s) {
|
|
916
|
+
s = s || {};
|
|
917
|
+
return {
|
|
918
|
+
id: typeof s.id === "string" ? s.id : uid("tstep-"),
|
|
919
|
+
step: typeof s.step === "string" ? s.step : "",
|
|
920
|
+
data: typeof s.data === "string" ? s.data : "",
|
|
921
|
+
expectedResult: typeof s.expectedResult === "string" ? s.expectedResult : ""
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// SM-53: prerequisites mirror the DoR checklist shape (id/label/required/checked)
|
|
926
|
+
// so the same inline-toggle UI can render them. Same idempotent normalizer
|
|
927
|
+
// pattern as normalizeAcceptanceCriterion.
|
|
928
|
+
function normalizeTestPrerequisite(p) {
|
|
929
|
+
p = p || {};
|
|
930
|
+
return {
|
|
931
|
+
id: typeof p.id === "string" ? p.id : uid("tpre-"),
|
|
932
|
+
label: typeof p.label === "string" ? p.label : "",
|
|
933
|
+
required: p.required !== false,
|
|
934
|
+
checked: p.checked === true
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// SM-56: a single executed step — the static fields are cloned from the
|
|
939
|
+
// definition at run-start; actualResult/status/note are filled during the run.
|
|
940
|
+
// status ∈ pending|passed|failed|blocked|skipped.
|
|
941
|
+
const TEST_EXEC_STEP_STATUSES = ["pending", "passed", "failed", "blocked", "skipped"];
|
|
942
|
+
function normalizeTestExecStep(s) {
|
|
943
|
+
s = s || {};
|
|
944
|
+
const status = TEST_EXEC_STEP_STATUSES.indexOf(s.status) >= 0 ? s.status : "pending";
|
|
945
|
+
const out = {
|
|
946
|
+
id: typeof s.id === "string" ? s.id : uid("xstep-"),
|
|
947
|
+
stepId: typeof s.stepId === "string" ? s.stepId : "",
|
|
948
|
+
step: typeof s.step === "string" ? s.step : "",
|
|
949
|
+
data: typeof s.data === "string" ? s.data : "",
|
|
950
|
+
expectedResult: typeof s.expectedResult === "string" ? s.expectedResult : "",
|
|
951
|
+
actualResult: typeof s.actualResult === "string" ? s.actualResult : "",
|
|
952
|
+
status: status
|
|
953
|
+
};
|
|
954
|
+
if (typeof s.note === "string" && s.note.length > 0) out.note = s.note;
|
|
955
|
+
return out;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// SM-56: outcome enum — same five values as step.status but at the
|
|
959
|
+
// execution-ticket level. getEffectiveOutcome returns the manual override
|
|
960
|
+
// when set, else the derived value.
|
|
961
|
+
const TEST_EXEC_OUTCOMES = ["pending", "passed", "failed", "blocked", "skipped"];
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* Derive the test-execution outcome from its steps:
|
|
965
|
+
* - any failed → "failed"
|
|
966
|
+
* - any blocked (and no failed) → "blocked"
|
|
967
|
+
* - all passed → "passed"
|
|
968
|
+
* - otherwise → "pending" (includes the empty-steps case)
|
|
969
|
+
*
|
|
970
|
+
* Pure helper, exported so MCP tools and the UI can call the same logic.
|
|
971
|
+
*/
|
|
972
|
+
function deriveOutcome(executionSteps) {
|
|
973
|
+
if (!Array.isArray(executionSteps) || executionSteps.length === 0) return "pending";
|
|
974
|
+
let allPassed = true;
|
|
975
|
+
let anyBlocked = false;
|
|
976
|
+
for (const s of executionSteps) {
|
|
977
|
+
if (!s || typeof s.status !== "string") { allPassed = false; continue; }
|
|
978
|
+
if (s.status === "failed") return "failed";
|
|
979
|
+
if (s.status === "blocked") anyBlocked = true;
|
|
980
|
+
if (s.status !== "passed") allPassed = false;
|
|
981
|
+
}
|
|
982
|
+
if (anyBlocked) return "blocked";
|
|
983
|
+
return allPassed ? "passed" : "pending";
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/** Manual outcomeOverride wins; otherwise the derived value. */
|
|
987
|
+
function getEffectiveOutcome(ticket) {
|
|
988
|
+
if (!ticket) return "pending";
|
|
989
|
+
if (typeof ticket.outcomeOverride === "string"
|
|
990
|
+
&& TEST_EXEC_OUTCOMES.indexOf(ticket.outcomeOverride) >= 0) {
|
|
991
|
+
return ticket.outcomeOverride;
|
|
992
|
+
}
|
|
993
|
+
return deriveOutcome(ticket.executionSteps || []);
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/**
|
|
997
|
+
* SM-57-followup: keep a test-execution's status in sync with its outcome.
|
|
998
|
+
* - Outcome != "pending" → status should be "done" (test produced a result).
|
|
999
|
+
* - Outcome == "pending" → status should be "in-progress" (test still running).
|
|
1000
|
+
*
|
|
1001
|
+
* Only fires the auto-transition when the current status is one of the two
|
|
1002
|
+
* states this coupling owns — `in-progress` (forward) or `done` (revert).
|
|
1003
|
+
* Manual states like `backlog` / `ready` / `review` are left alone so a user
|
|
1004
|
+
* who deliberately parked a test-execution doesn't get steamrolled.
|
|
1005
|
+
*
|
|
1006
|
+
* Pure — mutates `exec` in place; caller decides whether to bumpAudit.
|
|
1007
|
+
*/
|
|
1008
|
+
function _syncExecStatusToOutcome(exec) {
|
|
1009
|
+
if (!exec || exec.type !== "test-execution") return false;
|
|
1010
|
+
const outcome = getEffectiveOutcome(exec);
|
|
1011
|
+
// SM-170: route through _setTicketStatus so the aging clock resets when the
|
|
1012
|
+
// outcome flip actually re-opens / closes the run (not a bare assignment).
|
|
1013
|
+
if (outcome !== "pending" && exec.status === "in-progress") {
|
|
1014
|
+
_setTicketStatus(exec, "done");
|
|
1015
|
+
return true;
|
|
1016
|
+
}
|
|
1017
|
+
if (outcome === "pending" && exec.status === "done") {
|
|
1018
|
+
_setTicketStatus(exec, "in-progress");
|
|
1019
|
+
return true;
|
|
1020
|
+
}
|
|
1021
|
+
return false;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// ---------------------------------------------------------------------------
|
|
1025
|
+
// SM-93 — Spec-Evolution Governance (Predicate-Library + Evaluator +
|
|
1026
|
+
// Template-Renderer + DEFAULT_GOVERNANCE). Pure, no I/O. The MCP wiring
|
|
1027
|
+
// (SM-94) consumes these to gate tools and surface clear errors.
|
|
1028
|
+
// ---------------------------------------------------------------------------
|
|
1029
|
+
|
|
1030
|
+
const TEST_DEFINITION_LIFECYCLES = ["draft", "published"];
|
|
1031
|
+
const TEST_DEFINITION_HEALTH_STATES = ["unused", "passing", "failing", "stale", "orphaned"];
|
|
1032
|
+
|
|
1033
|
+
const STORYMAPPER_DEFAULT_GOVERNANCE = {
|
|
1034
|
+
gates: {
|
|
1035
|
+
TARGET_NOT_READY: { predicate: "linked_target_status",
|
|
1036
|
+
args: { linkTypeId: "tests", minStatus: "ready" } },
|
|
1037
|
+
MISSING_TESTS_LINK: { predicate: "outgoing_link",
|
|
1038
|
+
args: { linkTypeId: "tests", minCount: 1 } },
|
|
1039
|
+
MISSING_STEPS: { predicate: "ticket_field",
|
|
1040
|
+
args: { field: "steps", check: "non_empty" } },
|
|
1041
|
+
EMPTY_EXPECTED: { predicate: "for_each_step",
|
|
1042
|
+
args: { field: "expectedResult", check: "non_empty" } },
|
|
1043
|
+
STALE_LINKED_DEF: { predicate: "linked_definitions_health",
|
|
1044
|
+
args: { allowed: ["passing", "unused"] } },
|
|
1045
|
+
OPEN_MODIFIES: { predicate: "incoming_modifies_open", args: {} },
|
|
1046
|
+
// SM-299: no done without a test plan. A user-story/bug must have a
|
|
1047
|
+
// PUBLISHED test-definition linked via `tests` before it can complete.
|
|
1048
|
+
// Hard-gate, plan-only (a passing execution is NOT required — that would
|
|
1049
|
+
// be a stricter variant; `allowed`/exec health stays with STALE_LINKED_DEF).
|
|
1050
|
+
MISSING_TEST_DEFINITION: {
|
|
1051
|
+
predicate: "incoming_link",
|
|
1052
|
+
args: { linkTypeId: "tests", sourceType: "test-definition", sourceLifecycle: "published", minCount: 1 },
|
|
1053
|
+
appliesToTypes: ["user-story", "bug"]
|
|
1054
|
+
}
|
|
1055
|
+
},
|
|
1056
|
+
messages: {
|
|
1057
|
+
TARGET_NOT_READY: {
|
|
1058
|
+
title: "Target ticket is not ready",
|
|
1059
|
+
reason: "Definition's target {targetKey} is in status={targetStatus}. Tests can only be published once their target reaches '{minStatus}'+.",
|
|
1060
|
+
suggestion: "Promote {targetKey} to ready first (mark_ready). Once AC are frozen, retry publish_test_definition on {definitionKey}.",
|
|
1061
|
+
skillRef: "Test-Definitions require their target to be in ready+. Specs must be frozen before tests are derived."
|
|
1062
|
+
},
|
|
1063
|
+
MISSING_TESTS_LINK: {
|
|
1064
|
+
title: "Definition has no target",
|
|
1065
|
+
reason: "Definition {definitionKey} has no outgoing 'tests'-link.",
|
|
1066
|
+
suggestion: "Add a 'tests'-link via link_create pointing at the feature/bug to be validated, then retry publish_test_definition.",
|
|
1067
|
+
skillRef: "A test-definition without a target is orphaned. Every definition tests a specific feature."
|
|
1068
|
+
},
|
|
1069
|
+
MISSING_STEPS: {
|
|
1070
|
+
title: "Definition has no steps",
|
|
1071
|
+
reason: "Definition {definitionKey} has 0 steps.",
|
|
1072
|
+
suggestion: "Add at least one step via test_def_step_add (each step needs a non-empty expectedResult), then retry publish_test_definition.",
|
|
1073
|
+
skillRef: "A test-definition needs steps with expectedResult — that's the spec being tested."
|
|
1074
|
+
},
|
|
1075
|
+
EMPTY_EXPECTED: {
|
|
1076
|
+
title: "Step has empty expectedResult",
|
|
1077
|
+
reason: "At least one step in {definitionKey} has an empty expectedResult.",
|
|
1078
|
+
suggestion: "Fill in the expectedResult for every step via test_def_step_update, then retry publish_test_definition.",
|
|
1079
|
+
skillRef: "Each step's expectedResult defines pass-criteria — empty makes the step un-verifiable."
|
|
1080
|
+
},
|
|
1081
|
+
STALE_LINKED_DEF: {
|
|
1082
|
+
title: "Linked test-definition is stale",
|
|
1083
|
+
reason: "Definition(s) {staleReasons} block completion of {targetKey}.",
|
|
1084
|
+
suggestion: "Resolve the blocking changes first, THEN re-run the affected definition(s) via test_exec_start, THEN retry complete_ticket on {targetKey}.",
|
|
1085
|
+
skillRef: "Specs frieren ein. Aenderungen werden zu Tickets. Stale tests block done-transitions."
|
|
1086
|
+
},
|
|
1087
|
+
OPEN_MODIFIES: {
|
|
1088
|
+
title: "Open modification ticket(s) block completion",
|
|
1089
|
+
reason: "Open modifies-ticket(s) {modBlockerKeys} point at {targetKey}.",
|
|
1090
|
+
suggestion: "Resolve {modBlockerKeys} (move them to done) before completing {targetKey}.",
|
|
1091
|
+
skillRef: "An open modifies-link means the spec is mid-change. complete_ticket must wait."
|
|
1092
|
+
},
|
|
1093
|
+
MISSING_TEST_DEFINITION: {
|
|
1094
|
+
title: "No test plan linked",
|
|
1095
|
+
reason: "{targetKey} ({targetType}) has no PUBLISHED test-definition linked via 'tests' — a test plan is required before done.",
|
|
1096
|
+
suggestion: "1) ticket_create type=test-definition (the test plan). 2) test_def_step_add for each step (with expectedResult). 3) link_create linkTypeId='tests' from the definition to {targetKey}. 4) publish_test_definition (target must be ready+). THEN complete_ticket on {targetKey}.",
|
|
1097
|
+
skillRef: "Kein done ohne Testplan: jede user-story/bug braucht eine publizierte, per 'tests'-Link verknuepfte test-definition (SM-299)."
|
|
1098
|
+
}
|
|
1099
|
+
},
|
|
1100
|
+
tool_actions: {
|
|
1101
|
+
publish_test_definition: {
|
|
1102
|
+
gates: ["MISSING_STEPS", "MISSING_TESTS_LINK", "TARGET_NOT_READY", "EMPTY_EXPECTED"]
|
|
1103
|
+
},
|
|
1104
|
+
complete_ticket: {
|
|
1105
|
+
// SM-299: MISSING_TEST_DEFINITION ordered LAST — when a story ALSO
|
|
1106
|
+
// trips STALE_LINKED_DEF/OPEN_MODIFIES, that more-specific gate stays
|
|
1107
|
+
// errors[0] (the one surfaced to the caller).
|
|
1108
|
+
gates: ["STALE_LINKED_DEF", "OPEN_MODIFIES", "MISSING_TEST_DEFINITION"]
|
|
1109
|
+
}
|
|
1110
|
+
},
|
|
1111
|
+
tool_warnings: {}
|
|
1112
|
+
};
|
|
1113
|
+
|
|
1114
|
+
/**
|
|
1115
|
+
* Normalize `project.governance` — accepts caller-supplied overrides, falls
|
|
1116
|
+
* back to STORYMAPPER_DEFAULT_GOVERNANCE when the payload is missing or
|
|
1117
|
+
* structurally incomplete. Idempotent.
|
|
1118
|
+
*/
|
|
1119
|
+
function normalizeGovernance(g) {
|
|
1120
|
+
const fallback = STORYMAPPER_DEFAULT_GOVERNANCE;
|
|
1121
|
+
if (!isObject(g)) return clone(fallback);
|
|
1122
|
+
const out = {
|
|
1123
|
+
gates: (isObject(g.gates)) ? g.gates : clone(fallback.gates),
|
|
1124
|
+
messages: (isObject(g.messages)) ? g.messages : clone(fallback.messages),
|
|
1125
|
+
tool_actions: (isObject(g.tool_actions)) ? g.tool_actions : clone(fallback.tool_actions),
|
|
1126
|
+
tool_warnings: (isObject(g.tool_warnings)) ? g.tool_warnings : {}
|
|
1127
|
+
};
|
|
1128
|
+
return out;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
// ---- Predicate library (closed set of pure functions) ----------------------
|
|
1132
|
+
// Each predicate signature: (snapshot, ticket, args) -> { ok: bool, context?: object }.
|
|
1133
|
+
// `context` is used by the renderer to fill in error messages.
|
|
1134
|
+
|
|
1135
|
+
function _matchCheck(value, check) {
|
|
1136
|
+
if (typeof check === "string") {
|
|
1137
|
+
if (check === "non_empty") {
|
|
1138
|
+
if (value == null) return false;
|
|
1139
|
+
if (typeof value === "string") return value.length > 0;
|
|
1140
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
1141
|
+
return true;
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
if (isObject(check)) {
|
|
1145
|
+
if (check.equals !== undefined) return value === check.equals;
|
|
1146
|
+
if (check.gte !== undefined) return typeof value === "number" && value >= check.gte;
|
|
1147
|
+
if (check.lte !== undefined) return typeof value === "number" && value <= check.lte;
|
|
1148
|
+
if (Array.isArray(check.in)) return check.in.indexOf(value) >= 0;
|
|
1149
|
+
}
|
|
1150
|
+
return false;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
const GOVERNANCE_PREDICATES = {
|
|
1154
|
+
ticket_field(snap, ticket, args) {
|
|
1155
|
+
args = args || {};
|
|
1156
|
+
if (!ticket || !args.field) return { ok: false, context: { reason: "missing field" } };
|
|
1157
|
+
const v = ticket[args.field];
|
|
1158
|
+
return _matchCheck(v, args.check)
|
|
1159
|
+
? { ok: true }
|
|
1160
|
+
: { ok: false, context: { field: args.field, value: v } };
|
|
1161
|
+
},
|
|
1162
|
+
outgoing_link(snap, ticket, args) {
|
|
1163
|
+
args = args || {};
|
|
1164
|
+
const links = (ticket && ticket.links) || [];
|
|
1165
|
+
let matching = links.filter(l => l.linkTypeId === args.linkTypeId);
|
|
1166
|
+
if (args.targetStatus) {
|
|
1167
|
+
matching = matching.filter(l => {
|
|
1168
|
+
const tgt = ((snap && snap.tickets) || []).find(t => t.id === l.targetTicketId);
|
|
1169
|
+
return tgt && tgt.status === args.targetStatus;
|
|
1170
|
+
});
|
|
1171
|
+
}
|
|
1172
|
+
const minCount = args.minCount || 1;
|
|
1173
|
+
return matching.length >= minCount
|
|
1174
|
+
? { ok: true }
|
|
1175
|
+
: { ok: false, context: { linkTypeId: args.linkTypeId, count: matching.length, required: minCount } };
|
|
1176
|
+
},
|
|
1177
|
+
incoming_link(snap, ticket, args) {
|
|
1178
|
+
args = args || {};
|
|
1179
|
+
if (!ticket || !snap || !snap.tickets) return { ok: false, context: { reason: "no snapshot" } };
|
|
1180
|
+
let matching = snap.tickets.filter(t =>
|
|
1181
|
+
!t.isDeleted && // SM-299: a soft-deleted source cannot satisfy the gate
|
|
1182
|
+
(t.links || []).some(l => l.linkTypeId === args.linkTypeId && l.targetTicketId === ticket.id));
|
|
1183
|
+
if (args.sourceStatus) matching = matching.filter(t => t.status === args.sourceStatus);
|
|
1184
|
+
// SM-299: optional source-ticket filters (a test-plan gate wants an
|
|
1185
|
+
// incoming `tests`-link specifically from a PUBLISHED test-definition).
|
|
1186
|
+
if (args.sourceType) matching = matching.filter(t => t.type === args.sourceType);
|
|
1187
|
+
if (args.sourceLifecycle) matching = matching.filter(t => t.lifecycle === args.sourceLifecycle);
|
|
1188
|
+
const minCount = args.minCount || 1;
|
|
1189
|
+
return matching.length >= minCount
|
|
1190
|
+
? { ok: true }
|
|
1191
|
+
: { ok: false, context: { linkTypeId: args.linkTypeId, count: matching.length, required: minCount } };
|
|
1192
|
+
},
|
|
1193
|
+
linked_target_status(snap, ticket, args) {
|
|
1194
|
+
args = args || {};
|
|
1195
|
+
const links = ((ticket && ticket.links) || []).filter(l => l.linkTypeId === args.linkTypeId);
|
|
1196
|
+
if (links.length === 0) return { ok: false, context: { reason: "no outgoing " + args.linkTypeId + "-link" } };
|
|
1197
|
+
const wf = snap && snap.project && snap.project.workflow;
|
|
1198
|
+
const statuses = (wf && Array.isArray(wf.statuses)) ? wf.statuses : [];
|
|
1199
|
+
const minIdx = statuses.findIndex(s => s.id === args.minStatus);
|
|
1200
|
+
if (minIdx < 0) return { ok: true }; // unknown status floor → can't validate, treat as pass
|
|
1201
|
+
for (const l of links) {
|
|
1202
|
+
const tgt = ((snap && snap.tickets) || []).find(t => t.id === l.targetTicketId);
|
|
1203
|
+
if (!tgt) continue;
|
|
1204
|
+
const tgtIdx = statuses.findIndex(s => s.id === tgt.status);
|
|
1205
|
+
if (tgtIdx < minIdx) {
|
|
1206
|
+
return { ok: false, context: {
|
|
1207
|
+
targetKey: tgt.ticketKey, targetStatus: tgt.status, minStatus: args.minStatus
|
|
1208
|
+
}};
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
return { ok: true };
|
|
1212
|
+
},
|
|
1213
|
+
linked_definitions_health(snap, ticket, args) {
|
|
1214
|
+
args = args || {};
|
|
1215
|
+
if (!ticket || !snap || !snap.tickets) return { ok: true };
|
|
1216
|
+
const allowed = new Set(args.allowed || ["passing", "unused"]);
|
|
1217
|
+
const definitions = snap.tickets.filter(t =>
|
|
1218
|
+
t.type === "test-definition" && !t.isDeleted &&
|
|
1219
|
+
(t.links || []).some(l => l.linkTypeId === "tests" && l.targetTicketId === ticket.id));
|
|
1220
|
+
const offending = definitions.filter(d => !allowed.has(d.derivedHealth || "unused"));
|
|
1221
|
+
if (offending.length === 0) return { ok: true };
|
|
1222
|
+
const staleReasons = offending.map(d => d.ticketKey + " is " + (d.derivedHealth || "unused")).join("; ");
|
|
1223
|
+
return { ok: false, context: {
|
|
1224
|
+
stale: offending.map(d => ({ defKey: d.ticketKey, health: d.derivedHealth || "unused" })),
|
|
1225
|
+
staleReasons: staleReasons,
|
|
1226
|
+
count: offending.length
|
|
1227
|
+
}};
|
|
1228
|
+
},
|
|
1229
|
+
incoming_modifies_open(snap, ticket, args) {
|
|
1230
|
+
if (!ticket || !snap || !snap.tickets) return { ok: true };
|
|
1231
|
+
const proj = snap.project;
|
|
1232
|
+
const openMods = snap.tickets.filter(t =>
|
|
1233
|
+
!t.isDeleted && !isTerminalStatus(proj, t.status) && // SM-242: cancelled is terminal too
|
|
1234
|
+
(t.links || []).some(l => l.linkTypeId === "modifies" && l.targetTicketId === ticket.id));
|
|
1235
|
+
if (openMods.length === 0) return { ok: true };
|
|
1236
|
+
return { ok: false, context: {
|
|
1237
|
+
modBlockerKeys: openMods.map(t => t.ticketKey).join(", "),
|
|
1238
|
+
count: openMods.length
|
|
1239
|
+
}};
|
|
1240
|
+
},
|
|
1241
|
+
for_each_step(snap, ticket, args) {
|
|
1242
|
+
args = args || {};
|
|
1243
|
+
const steps = (ticket && ticket.steps) || [];
|
|
1244
|
+
if (steps.length === 0) return { ok: false, context: { reason: "no steps" } };
|
|
1245
|
+
for (const s of steps) {
|
|
1246
|
+
if (!_matchCheck(s[args.field], args.check)) {
|
|
1247
|
+
return { ok: false, context: { field: args.field, stepId: s.id } };
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
return { ok: true };
|
|
1251
|
+
},
|
|
1252
|
+
for_each_prereq(snap, ticket, args) {
|
|
1253
|
+
args = args || {};
|
|
1254
|
+
const prereqs = (ticket && ticket.prerequisites) || [];
|
|
1255
|
+
for (const p of prereqs) {
|
|
1256
|
+
if (!_matchCheck(p[args.field], args.check)) {
|
|
1257
|
+
return { ok: false, context: { field: args.field, prereqId: p.id } };
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
return { ok: true };
|
|
1261
|
+
}
|
|
1262
|
+
};
|
|
1263
|
+
|
|
1264
|
+
/**
|
|
1265
|
+
* Run all gates configured for a given tool action. Pure — no side effects.
|
|
1266
|
+
* Returns `{ ok: true }` when every gate passes, otherwise
|
|
1267
|
+
* `{ ok: false, errors: [{kind, predicate, args, context}, ...] }`.
|
|
1268
|
+
*
|
|
1269
|
+
* Unknown error-kinds (referenced in tool_actions but not defined in
|
|
1270
|
+
* governance.gates) are silently skipped — they may be remnants of a stale
|
|
1271
|
+
* config; rejecting them outright would brick the project on schema-drift.
|
|
1272
|
+
*/
|
|
1273
|
+
function evaluateGates(toolAction, snapshot, ticket, governance) {
|
|
1274
|
+
governance = governance || STORYMAPPER_DEFAULT_GOVERNANCE;
|
|
1275
|
+
const action = (governance.tool_actions || {})[toolAction];
|
|
1276
|
+
if (!action || !Array.isArray(action.gates) || action.gates.length === 0) return { ok: true };
|
|
1277
|
+
const errors = [];
|
|
1278
|
+
for (const errorKind of action.gates) {
|
|
1279
|
+
const gateCfg = (governance.gates || {})[errorKind];
|
|
1280
|
+
if (!gateCfg || !gateCfg.predicate) continue;
|
|
1281
|
+
// SM-299: per-gate type scoping. An absent/empty appliesToTypes = every
|
|
1282
|
+
// type (backward compatible); otherwise the gate only evaluates for the
|
|
1283
|
+
// listed ticket types (e.g. MISSING_TEST_DEFINITION → user-story + bug).
|
|
1284
|
+
if (Array.isArray(gateCfg.appliesToTypes) && gateCfg.appliesToTypes.length > 0
|
|
1285
|
+
&& (!ticket || gateCfg.appliesToTypes.indexOf(ticket.type) < 0)) continue;
|
|
1286
|
+
const pred = GOVERNANCE_PREDICATES[gateCfg.predicate];
|
|
1287
|
+
if (!pred) {
|
|
1288
|
+
errors.push({ kind: errorKind, predicate: gateCfg.predicate,
|
|
1289
|
+
args: gateCfg.args || {}, context: { error: "unknown predicate" } });
|
|
1290
|
+
continue;
|
|
1291
|
+
}
|
|
1292
|
+
const result = pred(snapshot, ticket, gateCfg.args || {});
|
|
1293
|
+
if (!result.ok) {
|
|
1294
|
+
errors.push({ kind: errorKind, predicate: gateCfg.predicate,
|
|
1295
|
+
args: gateCfg.args || {}, context: result.context || {} });
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
return errors.length === 0 ? { ok: true } : { ok: false, errors: errors };
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
/**
|
|
1302
|
+
* Same shape as evaluateGates but for soft warnings — never blocks. Reads
|
|
1303
|
+
* governance.tool_warnings[toolAction].gates. Always returns
|
|
1304
|
+
* `{ warnings: [...] }` (possibly empty).
|
|
1305
|
+
*/
|
|
1306
|
+
function evaluateWarnings(toolAction, snapshot, ticket, governance) {
|
|
1307
|
+
governance = governance || STORYMAPPER_DEFAULT_GOVERNANCE;
|
|
1308
|
+
const action = (governance.tool_warnings || {})[toolAction];
|
|
1309
|
+
if (!action || !Array.isArray(action.gates) || action.gates.length === 0) return { warnings: [] };
|
|
1310
|
+
const warnings = [];
|
|
1311
|
+
for (const errorKind of action.gates) {
|
|
1312
|
+
const gateCfg = (governance.gates || {})[errorKind];
|
|
1313
|
+
if (!gateCfg || !gateCfg.predicate) continue;
|
|
1314
|
+
if (Array.isArray(gateCfg.appliesToTypes) && gateCfg.appliesToTypes.length > 0 // SM-299
|
|
1315
|
+
&& (!ticket || gateCfg.appliesToTypes.indexOf(ticket.type) < 0)) continue;
|
|
1316
|
+
const pred = GOVERNANCE_PREDICATES[gateCfg.predicate];
|
|
1317
|
+
if (!pred) continue;
|
|
1318
|
+
const result = pred(snapshot, ticket, gateCfg.args || {});
|
|
1319
|
+
if (!result.ok) {
|
|
1320
|
+
warnings.push({ kind: errorKind, predicate: gateCfg.predicate,
|
|
1321
|
+
args: gateCfg.args || {}, context: result.context || {} });
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
return { warnings: warnings };
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
// ---- Template-Renderer ----------------------------------------------------
|
|
1328
|
+
// Minimal-deps templater for governance.messages. Supports:
|
|
1329
|
+
// {var} — interpolate context[var]
|
|
1330
|
+
// {var | format} — apply formatter (shortdate, join, ticketKeyList, statusName)
|
|
1331
|
+
// {#count > N}...{/count} — conditional based on context.count
|
|
1332
|
+
|
|
1333
|
+
function _formatShortDate(v) {
|
|
1334
|
+
const d = (typeof v === "number") ? new Date(v) : new Date(String(v));
|
|
1335
|
+
if (isNaN(d.getTime())) return String(v);
|
|
1336
|
+
return d.toISOString().slice(0, 10);
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
function _interpolate(str, ctx) {
|
|
1340
|
+
if (typeof str !== "string") return "";
|
|
1341
|
+
ctx = ctx || {};
|
|
1342
|
+
// Step 1: pluralization conditionals.
|
|
1343
|
+
str = str.replace(/\{#count\s*>\s*(\d+)\}([\s\S]*?)\{\/count\}/g, (_m, n, content) =>
|
|
1344
|
+
(Number(ctx.count) || 0) > Number(n) ? content : "");
|
|
1345
|
+
// Step 2: variable interpolation, optional pipe-format.
|
|
1346
|
+
return str.replace(/\{(\w+)(?:\s*\|\s*(\w+))?\}/g, (_m, name, fmt) => {
|
|
1347
|
+
const val = ctx[name];
|
|
1348
|
+
if (val === undefined || val === null) return "";
|
|
1349
|
+
if (fmt === "join") return Array.isArray(val) ? val.join(", ") : String(val);
|
|
1350
|
+
if (fmt === "shortdate") return _formatShortDate(val);
|
|
1351
|
+
if (fmt === "ticketKeyList") return Array.isArray(val) ? val.join(", ") : String(val);
|
|
1352
|
+
if (fmt === "statusName") return String(val);
|
|
1353
|
+
return String(val);
|
|
1354
|
+
});
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
/**
|
|
1358
|
+
* Render a message-template (one of governance.messages[errorKind]) with the
|
|
1359
|
+
* provided context. Returns `{title, reason, suggestion, skillRef}` — every
|
|
1360
|
+
* field is always a string (empty when the template field is missing).
|
|
1361
|
+
*/
|
|
1362
|
+
function renderMessage(template, context) {
|
|
1363
|
+
template = template || {};
|
|
1364
|
+
return {
|
|
1365
|
+
title: _interpolate(template.title || "", context),
|
|
1366
|
+
reason: _interpolate(template.reason || "", context),
|
|
1367
|
+
suggestion: _interpolate(template.suggestion || "", context),
|
|
1368
|
+
skillRef: _interpolate(template.skillRef || "", context)
|
|
1369
|
+
};
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
// ---- derivedHealth for test-definition tickets ----------------------------
|
|
1373
|
+
// Computed at snapshot-load time (post-pass in normalizeSnapshot). Not
|
|
1374
|
+
// persisted on the ticket — pure derivation from the link graph + execution
|
|
1375
|
+
// history. Five states:
|
|
1376
|
+
// unused — no execution yet
|
|
1377
|
+
// passing — latest execution passed, no spec change since
|
|
1378
|
+
// failing — latest execution failed or blocked
|
|
1379
|
+
// stale — latest run was green but definition/target updated since
|
|
1380
|
+
// orphaned — every linked target is soft-deleted or missing
|
|
1381
|
+
|
|
1382
|
+
function computeDerivedHealth(snap, td) {
|
|
1383
|
+
if (!td || td.type !== "test-definition") return "";
|
|
1384
|
+
const tickets = (snap && snap.tickets) || [];
|
|
1385
|
+
const targetLinks = (td.links || []).filter(l => l.linkTypeId === "tests");
|
|
1386
|
+
// Resolve targets — only non-deleted, existing tickets count as "live".
|
|
1387
|
+
let liveTargets = 0;
|
|
1388
|
+
let liveTargetMaxUpdatedAt = 0;
|
|
1389
|
+
let anyTargetLink = targetLinks.length > 0;
|
|
1390
|
+
for (const l of targetLinks) {
|
|
1391
|
+
const tgt = tickets.find(t => t.id === l.targetTicketId);
|
|
1392
|
+
if (!tgt || tgt.isDeleted) continue;
|
|
1393
|
+
liveTargets++;
|
|
1394
|
+
if ((tgt.updatedAt || 0) > liveTargetMaxUpdatedAt) liveTargetMaxUpdatedAt = (tgt.updatedAt || 0);
|
|
1395
|
+
}
|
|
1396
|
+
if (anyTargetLink && liveTargets === 0) return "orphaned";
|
|
1397
|
+
|
|
1398
|
+
const executions = tickets.filter(t =>
|
|
1399
|
+
t.type === "test-execution" && !t.isDeleted && t.referencedTestDefinitionId === td.id);
|
|
1400
|
+
if (executions.length === 0) return "unused";
|
|
1401
|
+
const latest = executions.slice().sort((a, b) => (b.runAt || 0) - (a.runAt || 0))[0];
|
|
1402
|
+
const outcome = getEffectiveOutcome(latest);
|
|
1403
|
+
if (outcome === "failed" || outcome === "blocked") return "failing";
|
|
1404
|
+
if (outcome === "passed") {
|
|
1405
|
+
const lastRun = latest.runAt || 0;
|
|
1406
|
+
if ((td.updatedAt || 0) > lastRun) return "stale";
|
|
1407
|
+
if (liveTargetMaxUpdatedAt > lastRun) return "stale";
|
|
1408
|
+
return "passing";
|
|
1409
|
+
}
|
|
1410
|
+
// pending / skipped — no green run on record, treat as unused-equivalent.
|
|
1411
|
+
return "unused";
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
function _annotateDerivedHealth(snap) {
|
|
1415
|
+
const tickets = (snap && snap.tickets) || [];
|
|
1416
|
+
for (const td of tickets) {
|
|
1417
|
+
if (td.type !== "test-definition") continue;
|
|
1418
|
+
td.derivedHealth = computeDerivedHealth(snap, td);
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
// ---------------------------------------------------------------------------
|
|
1423
|
+
|
|
1424
|
+
// SM-196 R-1: a requirement's back-reference into its source PRD. Points at
|
|
1425
|
+
// the project attachment + (once the Markdown pre-slicer runs, R-3/R-4) the
|
|
1426
|
+
// section id and char span that produced this slice — the anchor that powers
|
|
1427
|
+
// the reconciliation UI (R-7). Tolerates partial/missing input; null when no
|
|
1428
|
+
// anchor is set (the common case until a slice is imported).
|
|
1429
|
+
function normalizeSourceAnchor(a) {
|
|
1430
|
+
if (!isObject(a)) return null;
|
|
1431
|
+
return {
|
|
1432
|
+
attachmentId: typeof a.attachmentId === "string" ? a.attachmentId : "",
|
|
1433
|
+
sectionId: typeof a.sectionId === "string" ? a.sectionId : "",
|
|
1434
|
+
charStart: typeof a.charStart === "number" ? a.charStart : null,
|
|
1435
|
+
charEnd: typeof a.charEnd === "number" ? a.charEnd : null
|
|
1436
|
+
};
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
function normalizeTicket(t) {
|
|
1440
|
+
t = t || {};
|
|
1441
|
+
const createdBy = isObject(t.createdBy) ? normalizeActor(t.createdBy) : { type: "human", id: "unknown", name: "Unknown" };
|
|
1442
|
+
const createdAt = typeof t.createdAt === "number" ? t.createdAt : now();
|
|
1443
|
+
const type = typeof t.type === "string" ? t.type : "user-story";
|
|
1444
|
+
|
|
1445
|
+
// SM-196 R-1: spec-layer fields. `sectionPath` is the DOORS-style object
|
|
1446
|
+
// identifier ("2.1") that orders requirements within their spec module;
|
|
1447
|
+
// `sourceAnchor` points back to the source PRD section. Gated to the
|
|
1448
|
+
// requirement type so every other ticket keeps a uniform empty shape.
|
|
1449
|
+
const sectionPath = (type === "requirement" && typeof t.sectionPath === "string") ? t.sectionPath : "";
|
|
1450
|
+
const sourceAnchor = (type === "requirement") ? normalizeSourceAnchor(t.sourceAnchor) : null;
|
|
1451
|
+
// SM-196 R-2: a spec-module is bound to exactly one source attachment (the
|
|
1452
|
+
// PRD it slices). Gated to the spec-module type; "" otherwise.
|
|
1453
|
+
const sourceAttachmentId = (type === "spec-module" && typeof t.sourceAttachmentId === "string") ? t.sourceAttachmentId : "";
|
|
1454
|
+
|
|
1455
|
+
// SM-53 / SM-56 — type-specific test fields. Stored on every ticket but
|
|
1456
|
+
// only populated for the matching type; non-test tickets carry empty
|
|
1457
|
+
// arrays so the JSON shape stays uniform (cheap, simplifies all readers).
|
|
1458
|
+
const prerequisites = (type === "test-definition" && Array.isArray(t.prerequisites))
|
|
1459
|
+
? t.prerequisites.map(normalizeTestPrerequisite) : [];
|
|
1460
|
+
const steps = (type === "test-definition" && Array.isArray(t.steps))
|
|
1461
|
+
? t.steps.map(normalizeTestStep) : [];
|
|
1462
|
+
const executionSteps = (type === "test-execution" && Array.isArray(t.executionSteps))
|
|
1463
|
+
? t.executionSteps.map(normalizeTestExecStep) : [];
|
|
1464
|
+
const refDefId = (type === "test-execution" && typeof t.referencedTestDefinitionId === "string")
|
|
1465
|
+
? t.referencedTestDefinitionId : "";
|
|
1466
|
+
const runAt = (type === "test-execution" && typeof t.runAt === "number") ? t.runAt : null;
|
|
1467
|
+
const runBy = (type === "test-execution" && isObject(t.runBy)) ? normalizeActor(t.runBy) : null;
|
|
1468
|
+
const env = (type === "test-execution" && typeof t.env === "string") ? t.env : "";
|
|
1469
|
+
const outcomeOverride = (type === "test-execution"
|
|
1470
|
+
&& typeof t.outcomeOverride === "string"
|
|
1471
|
+
&& TEST_EXEC_OUTCOMES.indexOf(t.outcomeOverride) >= 0)
|
|
1472
|
+
? t.outcomeOverride : null;
|
|
1473
|
+
|
|
1474
|
+
// SM-93: lifecycle for test-definition only. draft = still being authored,
|
|
1475
|
+
// published = locked-in spec runnable via test_exec_start. Default = draft.
|
|
1476
|
+
// Other types get a stable "" so the JSON shape stays uniform.
|
|
1477
|
+
const lifecycle = (type === "test-definition")
|
|
1478
|
+
? (TEST_DEFINITION_LIFECYCLES.indexOf(t.lifecycle) >= 0 ? t.lifecycle : "draft")
|
|
1479
|
+
: "";
|
|
1480
|
+
|
|
1481
|
+
return {
|
|
1482
|
+
id: typeof t.id === "string" ? t.id : uid("t-"),
|
|
1483
|
+
projectId: typeof t.projectId === "string" ? t.projectId : "",
|
|
1484
|
+
type: type,
|
|
1485
|
+
ticketKey: typeof t.ticketKey === "string" ? t.ticketKey : "",
|
|
1486
|
+
title: typeof t.title === "string" ? t.title : "",
|
|
1487
|
+
description: typeof t.description === "string" ? t.description : "",
|
|
1488
|
+
status: typeof t.status === "string" ? t.status : "backlog",
|
|
1489
|
+
position: normalizePosition(t.position),
|
|
1490
|
+
// SM-196 — spec-layer fields (requirement: sectionPath/sourceAnchor;
|
|
1491
|
+
// spec-module: sourceAttachmentId). Empty/null on every other type.
|
|
1492
|
+
sectionPath: sectionPath,
|
|
1493
|
+
sourceAnchor: sourceAnchor,
|
|
1494
|
+
sourceAttachmentId: sourceAttachmentId,
|
|
1495
|
+
definitionOfReady: normalizeChecklist(t.definitionOfReady),
|
|
1496
|
+
definitionOfDone: normalizeChecklist(t.definitionOfDone),
|
|
1497
|
+
acceptanceCriteria: Array.isArray(t.acceptanceCriteria) ? t.acceptanceCriteria.map(normalizeAcceptanceCriterion) : [],
|
|
1498
|
+
comments: Array.isArray(t.comments) ? t.comments.map(normalizeComment) : [],
|
|
1499
|
+
labels: Array.isArray(t.labels) ? t.labels.slice() : [],
|
|
1500
|
+
links: Array.isArray(t.links) ? t.links.map(normalizeLink) : [],
|
|
1501
|
+
// SM-53 / SM-56 — test-type fields.
|
|
1502
|
+
prerequisites: prerequisites,
|
|
1503
|
+
steps: steps,
|
|
1504
|
+
executionSteps: executionSteps,
|
|
1505
|
+
referencedTestDefinitionId: refDefId,
|
|
1506
|
+
runAt: runAt,
|
|
1507
|
+
runBy: runBy,
|
|
1508
|
+
env: env,
|
|
1509
|
+
outcomeOverride: outcomeOverride,
|
|
1510
|
+
// SM-93 — test-definition lifecycle + derived health (the latter gets
|
|
1511
|
+
// recomputed in normalizeSnapshot's post-pass, so the per-ticket field
|
|
1512
|
+
// here just carries the persisted hint or "" until then).
|
|
1513
|
+
lifecycle: lifecycle,
|
|
1514
|
+
derivedHealth: typeof t.derivedHealth === "string" ? t.derivedHealth : "",
|
|
1515
|
+
isDeleted: t.isDeleted === true,
|
|
1516
|
+
deletedAt: typeof t.deletedAt === "number" ? t.deletedAt : null,
|
|
1517
|
+
deletedBy: isObject(t.deletedBy) ? normalizeActor(t.deletedBy) : null,
|
|
1518
|
+
createdAt: createdAt,
|
|
1519
|
+
createdBy: createdBy,
|
|
1520
|
+
updatedAt: typeof t.updatedAt === "number" ? t.updatedAt : createdAt,
|
|
1521
|
+
updatedBy: isObject(t.updatedBy) ? normalizeActor(t.updatedBy) : createdBy,
|
|
1522
|
+
// SM-170 (Card-Aging): when the ticket last ENTERED its current status.
|
|
1523
|
+
// Distinct from updatedAt (which bumps on any edit) — this only resets on
|
|
1524
|
+
// an actual status transition. Legacy tickets fall back to createdAt.
|
|
1525
|
+
statusEnteredAt: typeof t.statusEnteredAt === "number" ? t.statusEnteredAt : createdAt,
|
|
1526
|
+
version: typeof t.version === "number" ? t.version : 1
|
|
1527
|
+
};
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
function normalizeRelease(r) {
|
|
1531
|
+
r = r || {};
|
|
1532
|
+
const createdBy = isObject(r.createdBy) ? normalizeActor(r.createdBy) : { type: "human", id: "unknown", name: "Unknown" };
|
|
1533
|
+
const createdAt = typeof r.createdAt === "number" ? r.createdAt : now();
|
|
1534
|
+
return {
|
|
1535
|
+
id: typeof r.id === "string" ? r.id : uid("r-"),
|
|
1536
|
+
projectId: typeof r.projectId === "string" ? r.projectId : "",
|
|
1537
|
+
name: typeof r.name === "string" ? r.name : "",
|
|
1538
|
+
description: typeof r.description === "string" ? r.description : "",
|
|
1539
|
+
startDate: typeof r.startDate === "string" || typeof r.startDate === "number" ? r.startDate : null,
|
|
1540
|
+
endDate: typeof r.endDate === "string" || typeof r.endDate === "number" ? r.endDate : null,
|
|
1541
|
+
status: DEFAULT_RELEASE_STATUSES.indexOf(r.status) >= 0 ? r.status : "planning",
|
|
1542
|
+
sortOrder: typeof r.sortOrder === "number" ? r.sortOrder : 0,
|
|
1543
|
+
isDeleted: r.isDeleted === true,
|
|
1544
|
+
deletedAt: typeof r.deletedAt === "number" ? r.deletedAt : null,
|
|
1545
|
+
deletedBy: isObject(r.deletedBy) ? normalizeActor(r.deletedBy) : null,
|
|
1546
|
+
createdAt: createdAt,
|
|
1547
|
+
createdBy: createdBy,
|
|
1548
|
+
updatedAt: typeof r.updatedAt === "number" ? r.updatedAt : createdAt,
|
|
1549
|
+
updatedBy: isObject(r.updatedBy) ? normalizeActor(r.updatedBy) : createdBy,
|
|
1550
|
+
version: typeof r.version === "number" ? r.version : 1
|
|
1551
|
+
};
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
function normalizeProcessStep(s) {
|
|
1555
|
+
s = s || {};
|
|
1556
|
+
const createdBy = isObject(s.createdBy) ? normalizeActor(s.createdBy) : { type: "human", id: "unknown", name: "Unknown" };
|
|
1557
|
+
const createdAt = typeof s.createdAt === "number" ? s.createdAt : now();
|
|
1558
|
+
return {
|
|
1559
|
+
id: typeof s.id === "string" ? s.id : uid("ps-"),
|
|
1560
|
+
projectId: typeof s.projectId === "string" ? s.projectId : "",
|
|
1561
|
+
name: typeof s.name === "string" ? s.name : "",
|
|
1562
|
+
description: typeof s.description === "string" ? s.description : "",
|
|
1563
|
+
epicId: typeof s.epicId === "string" ? s.epicId : null,
|
|
1564
|
+
sortOrder: typeof s.sortOrder === "number" ? s.sortOrder : 0,
|
|
1565
|
+
isDeleted: s.isDeleted === true,
|
|
1566
|
+
deletedAt: typeof s.deletedAt === "number" ? s.deletedAt : null,
|
|
1567
|
+
deletedBy: isObject(s.deletedBy) ? normalizeActor(s.deletedBy) : null,
|
|
1568
|
+
createdAt: createdAt,
|
|
1569
|
+
createdBy: createdBy,
|
|
1570
|
+
updatedAt: typeof s.updatedAt === "number" ? s.updatedAt : createdAt,
|
|
1571
|
+
updatedBy: isObject(s.updatedBy) ? normalizeActor(s.updatedBy) : createdBy,
|
|
1572
|
+
version: typeof s.version === "number" ? s.version : 1
|
|
1573
|
+
};
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
function normalizeSnapshot(snap) {
|
|
1577
|
+
snap = snap || {};
|
|
1578
|
+
const out = {
|
|
1579
|
+
version: SCHEMA_VERSION,
|
|
1580
|
+
project: normalizeProject(snap.project),
|
|
1581
|
+
tickets: Array.isArray(snap.tickets) ? snap.tickets.map(normalizeTicket) : [],
|
|
1582
|
+
releases: Array.isArray(snap.releases) ? snap.releases.map(normalizeRelease) : [],
|
|
1583
|
+
processSteps: Array.isArray(snap.processSteps) ? snap.processSteps.map(normalizeProcessStep) : []
|
|
1584
|
+
};
|
|
1585
|
+
// SM-44: post-process tickets — drop ticket.links whose targetTicketId no
|
|
1586
|
+
// longer exists in the snapshot (stale-target cleanup). This keeps the
|
|
1587
|
+
// model self-consistent even after a referenced ticket is hard-deleted.
|
|
1588
|
+
// Soft-deleted (isDeleted=true) tickets STILL count as valid targets so
|
|
1589
|
+
// restoring them preserves their links.
|
|
1590
|
+
const validIds = new Set(out.tickets.map(t => t.id));
|
|
1591
|
+
for (const t of out.tickets) {
|
|
1592
|
+
if (t.links && t.links.length > 0) {
|
|
1593
|
+
t.links = t.links.filter(l => validIds.has(l.targetTicketId));
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
// SM-52: migrate legacy ticket.position.epicId → outgoing contains-link
|
|
1597
|
+
// from the parent epic. Idempotent: if the link already exists, just
|
|
1598
|
+
// clear the legacy field.
|
|
1599
|
+
migrateEpicContainsLinks(out);
|
|
1600
|
+
// SM-78: defensive cleanup — null any ticket.position.processStepId or
|
|
1601
|
+
// releaseId that no longer references a live (non-soft-deleted) entry.
|
|
1602
|
+
// Without this, stale positions left over from a deleted PS/Release make
|
|
1603
|
+
// the ticket invisible on the Map (no matching cell) while still visible
|
|
1604
|
+
// on the Kanban (status-based) — a confusing inconsistency. Runs BEFORE
|
|
1605
|
+
// SM-67's sync-pass so that a stale epic position doesn't get cascaded
|
|
1606
|
+
// to its contained stories. Idempotent.
|
|
1607
|
+
pruneStaleContainerRefs(out);
|
|
1608
|
+
// SM-67: sync-pass — any contained story whose releaseId/processStepId
|
|
1609
|
+
// diverged from its container epic gets pulled back into alignment.
|
|
1610
|
+
// Idempotent: aligned stories are untouched.
|
|
1611
|
+
syncContainedStoryPositions(out);
|
|
1612
|
+
// SM-236: derive every epic's status from its contained stories. Runs after
|
|
1613
|
+
// the contains-link migration + position sync so the children are final.
|
|
1614
|
+
// This is the MIGRATION pass for legacy data (epics manually walked to a
|
|
1615
|
+
// status that no longer matches their stories) and the safety net behind
|
|
1616
|
+
// the per-op recompute calls (the SM-220 invariance test is the
|
|
1617
|
+
// completeness check that no mutating op forgets to call it).
|
|
1618
|
+
recomputeEpicStatuses(out);
|
|
1619
|
+
// SM-93: annotate every test-definition with its derivedHealth, based on
|
|
1620
|
+
// the link-graph + execution history. Runs LAST so all link / position /
|
|
1621
|
+
// execution data is in its final form.
|
|
1622
|
+
_annotateDerivedHealth(out);
|
|
1623
|
+
return out;
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
/**
|
|
1627
|
+
* SM-78: defensive pass that clears stale ticket.position.processStepId /
|
|
1628
|
+
* releaseId references. A reference is "stale" when the target Release /
|
|
1629
|
+
* ProcessStep is either hard-removed from the snapshot or soft-deleted
|
|
1630
|
+
* (isDeleted=true). Cleared fields land in the Backlog, which is at least
|
|
1631
|
+
* VISIBLE — better than the silent disappearance from the Map cells.
|
|
1632
|
+
* Idempotent.
|
|
1633
|
+
*/
|
|
1634
|
+
function pruneStaleContainerRefs(snap) {
|
|
1635
|
+
const tickets = snap.tickets || [];
|
|
1636
|
+
if (!tickets.length) return;
|
|
1637
|
+
const liveReleaseIds = new Set();
|
|
1638
|
+
for (const r of (snap.releases || [])) {
|
|
1639
|
+
if (r && r.id && !r.isDeleted) liveReleaseIds.add(r.id);
|
|
1640
|
+
}
|
|
1641
|
+
const livePsIds = new Set();
|
|
1642
|
+
for (const ps of (snap.processSteps || [])) {
|
|
1643
|
+
if (ps && ps.id && !ps.isDeleted) livePsIds.add(ps.id);
|
|
1644
|
+
}
|
|
1645
|
+
for (const t of tickets) {
|
|
1646
|
+
const p = t.position;
|
|
1647
|
+
if (!p) continue;
|
|
1648
|
+
const curRel = p.releaseId || null;
|
|
1649
|
+
const curPs = p.processStepId || null;
|
|
1650
|
+
const newRel = (curRel && !liveReleaseIds.has(curRel)) ? null : curRel;
|
|
1651
|
+
const newPs = (curPs && !livePsIds.has(curPs)) ? null : curPs;
|
|
1652
|
+
if (newRel === curRel && newPs === curPs) continue;
|
|
1653
|
+
t.position = normalizePosition(Object.assign({}, p, {
|
|
1654
|
+
releaseId: newRel,
|
|
1655
|
+
processStepId: newPs
|
|
1656
|
+
}));
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
/**
|
|
1661
|
+
* SM-67: idempotent pass that aligns every contained story's release+
|
|
1662
|
+
* processStep to its container epic's. Runs after migration so legacy
|
|
1663
|
+
* snapshots with mis-aligned positions get repaired on load.
|
|
1664
|
+
*/
|
|
1665
|
+
function syncContainedStoryPositions(snap) {
|
|
1666
|
+
const tickets = snap.tickets || [];
|
|
1667
|
+
if (!tickets.length) return;
|
|
1668
|
+
const byId = new Map(tickets.map(t => [t.id, t]));
|
|
1669
|
+
for (const epic of tickets) {
|
|
1670
|
+
if (epic.type !== "epic" || epic.isDeleted || !epic.links || !epic.links.length) continue;
|
|
1671
|
+
if (!epic.position) continue;
|
|
1672
|
+
const epicRel = epic.position.releaseId || null;
|
|
1673
|
+
const epicPs = epic.position.processStepId || null;
|
|
1674
|
+
for (const l of epic.links) {
|
|
1675
|
+
const lt = l.linkTypeId || l.type;
|
|
1676
|
+
if (lt !== CONTAINS_LINK_TYPE_ID) continue;
|
|
1677
|
+
const story = byId.get(l.targetTicketId);
|
|
1678
|
+
if (!story || story.isDeleted) continue;
|
|
1679
|
+
const sp = story.position || {};
|
|
1680
|
+
if ((sp.releaseId || null) === epicRel && (sp.processStepId || null) === epicPs) continue;
|
|
1681
|
+
story.position = normalizePosition(Object.assign({}, sp, {
|
|
1682
|
+
releaseId: epicRel,
|
|
1683
|
+
processStepId: epicPs
|
|
1684
|
+
}));
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
// SM-52 helpers ------------------------------------------------------------
|
|
1690
|
+
|
|
1691
|
+
/**
|
|
1692
|
+
* Migrate every ticket carrying a non-null `position.epicId` to the canonical
|
|
1693
|
+
* representation: an outgoing `contains` link from the epic, with the legacy
|
|
1694
|
+
* field cleared. Idempotent — running multiple times on an already-migrated
|
|
1695
|
+
* snapshot is a no-op (the link check prevents duplicates).
|
|
1696
|
+
*/
|
|
1697
|
+
function migrateEpicContainsLinks(snap) {
|
|
1698
|
+
const tickets = snap.tickets || [];
|
|
1699
|
+
if (!tickets.length) return;
|
|
1700
|
+
const byId = new Map(tickets.map(t => [t.id, t]));
|
|
1701
|
+
for (const t of tickets) {
|
|
1702
|
+
const epicId = t.position && t.position.epicId;
|
|
1703
|
+
if (!epicId) continue;
|
|
1704
|
+
const epic = byId.get(epicId);
|
|
1705
|
+
if (epic && epic.id !== t.id && epic.type === "epic") {
|
|
1706
|
+
_ensureContainsLink(snap, epic.id, t.id, t.createdBy);
|
|
1707
|
+
}
|
|
1708
|
+
// Always clear — the link is canonical; if the epic doesn't exist
|
|
1709
|
+
// anymore we drop the legacy pointer silently.
|
|
1710
|
+
t.position = Object.assign({}, t.position, { epicId: null });
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
/**
|
|
1715
|
+
* Ensure that `epicId` has an outgoing `contains` link to `targetId`. No-op
|
|
1716
|
+
* if the link already exists. Returns true if added, false if pre-existing.
|
|
1717
|
+
*/
|
|
1718
|
+
function _ensureContainsLink(snap, epicId, targetId, actor) {
|
|
1719
|
+
if (!epicId || !targetId || epicId === targetId) return false;
|
|
1720
|
+
// Read first (cheap), COW only when we actually add the link (SM-240).
|
|
1721
|
+
const probe = (snap.tickets || []).find(t => t.id === epicId);
|
|
1722
|
+
if (!probe) return false;
|
|
1723
|
+
for (const l of (probe.links || [])) {
|
|
1724
|
+
const lt = l.linkTypeId || l.type;
|
|
1725
|
+
if (lt === CONTAINS_LINK_TYPE_ID && l.targetTicketId === targetId) return false;
|
|
1726
|
+
}
|
|
1727
|
+
const epic = findTicket(snap, epicId); // COW-aware on cow snapshots
|
|
1728
|
+
epic.links = epic.links || [];
|
|
1729
|
+
const a = isObject(actor) ? normalizeActor(actor) : { type: "system", id: "sm-52", name: "SM-52 migration" };
|
|
1730
|
+
epic.links.push(normalizeLink({
|
|
1731
|
+
linkTypeId: CONTAINS_LINK_TYPE_ID,
|
|
1732
|
+
targetTicketId: targetId,
|
|
1733
|
+
createdBy: a
|
|
1734
|
+
}));
|
|
1735
|
+
return true;
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
/**
|
|
1739
|
+
* Strip any incoming `contains` link to `ticketId`. Used when re-parenting
|
|
1740
|
+
* a ticket or removing it from its epic container.
|
|
1741
|
+
*
|
|
1742
|
+
* SM-166: when `actor` is supplied, every epic that actually loses a link is
|
|
1743
|
+
* bumpAudit'd — a containment change is a real edit to that epic and must be
|
|
1744
|
+
* versioned + visible (previously it was silent: no version bump on the epic).
|
|
1745
|
+
*/
|
|
1746
|
+
function _removeContainsLinksTo(snap, ticketId, actor) {
|
|
1747
|
+
// Iterate a snapshot of the array — cowTicket swaps entries in place.
|
|
1748
|
+
for (const t of (snap.tickets || []).slice()) {
|
|
1749
|
+
if (t.type !== "epic" || !t.links || !t.links.length) continue;
|
|
1750
|
+
const hasLink = t.links.some(l => {
|
|
1751
|
+
const lt = l.linkTypeId || l.type;
|
|
1752
|
+
return lt === CONTAINS_LINK_TYPE_ID && l.targetTicketId === ticketId;
|
|
1753
|
+
});
|
|
1754
|
+
if (!hasLink) continue;
|
|
1755
|
+
const epic = findTicket(snap, t.id); // COW-aware (SM-240)
|
|
1756
|
+
epic.links = epic.links.filter(l => {
|
|
1757
|
+
const lt = l.linkTypeId || l.type;
|
|
1758
|
+
return !(lt === CONTAINS_LINK_TYPE_ID && l.targetTicketId === ticketId);
|
|
1759
|
+
});
|
|
1760
|
+
if (actor) bumpAudit(epic, actor);
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
/**
|
|
1765
|
+
* Re-parent `ticketId` so its container is `newEpicId` (or none, if null).
|
|
1766
|
+
* Strips any existing inbound contains-link first to guarantee uniqueness.
|
|
1767
|
+
* SM-166: bumpAudit fires on both the old container (via _removeContainsLinksTo)
|
|
1768
|
+
* and the new container (here) so the re-parent is reflected in their versions.
|
|
1769
|
+
*/
|
|
1770
|
+
function _setContainerEpic(snap, ticketId, newEpicId, actor) {
|
|
1771
|
+
_removeContainsLinksTo(snap, ticketId, actor);
|
|
1772
|
+
if (newEpicId) {
|
|
1773
|
+
const added = _ensureContainsLink(snap, newEpicId, ticketId, actor);
|
|
1774
|
+
if (added && actor) {
|
|
1775
|
+
const epic = findTicket(snap, newEpicId); // COW-aware (SM-240)
|
|
1776
|
+
if (epic) bumpAudit(epic, actor);
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
/**
|
|
1782
|
+
* Map<storyId, epicId> — which epic currently contains each story (via
|
|
1783
|
+
* inbound contains-link). Used by read-side helpers.
|
|
1784
|
+
*/
|
|
1785
|
+
function _containerEpicByStoryId(snap) {
|
|
1786
|
+
const map = new Map();
|
|
1787
|
+
for (const t of (snap.tickets || [])) {
|
|
1788
|
+
if (t.type !== "epic" || t.isDeleted) continue;
|
|
1789
|
+
if (!t.links || !t.links.length) continue;
|
|
1790
|
+
for (const l of t.links) {
|
|
1791
|
+
const lt = l.linkTypeId || l.type;
|
|
1792
|
+
if (lt === CONTAINS_LINK_TYPE_ID) map.set(l.targetTicketId, t.id);
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
return map;
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
/**
|
|
1799
|
+
* Public: which epic currently contains the given ticket, via outbound
|
|
1800
|
+
* `contains` link? Returns the epic-id or `null`. SM-52: this is the
|
|
1801
|
+
* canonical source for the Epic-parent — `ticket.position.epicId` is
|
|
1802
|
+
* stripped to null on every write, so do NOT read it for display either.
|
|
1803
|
+
*
|
|
1804
|
+
* Bug context (SM-77-followup): the Epic-dropdown in the ticket-detail
|
|
1805
|
+
* modal used to read `ticket.position.epicId` directly. That value is
|
|
1806
|
+
* always null after SM-52, so the dropdown showed "— none —" even when
|
|
1807
|
+
* the ticket was contained. Worse, the no-op-save path then persisted
|
|
1808
|
+
* the null choice and dropped the contains-link silently. Always use
|
|
1809
|
+
* this helper to display + diff the current container.
|
|
1810
|
+
*/
|
|
1811
|
+
function containerEpicIdOf(snap, ticketId) {
|
|
1812
|
+
if (!snap || !ticketId) return null;
|
|
1813
|
+
for (const t of (snap.tickets || [])) {
|
|
1814
|
+
if (t.type !== "epic" || t.isDeleted || !t.links) continue;
|
|
1815
|
+
for (const l of t.links) {
|
|
1816
|
+
const lt = l.linkTypeId || l.type;
|
|
1817
|
+
if (lt === CONTAINS_LINK_TYPE_ID && l.targetTicketId === ticketId) return t.id;
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
return null;
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
// SM-67 helpers ------------------------------------------------------------
|
|
1824
|
+
|
|
1825
|
+
/**
|
|
1826
|
+
* SM-67: a contained story inherits its containerEpic's releaseId+processStepId.
|
|
1827
|
+
* Given an input position-patch and a target container-epic-id, return a
|
|
1828
|
+
* position object where releaseId+processStepId are forced to the epic's
|
|
1829
|
+
* values (other fields — sortOrder — kept from input). If the epic doesn't
|
|
1830
|
+
* exist or has no position, the input is returned unchanged.
|
|
1831
|
+
*
|
|
1832
|
+
* Pure: does not mutate `inputPos`.
|
|
1833
|
+
*/
|
|
1834
|
+
function _inheritPositionFromEpic(snap, containerEpicId, inputPos) {
|
|
1835
|
+
const pos = Object.assign({}, inputPos || {});
|
|
1836
|
+
if (!containerEpicId) return pos;
|
|
1837
|
+
const epic = (snap.tickets || []).find(t =>
|
|
1838
|
+
t.id === containerEpicId && t.type === "epic" && !t.isDeleted);
|
|
1839
|
+
if (!epic || !epic.position) return pos;
|
|
1840
|
+
pos.releaseId = epic.position.releaseId || null;
|
|
1841
|
+
pos.processStepId = epic.position.processStepId || null;
|
|
1842
|
+
return pos;
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
/**
|
|
1846
|
+
* SM-67: when an epic's release/processStep changes, every story contained
|
|
1847
|
+
* via `contains` link cascades to the new position (release+processStep
|
|
1848
|
+
* only; sortOrder is preserved per story). bumpAudit fires for each
|
|
1849
|
+
* touched story so the audit trail reflects the cascade.
|
|
1850
|
+
*/
|
|
1851
|
+
function _cascadeEpicPositionToContainedStories(snap, epicId, actor) {
|
|
1852
|
+
const epic = (snap.tickets || []).find(t =>
|
|
1853
|
+
t.id === epicId && t.type === "epic" && !t.isDeleted);
|
|
1854
|
+
if (!epic || !epic.position || !epic.links || !epic.links.length) return;
|
|
1855
|
+
const epicRel = epic.position.releaseId || null;
|
|
1856
|
+
const epicPs = epic.position.processStepId || null;
|
|
1857
|
+
for (const l of epic.links) {
|
|
1858
|
+
const lt = l.linkTypeId || l.type;
|
|
1859
|
+
if (lt !== CONTAINS_LINK_TYPE_ID) continue;
|
|
1860
|
+
const probe = (snap.tickets || []).find(t => t.id === l.targetTicketId);
|
|
1861
|
+
if (!probe || probe.isDeleted) continue;
|
|
1862
|
+
const sp = probe.position || {};
|
|
1863
|
+
if ((sp.releaseId || null) === epicRel && (sp.processStepId || null) === epicPs) continue;
|
|
1864
|
+
const story = findTicket(snap, probe.id); // COW-aware (SM-240)
|
|
1865
|
+
story.position = normalizePosition(Object.assign({}, story.position || {}, {
|
|
1866
|
+
releaseId: epicRel,
|
|
1867
|
+
processStepId: epicPs
|
|
1868
|
+
}));
|
|
1869
|
+
bumpAudit(story, actor);
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
// ---------------------------------------------------------------------------
|
|
1874
|
+
// resolveDefinitions + buildTicketChecklists
|
|
1875
|
+
// ---------------------------------------------------------------------------
|
|
1876
|
+
|
|
1877
|
+
function resolveBlock(block, ticketType) {
|
|
1878
|
+
block = block || { global: [], byType: {} };
|
|
1879
|
+
const byType = (block.byType && block.byType[ticketType]) || null;
|
|
1880
|
+
if (byType && Array.isArray(byType.overridden)) {
|
|
1881
|
+
return byType.overridden.map(it => Object.assign({}, it));
|
|
1882
|
+
}
|
|
1883
|
+
const list = (block.global || []).map(it => Object.assign({}, it));
|
|
1884
|
+
if (byType && Array.isArray(byType.appended)) {
|
|
1885
|
+
for (const it of byType.appended) list.push(Object.assign({}, it));
|
|
1886
|
+
}
|
|
1887
|
+
return list;
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
function resolveDefinitions(definitions, ticketType) {
|
|
1891
|
+
const defs = normalizeDefinitions(definitions);
|
|
1892
|
+
return {
|
|
1893
|
+
ready: resolveBlock(defs.ready, ticketType),
|
|
1894
|
+
done: resolveBlock(defs.done, ticketType)
|
|
1895
|
+
};
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
function freezeChecklist(items) {
|
|
1899
|
+
return {
|
|
1900
|
+
items: items.map(it => ({
|
|
1901
|
+
id: it.id,
|
|
1902
|
+
label: it.label,
|
|
1903
|
+
required: it.required !== false,
|
|
1904
|
+
checked: false,
|
|
1905
|
+
checkedAt: null,
|
|
1906
|
+
checkedBy: null
|
|
1907
|
+
}))
|
|
1908
|
+
};
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
function buildTicketChecklists(project, ticketPartial) {
|
|
1912
|
+
const resolved = resolveDefinitions(project.definitions, (ticketPartial && ticketPartial.type) || "user-story");
|
|
1913
|
+
return {
|
|
1914
|
+
definitionOfReady: freezeChecklist(resolved.ready),
|
|
1915
|
+
definitionOfDone: freezeChecklist(resolved.done)
|
|
1916
|
+
};
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
// ---------------------------------------------------------------------------
|
|
1920
|
+
// ops — pure (snapshot, args, actor) → newSnapshot
|
|
1921
|
+
// ---------------------------------------------------------------------------
|
|
1922
|
+
|
|
1923
|
+
function bumpAudit(entity, actor) {
|
|
1924
|
+
const a = normalizeActor(actor);
|
|
1925
|
+
entity.updatedAt = now();
|
|
1926
|
+
entity.updatedBy = a;
|
|
1927
|
+
entity.version = (entity.version || 1) + 1;
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
// SM-170 (Card-Aging): set a ticket's status, resetting `statusEnteredAt` ONLY
|
|
1931
|
+
// when the status actually changes. A no-op transition (same status) keeps the
|
|
1932
|
+
// original entry time, so re-saving a ticket doesn't reset its aging clock.
|
|
1933
|
+
function _setTicketStatus(t, newStatus) {
|
|
1934
|
+
if (t.status !== newStatus) t.statusEnteredAt = now();
|
|
1935
|
+
t.status = newStatus;
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
// SM-240: ops resolve their mutation target through findTicket. On a COW
|
|
1939
|
+
// snapshot (cowSnap output) this returns the PRIVATE clone of the ticket —
|
|
1940
|
+
// the one central seam that converts nearly every op to copy-on-write. On a
|
|
1941
|
+
// plain snapshot (read paths, normalize) it behaves exactly as before.
|
|
1942
|
+
function findTicket(snap, ticketId) {
|
|
1943
|
+
if (snap && snap.__cowFresh) return cowTicket(snap, ticketId) || undefined;
|
|
1944
|
+
return snap.tickets.find(t => t.id === ticketId);
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
// SM-111 (A2): a PUBLISHED test-definition's spec is frozen. Structural
|
|
1948
|
+
// spec edits (steps add/update/remove/reorder, prerequisites add/update/
|
|
1949
|
+
// remove) throw DEFINITION_FROZEN so neither REST nor MCP can mutate a
|
|
1950
|
+
// locked spec out from under the executions cloned from it. Runtime state
|
|
1951
|
+
// toggles (prereq check/uncheck) are intentionally NOT frozen — they don't
|
|
1952
|
+
// change the spec. Drafts (lifecycle !== "published") edit freely.
|
|
1953
|
+
function assertSpecEditable(ticket) {
|
|
1954
|
+
if (ticket && ticket.type === "test-definition" && ticket.lifecycle === "published") {
|
|
1955
|
+
const e = new Error("test-definition spec is frozen (published): " + (ticket.ticketKey || ticket.id));
|
|
1956
|
+
e.statusCode = 409;
|
|
1957
|
+
e.kind = "DEFINITION_FROZEN";
|
|
1958
|
+
throw e;
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
// ---------------------------------------------------------------------------
|
|
1963
|
+
// SM-236 — Epic status is DERIVED (roll-up) from the categories of the epic's
|
|
1964
|
+
// contained board-work-item children. It is NEVER set manually (SM-237 locks
|
|
1965
|
+
// the manual paths). Pure + deterministic: aggregates over the project
|
|
1966
|
+
// workflow's status CATEGORIES (todo/doing/blocked/done) so custom workflows
|
|
1967
|
+
// work without hard-coded status ids.
|
|
1968
|
+
// ---------------------------------------------------------------------------
|
|
1969
|
+
|
|
1970
|
+
// Build id → category lookup across the base workflow AND every per-type
|
|
1971
|
+
// workflow status list, so a child carrying a per-type status still resolves.
|
|
1972
|
+
// Ids absent from the workflow fall back to the kebab-id heuristic.
|
|
1973
|
+
function _statusCategoryLookup(project) {
|
|
1974
|
+
const map = new Map();
|
|
1975
|
+
const add = (list) => {
|
|
1976
|
+
for (const s of (list || [])) {
|
|
1977
|
+
const n = normalizeStatusItem(s);
|
|
1978
|
+
if (n && !map.has(n.id)) map.set(n.id, n.category);
|
|
1979
|
+
}
|
|
1980
|
+
};
|
|
1981
|
+
const wf = (project && project.workflow) || STORYMAPPER_DEFAULT_WORKFLOW;
|
|
1982
|
+
add(wf.statuses && wf.statuses.length ? wf.statuses : STORYMAPPER_DEFAULT_WORKFLOW.statuses);
|
|
1983
|
+
if (wf.byType) for (const k of Object.keys(wf.byType)) add(wf.byType[k] && wf.byType[k].statuses);
|
|
1984
|
+
return (id) => (map.has(id) ? map.get(id) : defaultCategoryForStatusId(id));
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
// SM-242: the first status id of a given category in the project's base
|
|
1988
|
+
// workflow (used by cancelTicket to resolve the cancelled-category status).
|
|
1989
|
+
function firstStatusOfCategory(project, category) {
|
|
1990
|
+
const wf = getWorkflowForType(project, null);
|
|
1991
|
+
const statuses = (wf.statuses && wf.statuses.length) ? wf.statuses : STORYMAPPER_DEFAULT_WORKFLOW.statuses;
|
|
1992
|
+
const s = statuses.find(x => normalizeStatusItem(x).category === category);
|
|
1993
|
+
return s ? (typeof s === "string" ? s : s.id) : null;
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
// SM-242: category of a status id (custom-workflow-safe, heuristic fallback).
|
|
1997
|
+
function statusCategoryOf(project, statusId) {
|
|
1998
|
+
return _statusCategoryLookup(project)(statusId);
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
// SM-242: a status is TERMINAL when its category is done OR cancelled — i.e. the
|
|
2002
|
+
// work item is closed (successfully or deliberately dropped). Replaces hardcoded
|
|
2003
|
+
// `status === "done"` checks so cancelled items don't count as open.
|
|
2004
|
+
function isTerminalStatus(project, statusId) {
|
|
2005
|
+
const c = statusCategoryOf(project, statusId);
|
|
2006
|
+
return c === "done" || c === "cancelled";
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
// Counts of contained board-work-item children by aggregated category.
|
|
2010
|
+
// blocked is folded into "doing" (work is underway, just stuck).
|
|
2011
|
+
// NOTE (by design, SM-236): children resolve via `storiesInEpic`, which counts
|
|
2012
|
+
// only board-work-items — nested epics and spec types do NOT count. The model
|
|
2013
|
+
// is 3-level (Cell → Epic → Story); an epic that contains ONLY sub-epics has
|
|
2014
|
+
// total=0 and therefore derives to the first todo status (backlog). Epic-of-
|
|
2015
|
+
// epics roll-up is intentionally unsupported.
|
|
2016
|
+
function epicChildStats(snap, epicId) {
|
|
2017
|
+
const children = tickets.storiesInEpic(snap || { tickets: [] }, epicId);
|
|
2018
|
+
const catOf = _statusCategoryLookup(snap && snap.project);
|
|
2019
|
+
let done = 0, doing = 0, todo = 0, cancelled = 0;
|
|
2020
|
+
for (const c of children) {
|
|
2021
|
+
const cat = catOf(c.status);
|
|
2022
|
+
if (cat === "done") done++;
|
|
2023
|
+
else if (cat === "cancelled") cancelled++; // SM-243: terminal, not open work
|
|
2024
|
+
else if (cat === "doing" || cat === "blocked") doing++;
|
|
2025
|
+
else todo++;
|
|
2026
|
+
}
|
|
2027
|
+
return { total: children.length, done, doing, todo, cancelled };
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
// Pure: the status an epic SHOULD have given its children.
|
|
2031
|
+
// no children → first todo status (unstarted plan), UNLESS the epic
|
|
2032
|
+
// was deliberately cancelled (empty-epic cancel, SM-243)
|
|
2033
|
+
// all children cancelled → first cancelled status (SM-243)
|
|
2034
|
+
// all terminal, ≥1 done → first done status (done dominates cancelled)
|
|
2035
|
+
// any active doing/blocked/done → first doing status (work is live)
|
|
2036
|
+
// else (all active todo) → first todo status
|
|
2037
|
+
// "active" = children NOT in the cancelled category — cancelled is scope
|
|
2038
|
+
// reduction and never counts as open work. Defensive: when the workflow lacks a
|
|
2039
|
+
// status of the needed category, the epic's current status is preserved.
|
|
2040
|
+
function deriveEpicStatus(snap, epicId) {
|
|
2041
|
+
const epic = ((snap && snap.tickets) || []).find(t => t && t.id === epicId);
|
|
2042
|
+
if (!epic) return null;
|
|
2043
|
+
const wf = getWorkflowForType(snap && snap.project, "epic");
|
|
2044
|
+
const statuses = (wf.statuses && wf.statuses.length) ? wf.statuses : STORYMAPPER_DEFAULT_WORKFLOW.statuses;
|
|
2045
|
+
const firstOf = (cat) => {
|
|
2046
|
+
const s = statuses.find(x => normalizeStatusItem(x).category === cat);
|
|
2047
|
+
return s ? (typeof s === "string" ? s : s.id) : null;
|
|
2048
|
+
};
|
|
2049
|
+
const stats = epicChildStats(snap, epicId);
|
|
2050
|
+
const active = stats.total - stats.cancelled; // SM-243: cancelled ≠ open
|
|
2051
|
+
let target;
|
|
2052
|
+
if (stats.total === 0) {
|
|
2053
|
+
// Empty epic: unstarted plan, unless it was deliberately cancelled.
|
|
2054
|
+
target = (statusCategoryOf(snap && snap.project, epic.status) === "cancelled")
|
|
2055
|
+
? epic.status : firstOf("todo");
|
|
2056
|
+
} else if (active === 0) {
|
|
2057
|
+
// every child cancelled → cancelled. NEVER fall back to done here (an
|
|
2058
|
+
// all-cancelled epic is not "done"); keep the current status if the
|
|
2059
|
+
// workflow can't resolve a cancelled-category status (defensive).
|
|
2060
|
+
target = firstOf("cancelled") || epic.status;
|
|
2061
|
+
} else if (stats.done === active) {
|
|
2062
|
+
target = firstOf("done"); // all non-cancelled done
|
|
2063
|
+
} else if (stats.doing > 0 || stats.done > 0) {
|
|
2064
|
+
target = firstOf("doing");
|
|
2065
|
+
} else {
|
|
2066
|
+
target = firstOf("todo");
|
|
2067
|
+
}
|
|
2068
|
+
return target || epic.status;
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
// Recompute every (non-deleted) epic's status from its children. Only epics
|
|
2072
|
+
// whose derived status actually differs are COW-cloned + mutated, so untouched
|
|
2073
|
+
// epics stay reference-equal (SM-240 structural sharing). Sets ONLY .status —
|
|
2074
|
+
// the derivation is not an actor action, so it never bumps version/updatedBy/
|
|
2075
|
+
// statusEnteredAt (keeps normalize-invariance + audit-noise-free). Called at
|
|
2076
|
+
// the end of every op that can change child status/containment, and as the
|
|
2077
|
+
// last pass of normalizeSnapshot (migration + safety net).
|
|
2078
|
+
function recomputeEpicStatuses(s) {
|
|
2079
|
+
if (!s || !Array.isArray(s.tickets)) return s;
|
|
2080
|
+
for (const t of s.tickets) {
|
|
2081
|
+
if (!t || t.isDeleted || t.type !== "epic") continue;
|
|
2082
|
+
const derived = deriveEpicStatus(s, t.id);
|
|
2083
|
+
if (derived && derived !== t.status) {
|
|
2084
|
+
const fresh = cowTicket(s, t.id);
|
|
2085
|
+
if (fresh) fresh.status = derived;
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
return s;
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
// SM-239 — release progress over its NON-EPIC, non-spec work items (exactly the
|
|
2092
|
+
// set the Kanban renders, so the count never includes tickets you can't see —
|
|
2093
|
+
// that was the root of the SM-226 confusion). `done` counts items whose status
|
|
2094
|
+
// is in the done CATEGORY (custom-workflow-safe, not the hard-coded "done" id).
|
|
2095
|
+
// Returns { done, total, complete } — complete is false for an empty release.
|
|
2096
|
+
function releaseProgress(snap, releaseId) {
|
|
2097
|
+
const catOf = _statusCategoryLookup(snap && snap.project);
|
|
2098
|
+
let total = 0, done = 0;
|
|
2099
|
+
for (const t of ((snap && snap.tickets) || [])) {
|
|
2100
|
+
if (!t || t.isDeleted || !isBoardWorkItem(t.type)) continue;
|
|
2101
|
+
if (!t.position || t.position.releaseId !== releaseId) continue;
|
|
2102
|
+
const cat = catOf(t.status);
|
|
2103
|
+
if (cat === "cancelled") continue; // SM-244: cancelled = scope reduction, out of X AND Y
|
|
2104
|
+
total++;
|
|
2105
|
+
if (cat === "done") done++;
|
|
2106
|
+
}
|
|
2107
|
+
return { done, total, complete: total > 0 && done === total };
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
const ops = {
|
|
2111
|
+
|
|
2112
|
+
createTicket(snap, partial, actor) {
|
|
2113
|
+
const s = cowSnap(snap);
|
|
2114
|
+
s.project = normalizeProject(s.project);
|
|
2115
|
+
s.project.ticketCounter = (s.project.ticketCounter || 0) + 1;
|
|
2116
|
+
const ticketKey = s.project.ticketPrefix + "-" + s.project.ticketCounter;
|
|
2117
|
+
const checklists = buildTicketChecklists(s.project, partial);
|
|
2118
|
+
const a = normalizeActor(actor);
|
|
2119
|
+
|
|
2120
|
+
// Auto-Epic-Assignment: ein Story-Ticket (kein Epic), das in eine
|
|
2121
|
+
// (releaseId, processStepId)-Zelle gelegt wird, die GENAU EIN Epic
|
|
2122
|
+
// enthält, bekommt automatisch dieses Epic als Container. Bei 0 oder
|
|
2123
|
+
// ≥2 Epics bleibt der Container leer (zu ambig). Explizit gesetzter
|
|
2124
|
+
// epicId-Wert im partial wird respektiert. Epics selbst bekommen
|
|
2125
|
+
// niemals einen Container.
|
|
2126
|
+
// SM-52: containment is expressed as a `contains` link from the epic;
|
|
2127
|
+
// position.epicId is the legacy input shape but never persisted.
|
|
2128
|
+
// SM-196: a spec object (requirement/spec-module) is never contained by an
|
|
2129
|
+
// epic — it lives in a spec module. Block BOTH explicit and inferred epic
|
|
2130
|
+
// containment for spec types (defense-in-depth: the MCP surface could
|
|
2131
|
+
// otherwise pass a position that triggers the auto-assign below).
|
|
2132
|
+
const isSpecNew = isSpecType(partial && partial.type);
|
|
2133
|
+
let inferredEpicId = null;
|
|
2134
|
+
const inputPos = partial && partial.position;
|
|
2135
|
+
const explicitEpicId = (!isSpecNew && inputPos && typeof inputPos.epicId === "string") ? inputPos.epicId : null;
|
|
2136
|
+
if (!explicitEpicId && !isSpecNew && partial && partial.type !== "epic"
|
|
2137
|
+
&& inputPos && inputPos.releaseId && inputPos.processStepId) {
|
|
2138
|
+
const candidates = (s.tickets || []).filter(t =>
|
|
2139
|
+
!t.isDeleted
|
|
2140
|
+
&& t.type === "epic"
|
|
2141
|
+
&& t.position
|
|
2142
|
+
&& t.position.releaseId === inputPos.releaseId
|
|
2143
|
+
&& t.position.processStepId === inputPos.processStepId);
|
|
2144
|
+
if (candidates.length === 1) inferredEpicId = candidates[0].id;
|
|
2145
|
+
}
|
|
2146
|
+
const containerEpicId = explicitEpicId || inferredEpicId || null;
|
|
2147
|
+
|
|
2148
|
+
// The merged partial may still carry epicId in its position; we strip it
|
|
2149
|
+
// BEFORE normalizeTicket so the persisted shape never holds the legacy
|
|
2150
|
+
// pointer (the contains-link gets added at the end).
|
|
2151
|
+
let mergedPartial = partial;
|
|
2152
|
+
|
|
2153
|
+
// SM-67: when the new ticket is contained by an epic, its release+
|
|
2154
|
+
// processStep are inherited from the epic — not from the input. Apply
|
|
2155
|
+
// inheritance to the merged partial's position BEFORE the auto-sortOrder
|
|
2156
|
+
// peer filter, so peer grouping matches the values that will actually
|
|
2157
|
+
// be persisted.
|
|
2158
|
+
if (partial && partial.type !== "epic" && containerEpicId) {
|
|
2159
|
+
const inheritedPos = _inheritPositionFromEpic(s,
|
|
2160
|
+
containerEpicId, mergedPartial && mergedPartial.position || {});
|
|
2161
|
+
mergedPartial = Object.assign({}, mergedPartial, { position: inheritedPos });
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
// Auto-sortOrder: neue Tickets landen IMMER am Ende ihrer Peer-Liste,
|
|
2165
|
+
// egal ob in einer Cell, unter einem Epic, oder als Orphan im Backlog.
|
|
2166
|
+
// SM-52: peers are now grouped by "same release × processStep × container-
|
|
2167
|
+
// epic" — and container-epic is read from the contains-link, not from
|
|
2168
|
+
// position.epicId. For Orphans the container is null on both sides.
|
|
2169
|
+
const containerByStory = _containerEpicByStoryId(s);
|
|
2170
|
+
const mergedPos = mergedPartial && mergedPartial.position;
|
|
2171
|
+
const hasNoExplicitSort = !mergedPos || typeof mergedPos.sortOrder !== "number" || mergedPos.sortOrder === 0;
|
|
2172
|
+
if (hasNoExplicitSort) {
|
|
2173
|
+
const peerRel = (mergedPos && mergedPos.releaseId) || null;
|
|
2174
|
+
const peerPs = (mergedPos && mergedPos.processStepId) || null;
|
|
2175
|
+
const peerEpic = containerEpicId;
|
|
2176
|
+
const peers = (s.tickets || []).filter(tk => {
|
|
2177
|
+
if (tk.isDeleted) return false;
|
|
2178
|
+
const tp = tk.position || {};
|
|
2179
|
+
const tkEpic = (tk.type === "epic") ? null : (containerByStory.get(tk.id) || null);
|
|
2180
|
+
return (tp.releaseId || null) === peerRel
|
|
2181
|
+
&& (tp.processStepId || null) === peerPs
|
|
2182
|
+
&& tkEpic === peerEpic;
|
|
2183
|
+
});
|
|
2184
|
+
const maxSort = peers.reduce((m, tk) => Math.max(m, (tk.position && tk.position.sortOrder) || 0), -1);
|
|
2185
|
+
mergedPartial = Object.assign({}, mergedPartial, {
|
|
2186
|
+
position: Object.assign({}, mergedPos || {}, { sortOrder: maxSort + 1 })
|
|
2187
|
+
});
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
// Allow the caller (UI / MCP) to override the auto-frozen checklists by
|
|
2191
|
+
// passing their own `definitionOfReady`/`definitionOfDone` in the partial.
|
|
2192
|
+
// Per-ticket configuration: items can be customized at create time
|
|
2193
|
+
// (E19-followup). Missing → fall back to the project-default freeze.
|
|
2194
|
+
const explicitDor = mergedPartial && mergedPartial.definitionOfReady;
|
|
2195
|
+
const explicitDod = mergedPartial && mergedPartial.definitionOfDone;
|
|
2196
|
+
// SM-52: position.epicId is stripped here — never persisted on the ticket.
|
|
2197
|
+
// SM-67 inheritance was already applied above (mergedPartial.position
|
|
2198
|
+
// carries the epic's release+processStep if there's a container).
|
|
2199
|
+
const cleanedPos = Object.assign({}, mergedPartial && mergedPartial.position || {}, { epicId: null });
|
|
2200
|
+
const t = normalizeTicket(Object.assign({}, mergedPartial, {
|
|
2201
|
+
id: uid("t-"),
|
|
2202
|
+
projectId: s.project.id,
|
|
2203
|
+
ticketKey: ticketKey,
|
|
2204
|
+
position: cleanedPos,
|
|
2205
|
+
definitionOfReady: explicitDor ? normalizeChecklist(explicitDor) : checklists.definitionOfReady,
|
|
2206
|
+
definitionOfDone: explicitDod ? normalizeChecklist(explicitDod) : checklists.definitionOfDone,
|
|
2207
|
+
createdBy: a,
|
|
2208
|
+
updatedBy: a,
|
|
2209
|
+
createdAt: now(),
|
|
2210
|
+
updatedAt: now(),
|
|
2211
|
+
version: 1
|
|
2212
|
+
}));
|
|
2213
|
+
s.tickets.push(markCowFresh(s, t));
|
|
2214
|
+
// SM-52: add the canonical contains-link if the new ticket has a parent epic.
|
|
2215
|
+
if (containerEpicId) _setContainerEpic(s, t.id, containerEpicId, a);
|
|
2216
|
+
bumpAudit(s.project, actor);
|
|
2217
|
+
recomputeEpicStatuses(s); // SM-236
|
|
2218
|
+
return s;
|
|
2219
|
+
},
|
|
2220
|
+
|
|
2221
|
+
updateTicket(snap, ticketId, patch, actor) {
|
|
2222
|
+
const s = cowSnap(snap);
|
|
2223
|
+
let t = findTicket(s, ticketId);
|
|
2224
|
+
if (!t) return s;
|
|
2225
|
+
// SM-237: an epic's status is derived (roll-up) — reject an explicit
|
|
2226
|
+
// status patch rather than silently ignoring it. The check uses the
|
|
2227
|
+
// FINAL type (a patch may convert away from epic, which is allowed).
|
|
2228
|
+
if (patch && Object.prototype.hasOwnProperty.call(patch, "status")) {
|
|
2229
|
+
const finalType = Object.prototype.hasOwnProperty.call(patch, "type") ? patch.type : t.type;
|
|
2230
|
+
if (finalType === "epic") {
|
|
2231
|
+
const e = new Error("epic status is derived from its contained stories — move the stories instead");
|
|
2232
|
+
e.statusCode = 422;
|
|
2233
|
+
e.kind = "EPIC_STATUS_DERIVED";
|
|
2234
|
+
throw e;
|
|
2235
|
+
}
|
|
2236
|
+
}
|
|
2237
|
+
const allowed = ["title", "description", "type", "status", "position",
|
|
2238
|
+
"acceptanceCriteria", "labels", "definitionOfReady", "definitionOfDone",
|
|
2239
|
+
// SM-54 — modal patches for test-definition.
|
|
2240
|
+
"prerequisites", "steps",
|
|
2241
|
+
// SM-57 — modal patches for test-execution.
|
|
2242
|
+
"executionSteps", "outcomeOverride", "env",
|
|
2243
|
+
// SM-94 — test-definition lifecycle (draft|published). Driven by the
|
|
2244
|
+
// publish_test_definition / reopen_test_definition MCP tools.
|
|
2245
|
+
"lifecycle"];
|
|
2246
|
+
for (const k of allowed) {
|
|
2247
|
+
if (Object.prototype.hasOwnProperty.call(patch, k)) {
|
|
2248
|
+
if (k === "position") {
|
|
2249
|
+
// SM-52: patch.position.epicId means "re-parent to this epic" —
|
|
2250
|
+
// never persisted on the ticket itself.
|
|
2251
|
+
// SM-166: only an EXPLICIT epicId key re-parents. An absent key means
|
|
2252
|
+
// "leave the container as-is", NOT "detach" — otherwise a sortOrder-
|
|
2253
|
+
// only position patch would silently strip the inbound contains-link.
|
|
2254
|
+
const posHasEpicId = patch.position
|
|
2255
|
+
&& Object.prototype.hasOwnProperty.call(patch.position, "epicId");
|
|
2256
|
+
const newEpicId = posHasEpicId && typeof patch.position.epicId === "string"
|
|
2257
|
+
? patch.position.epicId : null;
|
|
2258
|
+
// SM-260: a PARTIAL position patch merges with the current position —
|
|
2259
|
+
// an absent key keeps its current value (e.g. `{releaseId}` moves the
|
|
2260
|
+
// release only; processStepId + sortOrder survive). An EXPLICIT null
|
|
2261
|
+
// still clears a field. (Previously the patch replaced the position
|
|
2262
|
+
// wholesale, so a partial patch silently nulled the omitted fields.)
|
|
2263
|
+
let cleanedPos = Object.assign({}, t.position || {}, patch.position || {}, { epicId: null });
|
|
2264
|
+
// SM-67: contained stories inherit release+processStep from their
|
|
2265
|
+
// container. If the patch keeps/sets a container epic, override
|
|
2266
|
+
// the position with the epic's values. SM-240 review: when the
|
|
2267
|
+
// patch OMITS epicId (SM-166: keep container as-is), the KEPT
|
|
2268
|
+
// container must dictate release+processStep too — normalize's
|
|
2269
|
+
// syncContainedStoryPositions used to repair this; the op path no
|
|
2270
|
+
// longer runs it.
|
|
2271
|
+
const keptEpicId = (!posHasEpicId && t.type !== "epic")
|
|
2272
|
+
? containerEpicIdOf(s, ticketId) : null;
|
|
2273
|
+
if (t.type !== "epic" && (newEpicId || keptEpicId)) {
|
|
2274
|
+
cleanedPos = _inheritPositionFromEpic(s, newEpicId || keptEpicId, cleanedPos);
|
|
2275
|
+
}
|
|
2276
|
+
t.position = normalizePosition(cleanedPos);
|
|
2277
|
+
if (t.type !== "epic" && posHasEpicId) _setContainerEpic(s, ticketId, newEpicId, actor);
|
|
2278
|
+
// SM-67: when an epic's position changes, every contained story
|
|
2279
|
+
// cascades to the new release+processStep.
|
|
2280
|
+
if (t.type === "epic") {
|
|
2281
|
+
_cascadeEpicPositionToContainedStories(s, ticketId, actor);
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
2284
|
+
else if (k === "type") {
|
|
2285
|
+
const becameEpic = patch.type === "epic" && t.type !== "epic";
|
|
2286
|
+
t.type = patch.type;
|
|
2287
|
+
// An epic cannot be contained by another epic — strip any stale
|
|
2288
|
+
// inbound contains-link so syncContainedStoryPositions won't later
|
|
2289
|
+
// drag the now-epic back to its ex-parent's position. (SM-150)
|
|
2290
|
+
if (becameEpic) _removeContainsLinksTo(s, ticketId, actor);
|
|
2291
|
+
// SM-240 review: re-gate the type-conditional fields (steps/
|
|
2292
|
+
// prerequisites/lifecycle/execution fields) exactly the way
|
|
2293
|
+
// normalizeTicket would — the commit path no longer runs the full
|
|
2294
|
+
// normalize, so a type change must not leave e.g. test-definition
|
|
2295
|
+
// steps on a converted user-story. Swap the re-gated object in.
|
|
2296
|
+
const regated = markCowFresh(s, normalizeTicket(t));
|
|
2297
|
+
const ti = s.tickets.indexOf(t);
|
|
2298
|
+
if (ti >= 0) s.tickets[ti] = regated;
|
|
2299
|
+
t = regated;
|
|
2300
|
+
}
|
|
2301
|
+
else if (k === "acceptanceCriteria" && Array.isArray(patch.acceptanceCriteria)) {
|
|
2302
|
+
t.acceptanceCriteria = patch.acceptanceCriteria.map(normalizeAcceptanceCriterion);
|
|
2303
|
+
}
|
|
2304
|
+
else if (k === "definitionOfReady" || k === "definitionOfDone") {
|
|
2305
|
+
t[k] = normalizeChecklist(patch[k]);
|
|
2306
|
+
}
|
|
2307
|
+
else if (k === "prerequisites" && Array.isArray(patch.prerequisites)) {
|
|
2308
|
+
t.prerequisites = patch.prerequisites.map(normalizeTestPrerequisite);
|
|
2309
|
+
}
|
|
2310
|
+
else if (k === "steps" && Array.isArray(patch.steps)) {
|
|
2311
|
+
t.steps = patch.steps.map(normalizeTestStep);
|
|
2312
|
+
}
|
|
2313
|
+
else if (k === "executionSteps" && Array.isArray(patch.executionSteps)) {
|
|
2314
|
+
t.executionSteps = patch.executionSteps.map(normalizeTestExecStep);
|
|
2315
|
+
// SM-57-followup: outcome derived from these steps may have
|
|
2316
|
+
// flipped — sync status. The exec.outcomeOverride field may
|
|
2317
|
+
// still be patched in a later loop iteration; calling sync
|
|
2318
|
+
// here covers the executionSteps-only patch path. The override
|
|
2319
|
+
// path below calls sync again, idempotent.
|
|
2320
|
+
_syncExecStatusToOutcome(t);
|
|
2321
|
+
}
|
|
2322
|
+
else if (k === "outcomeOverride") {
|
|
2323
|
+
if (patch.outcomeOverride == null || patch.outcomeOverride === "auto") {
|
|
2324
|
+
t.outcomeOverride = null;
|
|
2325
|
+
} else if (TEST_EXEC_OUTCOMES.indexOf(patch.outcomeOverride) >= 0) {
|
|
2326
|
+
t.outcomeOverride = patch.outcomeOverride;
|
|
2327
|
+
}
|
|
2328
|
+
// Invalid values are silently ignored — UI-level validation should
|
|
2329
|
+
// prevent this from reaching us; defensive on the data layer.
|
|
2330
|
+
// SM-57-followup: outcome flipped — sync status accordingly.
|
|
2331
|
+
_syncExecStatusToOutcome(t);
|
|
2332
|
+
}
|
|
2333
|
+
else if (k === "status") _setTicketStatus(t, patch.status); // SM-170: aging clock
|
|
2334
|
+
// SM-240: object values (labels, …) are cloned — with the full
|
|
2335
|
+
// normalize gone from the op path, a shared caller reference would
|
|
2336
|
+
// otherwise leak straight into the store.
|
|
2337
|
+
else t[k] = (patch[k] && typeof patch[k] === "object") ? clone(patch[k]) : patch[k];
|
|
2338
|
+
}
|
|
2339
|
+
}
|
|
2340
|
+
bumpAudit(t, actor);
|
|
2341
|
+
recomputeEpicStatuses(s); // SM-236 (status/type/containment change may roll up)
|
|
2342
|
+
return s;
|
|
2343
|
+
},
|
|
2344
|
+
|
|
2345
|
+
softDeleteTicket(snap, ticketId, actor) {
|
|
2346
|
+
const s = cowSnap(snap);
|
|
2347
|
+
const t = findTicket(s, ticketId);
|
|
2348
|
+
if (!t) return s;
|
|
2349
|
+
t.isDeleted = true;
|
|
2350
|
+
t.deletedAt = now();
|
|
2351
|
+
t.deletedBy = normalizeActor(actor);
|
|
2352
|
+
bumpAudit(t, actor);
|
|
2353
|
+
recomputeEpicStatuses(s); // SM-236 (a removed child may complete its epic)
|
|
2354
|
+
return s;
|
|
2355
|
+
},
|
|
2356
|
+
|
|
2357
|
+
changeStatus(snap, ticketId, newStatus, actor) {
|
|
2358
|
+
const s = cowSnap(snap);
|
|
2359
|
+
const t = findTicket(s, ticketId);
|
|
2360
|
+
if (!t) return s;
|
|
2361
|
+
// SM-237: an epic's status is derived — reject a manual change at the op
|
|
2362
|
+
// layer itself, so the rule is structural (not call-site discipline). The
|
|
2363
|
+
// roll-up recompute below mutates ticket.status directly, never via this op.
|
|
2364
|
+
if (t.type === "epic") {
|
|
2365
|
+
const e = new Error("epic status is derived from its contained stories — move the stories instead");
|
|
2366
|
+
e.statusCode = 422;
|
|
2367
|
+
e.kind = "EPIC_STATUS_DERIVED";
|
|
2368
|
+
throw e;
|
|
2369
|
+
}
|
|
2370
|
+
_setTicketStatus(t, newStatus); // SM-170: resets statusEnteredAt on change
|
|
2371
|
+
bumpAudit(t, actor);
|
|
2372
|
+
recomputeEpicStatuses(s); // SM-236 (child status change rolls up to its epic)
|
|
2373
|
+
return s;
|
|
2374
|
+
},
|
|
2375
|
+
|
|
2376
|
+
// SM-242: cancel a ticket WITHOUT any gate check — the documented, explicit
|
|
2377
|
+
// way to record a deliberate non-implementation (Cancel ≠ Delete: the ticket
|
|
2378
|
+
// stays visible with its history + links). Sets the first cancelled-category
|
|
2379
|
+
// status of the project workflow. Epics are rejected here (kind=EPIC_CANCEL —
|
|
2380
|
+
// the cascade arrives in SM-243); spec types have their own lifecycle.
|
|
2381
|
+
cancelTicket(snap, ticketId, actor) {
|
|
2382
|
+
const s = cowSnap(snap);
|
|
2383
|
+
const t = findTicket(s, ticketId);
|
|
2384
|
+
if (!t) { const e = new Error("ticket not found: " + ticketId); e.statusCode = 404; throw e; }
|
|
2385
|
+
if (isSpecType(t.type)) {
|
|
2386
|
+
const e = new Error("spec objects have their own lifecycle — cannot be cancelled");
|
|
2387
|
+
e.statusCode = 422; e.kind = "SPEC_CANCEL"; throw e;
|
|
2388
|
+
}
|
|
2389
|
+
const cancelledId = firstStatusOfCategory(s.project, "cancelled");
|
|
2390
|
+
if (!cancelledId) {
|
|
2391
|
+
const e = new Error("workflow has no cancelled-category status");
|
|
2392
|
+
e.statusCode = 422; e.kind = "NO_CANCEL_STATUS"; throw e;
|
|
2393
|
+
}
|
|
2394
|
+
if (t.type === "epic") {
|
|
2395
|
+
// SM-243: cancelling an epic CASCADES — every contained non-terminal
|
|
2396
|
+
// story is cancelled in this one snapshot (atomic; the persist layer tags
|
|
2397
|
+
// it "epic_cancel"). done/cancelled children are left untouched. The epic
|
|
2398
|
+
// status is then DERIVED by the roll-up — EXCEPT an empty epic, which is
|
|
2399
|
+
// set directly (the only allowed manual epic-status change, SM-237).
|
|
2400
|
+
const children = tickets.storiesInEpic(s, ticketId);
|
|
2401
|
+
if (children.length === 0) {
|
|
2402
|
+
_setTicketStatus(t, cancelledId);
|
|
2403
|
+
bumpAudit(t, actor);
|
|
2404
|
+
} else {
|
|
2405
|
+
for (const child of children) {
|
|
2406
|
+
if (isTerminalStatus(s.project, child.status)) continue; // skip done + cancelled
|
|
2407
|
+
const fresh = cowTicket(s, child.id);
|
|
2408
|
+
_setTicketStatus(fresh, cancelledId);
|
|
2409
|
+
bumpAudit(fresh, actor);
|
|
2410
|
+
}
|
|
2411
|
+
}
|
|
2412
|
+
recomputeEpicStatuses(s);
|
|
2413
|
+
return s;
|
|
2414
|
+
}
|
|
2415
|
+
_setTicketStatus(t, cancelledId);
|
|
2416
|
+
bumpAudit(t, actor);
|
|
2417
|
+
recomputeEpicStatuses(s); // SM-236/243 (a cancelled child rolls up to its epic)
|
|
2418
|
+
return s;
|
|
2419
|
+
},
|
|
2420
|
+
|
|
2421
|
+
moveTicket(snap, ticketId, position, actor) {
|
|
2422
|
+
const s = cowSnap(snap);
|
|
2423
|
+
const t = findTicket(s, ticketId);
|
|
2424
|
+
if (!t) return s;
|
|
2425
|
+
// SM-52: position.epicId in the input is interpreted as a re-parenting
|
|
2426
|
+
// request (which epic should contain this ticket). It's translated into
|
|
2427
|
+
// contains-link updates and never persisted on the ticket itself.
|
|
2428
|
+
const newEpicId = position && typeof position.epicId === "string" ? position.epicId : null;
|
|
2429
|
+
let cleanedPos = Object.assign({}, position || {}, { epicId: null });
|
|
2430
|
+
// SM-67: contained stories inherit release+processStep from container.
|
|
2431
|
+
if (t.type !== "epic" && newEpicId) {
|
|
2432
|
+
cleanedPos = _inheritPositionFromEpic(s, newEpicId, cleanedPos);
|
|
2433
|
+
}
|
|
2434
|
+
t.position = normalizePosition(cleanedPos);
|
|
2435
|
+
if (t.type !== "epic") _setContainerEpic(s, ticketId, newEpicId, actor);
|
|
2436
|
+
// SM-67: epic move cascades to contained stories.
|
|
2437
|
+
if (t.type === "epic") {
|
|
2438
|
+
_cascadeEpicPositionToContainedStories(s, ticketId, actor);
|
|
2439
|
+
}
|
|
2440
|
+
bumpAudit(t, actor);
|
|
2441
|
+
recomputeEpicStatuses(s); // SM-236 (re-parenting changes both epics' children)
|
|
2442
|
+
return s;
|
|
2443
|
+
},
|
|
2444
|
+
|
|
2445
|
+
addComment(snap, ticketId, comment, actor) {
|
|
2446
|
+
const s = cowSnap(snap);
|
|
2447
|
+
const t = findTicket(s, ticketId);
|
|
2448
|
+
if (!t) return s;
|
|
2449
|
+
t.comments.push(normalizeComment(Object.assign({}, comment, {
|
|
2450
|
+
actor: normalizeActor(actor),
|
|
2451
|
+
timestamp: now()
|
|
2452
|
+
})));
|
|
2453
|
+
bumpAudit(t, actor);
|
|
2454
|
+
return s;
|
|
2455
|
+
},
|
|
2456
|
+
|
|
2457
|
+
checkDorItem(snap, ticketId, itemId, actor) {
|
|
2458
|
+
const s = cowSnap(snap);
|
|
2459
|
+
const t = findTicket(s, ticketId);
|
|
2460
|
+
if (!t) return s;
|
|
2461
|
+
const item = t.definitionOfReady.items.find(i => i.id === itemId);
|
|
2462
|
+
if (!item) return s;
|
|
2463
|
+
item.checked = true;
|
|
2464
|
+
item.checkedAt = now();
|
|
2465
|
+
item.checkedBy = normalizeActor(actor);
|
|
2466
|
+
bumpAudit(t, actor);
|
|
2467
|
+
return s;
|
|
2468
|
+
},
|
|
2469
|
+
|
|
2470
|
+
uncheckDorItem(snap, ticketId, itemId, actor) {
|
|
2471
|
+
const s = cowSnap(snap);
|
|
2472
|
+
const t = findTicket(s, ticketId);
|
|
2473
|
+
if (!t) return s;
|
|
2474
|
+
const item = t.definitionOfReady.items.find(i => i.id === itemId);
|
|
2475
|
+
if (!item) return s;
|
|
2476
|
+
item.checked = false;
|
|
2477
|
+
item.checkedAt = null;
|
|
2478
|
+
item.checkedBy = null;
|
|
2479
|
+
bumpAudit(t, actor);
|
|
2480
|
+
return s;
|
|
2481
|
+
},
|
|
2482
|
+
|
|
2483
|
+
checkDodItem(snap, ticketId, itemId, actor) {
|
|
2484
|
+
const s = cowSnap(snap);
|
|
2485
|
+
const t = findTicket(s, ticketId);
|
|
2486
|
+
if (!t) return s;
|
|
2487
|
+
const item = t.definitionOfDone.items.find(i => i.id === itemId);
|
|
2488
|
+
if (!item) return s;
|
|
2489
|
+
item.checked = true;
|
|
2490
|
+
item.checkedAt = now();
|
|
2491
|
+
item.checkedBy = normalizeActor(actor);
|
|
2492
|
+
bumpAudit(t, actor);
|
|
2493
|
+
return s;
|
|
2494
|
+
},
|
|
2495
|
+
|
|
2496
|
+
uncheckDodItem(snap, ticketId, itemId, actor) {
|
|
2497
|
+
const s = cowSnap(snap);
|
|
2498
|
+
const t = findTicket(s, ticketId);
|
|
2499
|
+
if (!t) return s;
|
|
2500
|
+
const item = t.definitionOfDone.items.find(i => i.id === itemId);
|
|
2501
|
+
if (!item) return s;
|
|
2502
|
+
item.checked = false;
|
|
2503
|
+
item.checkedAt = null;
|
|
2504
|
+
item.checkedBy = null;
|
|
2505
|
+
bumpAudit(t, actor);
|
|
2506
|
+
return s;
|
|
2507
|
+
},
|
|
2508
|
+
|
|
2509
|
+
// ---- SM-58: test-definition step + prereq ops -------------------------
|
|
2510
|
+
//
|
|
2511
|
+
// These operate on type='test-definition' tickets. Mutations append/edit/
|
|
2512
|
+
// delete/reorder the prerequisites or steps arrays in-place. Every op
|
|
2513
|
+
// returns a fresh cloned snapshot so the store-wrapper can capture an
|
|
2514
|
+
// undoable commit. Throws { statusCode } for not-found.
|
|
2515
|
+
|
|
2516
|
+
// SM-301: derive a test-definition from a story's acceptance criteria —
|
|
2517
|
+
// each AC becomes one step (step = the criterion, expectedResult = the
|
|
2518
|
+
// criterion as the observable outcome). The plan is a "north star" created
|
|
2519
|
+
// at AC-time (draft by default) and refined during implementation; the
|
|
2520
|
+
// SM-299 gate then requires it published before done. Returns the snapshot
|
|
2521
|
+
// with the new definition appended LAST (createTicket appends) and a
|
|
2522
|
+
// `tests`-link from the definition back to the story. Compound but ONE
|
|
2523
|
+
// logical op → ONE revision at the surface.
|
|
2524
|
+
deriveTestDefinition(snap, ticketId, opts, actor) {
|
|
2525
|
+
opts = opts || {};
|
|
2526
|
+
const target = findTicket(snap, ticketId);
|
|
2527
|
+
if (!target) { const e = new Error("ticket not found: " + ticketId); e.statusCode = 404; throw e; }
|
|
2528
|
+
if (target.isDeleted) { const e = new Error("cannot derive from a deleted ticket: " + ticketId); e.statusCode = 404; throw e; }
|
|
2529
|
+
// Deriving a test plan FOR a test plan (or an execution) is nonsensical —
|
|
2530
|
+
// review finding on f714cfa.
|
|
2531
|
+
if (target.type === "test-definition" || target.type === "test-execution") {
|
|
2532
|
+
const e = new Error("cannot derive a test-definition from a " + target.type + ": " + ticketId);
|
|
2533
|
+
e.statusCode = 400; throw e;
|
|
2534
|
+
}
|
|
2535
|
+
const acs = (Array.isArray(target.acceptanceCriteria) ? target.acceptanceCriteria : [])
|
|
2536
|
+
.filter((a) => a && String(a.text || "").trim());
|
|
2537
|
+
if (acs.length === 0) {
|
|
2538
|
+
const e = new Error("cannot derive a test-definition: " + (target.ticketKey || ticketId)
|
|
2539
|
+
+ " has no acceptance criteria (add AC first — they are the test plan)");
|
|
2540
|
+
e.statusCode = 422; e.kind = "NO_ACCEPTANCE_CRITERIA";
|
|
2541
|
+
throw e;
|
|
2542
|
+
}
|
|
2543
|
+
const title = (opts.title && String(opts.title).trim())
|
|
2544
|
+
|| ("Testplan: " + (target.title || target.ticketKey || ticketId));
|
|
2545
|
+
let s = ops.createTicket(snap, { type: "test-definition", title: title }, actor);
|
|
2546
|
+
const def = s.tickets[s.tickets.length - 1];
|
|
2547
|
+
acs.forEach((ac) => {
|
|
2548
|
+
const text = String(ac.text).trim();
|
|
2549
|
+
s = ops.addTestStep(s, def.id, { step: text, expectedResult: text }, actor);
|
|
2550
|
+
});
|
|
2551
|
+
s = ops.addLink(s, def.id, { linkTypeId: "tests", targetTicketId: ticketId }, actor);
|
|
2552
|
+
return s;
|
|
2553
|
+
},
|
|
2554
|
+
|
|
2555
|
+
addTestStep(snap, ticketId, patch, actor) {
|
|
2556
|
+
const s = cowSnap(snap);
|
|
2557
|
+
const t = findTicket(s, ticketId);
|
|
2558
|
+
if (!t) { const e = new Error("ticket not found: " + ticketId); e.statusCode = 404; throw e; }
|
|
2559
|
+
assertSpecEditable(t);
|
|
2560
|
+
const step = normalizeTestStep(patch || {});
|
|
2561
|
+
const list = Array.isArray(t.steps) ? t.steps : (t.steps = []);
|
|
2562
|
+
const pos = patch && typeof patch.position === "number" ? patch.position : list.length;
|
|
2563
|
+
list.splice(Math.max(0, Math.min(list.length, pos)), 0, step);
|
|
2564
|
+
bumpAudit(t, actor);
|
|
2565
|
+
return s;
|
|
2566
|
+
},
|
|
2567
|
+
|
|
2568
|
+
updateTestStep(snap, ticketId, stepId, patch, actor) {
|
|
2569
|
+
const s = cowSnap(snap);
|
|
2570
|
+
const t = findTicket(s, ticketId);
|
|
2571
|
+
if (!t) { const e = new Error("ticket not found: " + ticketId); e.statusCode = 404; throw e; }
|
|
2572
|
+
assertSpecEditable(t);
|
|
2573
|
+
const step = (t.steps || []).find(x => x.id === stepId);
|
|
2574
|
+
if (!step) { const e = new Error("step not found: " + stepId); e.statusCode = 404; throw e; }
|
|
2575
|
+
if (patch && typeof patch.step === "string") step.step = patch.step;
|
|
2576
|
+
if (patch && typeof patch.data === "string") step.data = patch.data;
|
|
2577
|
+
if (patch && typeof patch.expectedResult === "string") step.expectedResult = patch.expectedResult;
|
|
2578
|
+
bumpAudit(t, actor);
|
|
2579
|
+
return s;
|
|
2580
|
+
},
|
|
2581
|
+
|
|
2582
|
+
removeTestStep(snap, ticketId, stepId, actor) {
|
|
2583
|
+
const s = cowSnap(snap);
|
|
2584
|
+
const t = findTicket(s, ticketId);
|
|
2585
|
+
if (!t) { const e = new Error("ticket not found: " + ticketId); e.statusCode = 404; throw e; }
|
|
2586
|
+
assertSpecEditable(t);
|
|
2587
|
+
const before = (t.steps || []).length;
|
|
2588
|
+
t.steps = (t.steps || []).filter(x => x.id !== stepId);
|
|
2589
|
+
if (t.steps.length !== before) bumpAudit(t, actor);
|
|
2590
|
+
return s;
|
|
2591
|
+
},
|
|
2592
|
+
|
|
2593
|
+
reorderTestSteps(snap, ticketId, orderedStepIds, actor) {
|
|
2594
|
+
const s = cowSnap(snap);
|
|
2595
|
+
const t = findTicket(s, ticketId);
|
|
2596
|
+
if (!t) { const e = new Error("ticket not found: " + ticketId); e.statusCode = 404; throw e; }
|
|
2597
|
+
assertSpecEditable(t);
|
|
2598
|
+
const byId = new Map((t.steps || []).map(x => [x.id, x]));
|
|
2599
|
+
const reordered = [];
|
|
2600
|
+
for (const id of (orderedStepIds || [])) {
|
|
2601
|
+
const x = byId.get(id);
|
|
2602
|
+
if (x) { reordered.push(x); byId.delete(id); }
|
|
2603
|
+
}
|
|
2604
|
+
// Append any steps the caller didn't list (defensive — keep them).
|
|
2605
|
+
for (const remaining of byId.values()) reordered.push(remaining);
|
|
2606
|
+
t.steps = reordered;
|
|
2607
|
+
bumpAudit(t, actor);
|
|
2608
|
+
return s;
|
|
2609
|
+
},
|
|
2610
|
+
|
|
2611
|
+
addTestPrereq(snap, ticketId, patch, actor) {
|
|
2612
|
+
const s = cowSnap(snap);
|
|
2613
|
+
const t = findTicket(s, ticketId);
|
|
2614
|
+
if (!t) { const e = new Error("ticket not found: " + ticketId); e.statusCode = 404; throw e; }
|
|
2615
|
+
assertSpecEditable(t);
|
|
2616
|
+
const item = normalizeTestPrerequisite(patch || {});
|
|
2617
|
+
if (!Array.isArray(t.prerequisites)) t.prerequisites = [];
|
|
2618
|
+
t.prerequisites.push(item);
|
|
2619
|
+
bumpAudit(t, actor);
|
|
2620
|
+
return s;
|
|
2621
|
+
},
|
|
2622
|
+
|
|
2623
|
+
updateTestPrereq(snap, ticketId, prereqId, patch, actor) {
|
|
2624
|
+
const s = cowSnap(snap);
|
|
2625
|
+
const t = findTicket(s, ticketId);
|
|
2626
|
+
if (!t) { const e = new Error("ticket not found: " + ticketId); e.statusCode = 404; throw e; }
|
|
2627
|
+
assertSpecEditable(t);
|
|
2628
|
+
const item = (t.prerequisites || []).find(x => x.id === prereqId);
|
|
2629
|
+
if (!item) { const e = new Error("prereq not found: " + prereqId); e.statusCode = 404; throw e; }
|
|
2630
|
+
if (patch && typeof patch.label === "string") item.label = patch.label;
|
|
2631
|
+
if (patch && typeof patch.required === "boolean") item.required = patch.required;
|
|
2632
|
+
bumpAudit(t, actor);
|
|
2633
|
+
return s;
|
|
2634
|
+
},
|
|
2635
|
+
|
|
2636
|
+
removeTestPrereq(snap, ticketId, prereqId, actor) {
|
|
2637
|
+
const s = cowSnap(snap);
|
|
2638
|
+
const t = findTicket(s, ticketId);
|
|
2639
|
+
if (!t) { const e = new Error("ticket not found: " + ticketId); e.statusCode = 404; throw e; }
|
|
2640
|
+
assertSpecEditable(t);
|
|
2641
|
+
const before = (t.prerequisites || []).length;
|
|
2642
|
+
t.prerequisites = (t.prerequisites || []).filter(x => x.id !== prereqId);
|
|
2643
|
+
if (t.prerequisites.length !== before) bumpAudit(t, actor);
|
|
2644
|
+
return s;
|
|
2645
|
+
},
|
|
2646
|
+
|
|
2647
|
+
checkTestPrereq(snap, ticketId, prereqId, actor) {
|
|
2648
|
+
const s = cowSnap(snap);
|
|
2649
|
+
const t = findTicket(s, ticketId);
|
|
2650
|
+
if (!t) { const e = new Error("ticket not found: " + ticketId); e.statusCode = 404; throw e; }
|
|
2651
|
+
const item = (t.prerequisites || []).find(x => x.id === prereqId);
|
|
2652
|
+
if (!item) { const e = new Error("prereq not found: " + prereqId); e.statusCode = 404; throw e; }
|
|
2653
|
+
item.checked = true;
|
|
2654
|
+
bumpAudit(t, actor);
|
|
2655
|
+
return s;
|
|
2656
|
+
},
|
|
2657
|
+
|
|
2658
|
+
uncheckTestPrereq(snap, ticketId, prereqId, actor) {
|
|
2659
|
+
const s = cowSnap(snap);
|
|
2660
|
+
const t = findTicket(s, ticketId);
|
|
2661
|
+
if (!t) { const e = new Error("ticket not found: " + ticketId); e.statusCode = 404; throw e; }
|
|
2662
|
+
const item = (t.prerequisites || []).find(x => x.id === prereqId);
|
|
2663
|
+
if (!item) { const e = new Error("prereq not found: " + prereqId); e.statusCode = 404; throw e; }
|
|
2664
|
+
item.checked = false;
|
|
2665
|
+
bumpAudit(t, actor);
|
|
2666
|
+
return s;
|
|
2667
|
+
},
|
|
2668
|
+
|
|
2669
|
+
// ---- SM-58: test-execution lifecycle ops ------------------------------
|
|
2670
|
+
//
|
|
2671
|
+
// startTestExecution clones a test-definition's steps onto a new
|
|
2672
|
+
// test-execution ticket (snapshot-freeze semantic — later definition
|
|
2673
|
+
// changes don't bleed back). Also creates the "executes" link from the
|
|
2674
|
+
// run → definition, and bumps the project ticketCounter for the new key.
|
|
2675
|
+
|
|
2676
|
+
startTestExecution(snap, definitionId, opts, actor) {
|
|
2677
|
+
const s = cowSnap(snap);
|
|
2678
|
+
const def = findTicket(s, definitionId);
|
|
2679
|
+
if (!def) { const e = new Error("definition not found: " + definitionId); e.statusCode = 404; throw e; }
|
|
2680
|
+
if (def.type !== "test-definition") {
|
|
2681
|
+
const e = new Error("ticket is not a test-definition: " + definitionId);
|
|
2682
|
+
e.statusCode = 422; e.kind = "WRONG_TYPE";
|
|
2683
|
+
throw e;
|
|
2684
|
+
}
|
|
2685
|
+
const a = normalizeActor(actor);
|
|
2686
|
+
const env = opts && typeof opts.env === "string" ? opts.env : "";
|
|
2687
|
+
const runBy = (opts && isObject(opts.runBy)) ? normalizeActor(opts.runBy) : a;
|
|
2688
|
+
|
|
2689
|
+
// Clone steps with snapshot semantics: each execution step keeps a
|
|
2690
|
+
// pointer to the definition's stepId so MCP recording can address by id.
|
|
2691
|
+
const clonedSteps = (def.steps || []).map(src => normalizeTestExecStep({
|
|
2692
|
+
stepId: src.id,
|
|
2693
|
+
step: src.step,
|
|
2694
|
+
data: src.data,
|
|
2695
|
+
expectedResult: src.expectedResult,
|
|
2696
|
+
status: "pending"
|
|
2697
|
+
}));
|
|
2698
|
+
|
|
2699
|
+
s.project = normalizeProject(s.project);
|
|
2700
|
+
s.project.ticketCounter = (s.project.ticketCounter || 0) + 1;
|
|
2701
|
+
const ticketKey = s.project.ticketPrefix + "-" + s.project.ticketCounter;
|
|
2702
|
+
|
|
2703
|
+
const exec = normalizeTicket({
|
|
2704
|
+
id: uid("t-"),
|
|
2705
|
+
projectId: s.project.id,
|
|
2706
|
+
ticketKey: ticketKey,
|
|
2707
|
+
type: "test-execution",
|
|
2708
|
+
title: "Run of " + (def.ticketKey || def.id),
|
|
2709
|
+
description: "Execution of test-definition " + (def.ticketKey || def.id),
|
|
2710
|
+
status: "in-progress",
|
|
2711
|
+
// Place in the same release as the definition so it's visible nearby.
|
|
2712
|
+
position: { releaseId: (def.position && def.position.releaseId) || null,
|
|
2713
|
+
processStepId: (def.position && def.position.processStepId) || null,
|
|
2714
|
+
epicId: null,
|
|
2715
|
+
sortOrder: 0 },
|
|
2716
|
+
executionSteps: clonedSteps,
|
|
2717
|
+
referencedTestDefinitionId: def.id,
|
|
2718
|
+
runAt: now(),
|
|
2719
|
+
runBy: runBy,
|
|
2720
|
+
env: env,
|
|
2721
|
+
createdBy: a,
|
|
2722
|
+
updatedBy: a,
|
|
2723
|
+
createdAt: now(),
|
|
2724
|
+
updatedAt: now(),
|
|
2725
|
+
version: 1
|
|
2726
|
+
});
|
|
2727
|
+
s.tickets.push(markCowFresh(s, exec));
|
|
2728
|
+
|
|
2729
|
+
// Wire the 'executes' link: source = exec, target = def.
|
|
2730
|
+
exec.links = exec.links || [];
|
|
2731
|
+
exec.links.push(normalizeLink({
|
|
2732
|
+
linkTypeId: "executes",
|
|
2733
|
+
targetTicketId: def.id,
|
|
2734
|
+
createdBy: a
|
|
2735
|
+
}));
|
|
2736
|
+
bumpAudit(s.project, a);
|
|
2737
|
+
return s;
|
|
2738
|
+
},
|
|
2739
|
+
|
|
2740
|
+
recordTestExecStep(snap, executionId, stepId, patch, actor) {
|
|
2741
|
+
const s = cowSnap(snap);
|
|
2742
|
+
const exec = findTicket(s, executionId);
|
|
2743
|
+
if (!exec) { const e = new Error("execution not found: " + executionId); e.statusCode = 404; throw e; }
|
|
2744
|
+
if (exec.type !== "test-execution") {
|
|
2745
|
+
const e = new Error("ticket is not a test-execution: " + executionId);
|
|
2746
|
+
e.statusCode = 422; e.kind = "WRONG_TYPE";
|
|
2747
|
+
throw e;
|
|
2748
|
+
}
|
|
2749
|
+
// Match by execution-step.stepId (the cloned reference to the definition)
|
|
2750
|
+
// OR by execution-step.id (the execution's own id). Either is fine —
|
|
2751
|
+
// callers usually have the definition step id at hand.
|
|
2752
|
+
const step = (exec.executionSteps || []).find(x => x.stepId === stepId || x.id === stepId);
|
|
2753
|
+
if (!step) { const e = new Error("step not found: " + stepId); e.statusCode = 404; throw e; }
|
|
2754
|
+
if (patch && typeof patch.actualResult === "string") step.actualResult = patch.actualResult;
|
|
2755
|
+
if (patch && typeof patch.status === "string") {
|
|
2756
|
+
if (TEST_EXEC_STEP_STATUSES.indexOf(patch.status) < 0) {
|
|
2757
|
+
const e = new Error("invalid status: " + patch.status + " (allowed: " + TEST_EXEC_STEP_STATUSES.join(",") + ")");
|
|
2758
|
+
e.statusCode = 422; throw e;
|
|
2759
|
+
}
|
|
2760
|
+
step.status = patch.status;
|
|
2761
|
+
}
|
|
2762
|
+
if (patch && typeof patch.note === "string") {
|
|
2763
|
+
if (patch.note.length > 0) step.note = patch.note;
|
|
2764
|
+
else delete step.note;
|
|
2765
|
+
}
|
|
2766
|
+
// SM-57-followup: outcome may have flipped — sync status accordingly.
|
|
2767
|
+
_syncExecStatusToOutcome(exec);
|
|
2768
|
+
bumpAudit(exec, actor);
|
|
2769
|
+
return s;
|
|
2770
|
+
},
|
|
2771
|
+
|
|
2772
|
+
setTestExecOutcome(snap, executionId, outcome, actor) {
|
|
2773
|
+
const s = cowSnap(snap);
|
|
2774
|
+
const exec = findTicket(s, executionId);
|
|
2775
|
+
if (!exec) { const e = new Error("execution not found: " + executionId); e.statusCode = 404; throw e; }
|
|
2776
|
+
if (exec.type !== "test-execution") {
|
|
2777
|
+
const e = new Error("ticket is not a test-execution: " + executionId);
|
|
2778
|
+
e.statusCode = 422; e.kind = "WRONG_TYPE";
|
|
2779
|
+
throw e;
|
|
2780
|
+
}
|
|
2781
|
+
// "auto" resets the override so derive-from-steps takes over.
|
|
2782
|
+
if (outcome === "auto" || outcome == null) {
|
|
2783
|
+
exec.outcomeOverride = null;
|
|
2784
|
+
} else if (TEST_EXEC_OUTCOMES.indexOf(outcome) >= 0) {
|
|
2785
|
+
exec.outcomeOverride = outcome;
|
|
2786
|
+
} else {
|
|
2787
|
+
const e = new Error("invalid outcome: " + outcome + " (allowed: " + TEST_EXEC_OUTCOMES.join(",") + " or 'auto')");
|
|
2788
|
+
e.statusCode = 422; throw e;
|
|
2789
|
+
}
|
|
2790
|
+
// SM-57-followup: outcome flipped — sync status accordingly.
|
|
2791
|
+
_syncExecStatusToOutcome(exec);
|
|
2792
|
+
bumpAudit(exec, actor);
|
|
2793
|
+
return s;
|
|
2794
|
+
},
|
|
2795
|
+
|
|
2796
|
+
// E18.B: Project-Header-Update als Store-Op (für undo/redo + snapshot-PUT
|
|
2797
|
+
// Pipeline). Erlaubte Felder: name, description, definitions, workflow,
|
|
2798
|
+
// ticketTypes, entityTypeConfig, labels. id/ticketPrefix/ticketCounter sind
|
|
2799
|
+
// bewusst gesperrt — id ist Identität, ticketPrefix/Counter würden bestehende
|
|
2800
|
+
// ticketKeys brechen.
|
|
2801
|
+
updateProject(snap, patch, actor) {
|
|
2802
|
+
const s = cowSnap(snap);
|
|
2803
|
+
const allowed = ["name", "description", "definitions", "workflow",
|
|
2804
|
+
"boards", "ticketTypes", "entityTypeConfig", "labels", "linkTypes",
|
|
2805
|
+
// SM-93
|
|
2806
|
+
"governance"];
|
|
2807
|
+
const merged = Object.assign({}, s.project);
|
|
2808
|
+
for (const k of allowed) {
|
|
2809
|
+
if (patch && Object.prototype.hasOwnProperty.call(patch, k)) {
|
|
2810
|
+
// SM-240: clone object values — see updateTicket fall-through.
|
|
2811
|
+
merged[k] = (patch[k] && typeof patch[k] === "object") ? clone(patch[k]) : patch[k];
|
|
2812
|
+
}
|
|
2813
|
+
}
|
|
2814
|
+
s.project = normalizeProject(merged);
|
|
2815
|
+
bumpAudit(s.project, actor);
|
|
2816
|
+
return s;
|
|
2817
|
+
},
|
|
2818
|
+
|
|
2819
|
+
createRelease(snap, partial, actor) {
|
|
2820
|
+
const s = cowSnap(snap);
|
|
2821
|
+
const a = normalizeActor(actor);
|
|
2822
|
+
const maxSortOrder = s.releases.reduce((m, r) => Math.max(m, r.sortOrder), -1);
|
|
2823
|
+
const r = normalizeRelease(Object.assign({}, partial, {
|
|
2824
|
+
id: uid("r-"),
|
|
2825
|
+
projectId: s.project.id,
|
|
2826
|
+
sortOrder: typeof partial.sortOrder === "number" ? partial.sortOrder : maxSortOrder + 1,
|
|
2827
|
+
createdBy: a,
|
|
2828
|
+
updatedBy: a,
|
|
2829
|
+
createdAt: now(),
|
|
2830
|
+
updatedAt: now(),
|
|
2831
|
+
version: 1
|
|
2832
|
+
}));
|
|
2833
|
+
s.releases.push(markCowFresh(s, r));
|
|
2834
|
+
return s;
|
|
2835
|
+
},
|
|
2836
|
+
|
|
2837
|
+
updateRelease(snap, releaseId, patch, actor) {
|
|
2838
|
+
const s = cowSnap(snap);
|
|
2839
|
+
const r = cowRelease(s, releaseId);
|
|
2840
|
+
if (!r) return s;
|
|
2841
|
+
const allowed = ["name", "description", "startDate", "endDate", "status", "sortOrder"];
|
|
2842
|
+
for (const k of allowed) {
|
|
2843
|
+
if (Object.prototype.hasOwnProperty.call(patch, k)) r[k] = patch[k];
|
|
2844
|
+
}
|
|
2845
|
+
bumpAudit(r, actor);
|
|
2846
|
+
return s;
|
|
2847
|
+
},
|
|
2848
|
+
|
|
2849
|
+
softDeleteRelease(snap, releaseId, actor) {
|
|
2850
|
+
const s = cowSnap(snap);
|
|
2851
|
+
const r = cowRelease(s, releaseId);
|
|
2852
|
+
if (!r) return s;
|
|
2853
|
+
r.isDeleted = true;
|
|
2854
|
+
r.deletedAt = now();
|
|
2855
|
+
r.deletedBy = normalizeActor(actor);
|
|
2856
|
+
bumpAudit(r, actor);
|
|
2857
|
+
// SM-240 (normalize-only-at-edges): normalizeSnapshot used to prune the
|
|
2858
|
+
// now-dangling position.releaseId on the next pass; with the op path no
|
|
2859
|
+
// longer running a full normalize, the op must leave an invariant
|
|
2860
|
+
// snapshot itself — mirror SM-167's processStep behaviour.
|
|
2861
|
+
for (const probe of s.tickets.slice()) {
|
|
2862
|
+
if (!probe.position || probe.position.releaseId !== releaseId) continue;
|
|
2863
|
+
const t = cowTicket(s, probe.id);
|
|
2864
|
+
t.position = normalizePosition(Object.assign({}, t.position, { releaseId: null }));
|
|
2865
|
+
// SM-240 review: prune soft-deleted tickets too (normalize parity),
|
|
2866
|
+
// without an audit bump.
|
|
2867
|
+
if (!t.isDeleted) bumpAudit(t, actor);
|
|
2868
|
+
}
|
|
2869
|
+
return s;
|
|
2870
|
+
},
|
|
2871
|
+
|
|
2872
|
+
reorderReleases(snap, orderedIds, actor) {
|
|
2873
|
+
const s = cowSnap(snap);
|
|
2874
|
+
const byId = new Map(s.releases.map(r => [r.id, r]));
|
|
2875
|
+
const newList = [];
|
|
2876
|
+
orderedIds.forEach((id, idx) => {
|
|
2877
|
+
if (byId.has(id)) {
|
|
2878
|
+
const r = cowRelease(s, id); // COW-aware (SM-240)
|
|
2879
|
+
r.sortOrder = idx;
|
|
2880
|
+
bumpAudit(r, actor);
|
|
2881
|
+
newList.push(r);
|
|
2882
|
+
byId.delete(id);
|
|
2883
|
+
}
|
|
2884
|
+
});
|
|
2885
|
+
// any releases not in orderedIds keep relative order at the end
|
|
2886
|
+
for (const r of byId.values()) newList.push(r);
|
|
2887
|
+
s.releases = newList;
|
|
2888
|
+
return s;
|
|
2889
|
+
},
|
|
2890
|
+
|
|
2891
|
+
createProcessStep(snap, partial, actor) {
|
|
2892
|
+
const s = cowSnap(snap);
|
|
2893
|
+
const a = normalizeActor(actor);
|
|
2894
|
+
const maxSortOrder = s.processSteps.reduce((m, p) => Math.max(m, p.sortOrder), -1);
|
|
2895
|
+
const ps = normalizeProcessStep(Object.assign({}, partial, {
|
|
2896
|
+
id: uid("ps-"),
|
|
2897
|
+
projectId: s.project.id,
|
|
2898
|
+
sortOrder: typeof partial.sortOrder === "number" ? partial.sortOrder : maxSortOrder + 1,
|
|
2899
|
+
createdBy: a,
|
|
2900
|
+
updatedBy: a,
|
|
2901
|
+
createdAt: now(),
|
|
2902
|
+
updatedAt: now(),
|
|
2903
|
+
version: 1
|
|
2904
|
+
}));
|
|
2905
|
+
s.processSteps.push(markCowFresh(s, ps));
|
|
2906
|
+
return s;
|
|
2907
|
+
},
|
|
2908
|
+
|
|
2909
|
+
updateProcessStep(snap, stepId, patch, actor) {
|
|
2910
|
+
const s = cowSnap(snap);
|
|
2911
|
+
const ps = cowProcessStep(s, stepId);
|
|
2912
|
+
if (!ps) return s;
|
|
2913
|
+
const allowed = ["name", "description", "epicId", "sortOrder"];
|
|
2914
|
+
for (const k of allowed) {
|
|
2915
|
+
if (Object.prototype.hasOwnProperty.call(patch, k)) ps[k] = patch[k];
|
|
2916
|
+
}
|
|
2917
|
+
bumpAudit(ps, actor);
|
|
2918
|
+
return s;
|
|
2919
|
+
},
|
|
2920
|
+
|
|
2921
|
+
softDeleteProcessStep(snap, stepId, actor) {
|
|
2922
|
+
const s = cowSnap(snap);
|
|
2923
|
+
const ps = cowProcessStep(s, stepId);
|
|
2924
|
+
if (!ps) return s;
|
|
2925
|
+
ps.isDeleted = true;
|
|
2926
|
+
ps.deletedAt = now();
|
|
2927
|
+
ps.deletedBy = normalizeActor(actor);
|
|
2928
|
+
bumpAudit(ps, actor);
|
|
2929
|
+
// SM-167: tickets positioned in this step would otherwise keep a dangling
|
|
2930
|
+
// processStepId — they render in NO cell (the step is gone) AND are excluded
|
|
2931
|
+
// from the backlog's per-release group (which treats a truthy processStepId
|
|
2932
|
+
// as "placed in a cell"). Result: invisible in the map yet still counted
|
|
2933
|
+
// toward their release (e.g. the release-completion warning). Clear the
|
|
2934
|
+
// processStepId — keep the releaseId — so they resurface in the backlog's
|
|
2935
|
+
// "Scheduled for <release> but unplaced" group, where the user can re-place
|
|
2936
|
+
// or unschedule them. Contained stories follow their epic via
|
|
2937
|
+
// syncContainedStoryPositions on the next normalize.
|
|
2938
|
+
for (const probe of s.tickets.slice()) {
|
|
2939
|
+
if (!probe.position || probe.position.processStepId !== stepId) continue;
|
|
2940
|
+
const t = cowTicket(s, probe.id); // COW-aware (SM-240)
|
|
2941
|
+
t.position = normalizePosition(Object.assign({}, t.position, { processStepId: null }));
|
|
2942
|
+
// SM-240 review: soft-deleted tickets are pruned too (normalize's
|
|
2943
|
+
// pruneStaleContainerRefs does) — but without an audit bump.
|
|
2944
|
+
if (!t.isDeleted) bumpAudit(t, actor);
|
|
2945
|
+
}
|
|
2946
|
+
return s;
|
|
2947
|
+
},
|
|
2948
|
+
|
|
2949
|
+
reorderProcessSteps(snap, orderedIds, actor) {
|
|
2950
|
+
const s = cowSnap(snap);
|
|
2951
|
+
const byId = new Map(s.processSteps.map(p => [p.id, p]));
|
|
2952
|
+
const newList = [];
|
|
2953
|
+
orderedIds.forEach((id, idx) => {
|
|
2954
|
+
if (byId.has(id)) {
|
|
2955
|
+
const ps = cowProcessStep(s, id); // COW-aware (SM-240)
|
|
2956
|
+
ps.sortOrder = idx;
|
|
2957
|
+
bumpAudit(ps, actor);
|
|
2958
|
+
newList.push(ps);
|
|
2959
|
+
byId.delete(id);
|
|
2960
|
+
}
|
|
2961
|
+
});
|
|
2962
|
+
for (const ps of byId.values()) newList.push(ps);
|
|
2963
|
+
s.processSteps = newList;
|
|
2964
|
+
return s;
|
|
2965
|
+
},
|
|
2966
|
+
|
|
2967
|
+
/**
|
|
2968
|
+
* SM-247 — split a process step: insert a NEW step directly after the
|
|
2969
|
+
* original and move the chosen epics (and, via the SM-67 cascade, their
|
|
2970
|
+
* contained stories) into it. Loose stories stay with the original — the
|
|
2971
|
+
* split decision is expressed in epics, the unit the user sees. One
|
|
2972
|
+
* snapshot transition (one revision when persisted).
|
|
2973
|
+
*
|
|
2974
|
+
* opts: { name (required), description?, epicIds? } — every epicId must
|
|
2975
|
+
* be a non-deleted epic currently positioned in `processStepId`, else
|
|
2976
|
+
* 422 with the offending ids in `missing`. Empty epicIds = pure
|
|
2977
|
+
* insert-after (an empty new column).
|
|
2978
|
+
*/
|
|
2979
|
+
splitProcessStep(snap, processStepId, opts, actor) {
|
|
2980
|
+
const s = cowSnap(snap);
|
|
2981
|
+
const orig = s.processSteps.find(p => p.id === processStepId && !p.isDeleted);
|
|
2982
|
+
if (!orig) {
|
|
2983
|
+
const e = new Error("process step not found: " + processStepId);
|
|
2984
|
+
e.statusCode = 404;
|
|
2985
|
+
throw e;
|
|
2986
|
+
}
|
|
2987
|
+
const name = (opts && typeof opts.name === "string") ? opts.name.trim() : "";
|
|
2988
|
+
if (!name) {
|
|
2989
|
+
const e = new Error("split requires a name for the new process step");
|
|
2990
|
+
e.statusCode = 400;
|
|
2991
|
+
throw e;
|
|
2992
|
+
}
|
|
2993
|
+
const epicIds = (opts && Array.isArray(opts.epicIds)) ? opts.epicIds : [];
|
|
2994
|
+
const notInStep = epicIds.filter(id => {
|
|
2995
|
+
const t = (s.tickets || []).find(x => x.id === id);
|
|
2996
|
+
return !t || t.isDeleted || t.type !== "epic"
|
|
2997
|
+
|| !t.position || t.position.processStepId !== processStepId;
|
|
2998
|
+
});
|
|
2999
|
+
if (notInStep.length > 0) {
|
|
3000
|
+
const e = new Error("epics not in step " + processStepId + ": " + notInStep.join(", "));
|
|
3001
|
+
e.statusCode = 422;
|
|
3002
|
+
e.kind = "SPLIT_EPICS_NOT_IN_STEP";
|
|
3003
|
+
e.missing = notInStep;
|
|
3004
|
+
throw e;
|
|
3005
|
+
}
|
|
3006
|
+
const a = normalizeActor(actor);
|
|
3007
|
+
const newPs = normalizeProcessStep({
|
|
3008
|
+
id: uid("ps-"),
|
|
3009
|
+
projectId: s.project.id,
|
|
3010
|
+
name: name,
|
|
3011
|
+
description: (opts && typeof opts.description === "string") ? opts.description : "",
|
|
3012
|
+
sortOrder: (orig.sortOrder || 0) + 1, // renumbered below
|
|
3013
|
+
createdBy: a, updatedBy: a,
|
|
3014
|
+
createdAt: now(), updatedAt: now(),
|
|
3015
|
+
version: 1
|
|
3016
|
+
});
|
|
3017
|
+
s.processSteps.push(markCowFresh(s, newPs));
|
|
3018
|
+
// Renumber the LIVE steps contiguously with newPs directly after orig.
|
|
3019
|
+
const live = s.processSteps.filter(p => !p.isDeleted && p.id !== newPs.id)
|
|
3020
|
+
.slice().sort((x, y) => (x.sortOrder || 0) - (y.sortOrder || 0));
|
|
3021
|
+
const order = [];
|
|
3022
|
+
for (const p of live) {
|
|
3023
|
+
order.push(p.id);
|
|
3024
|
+
if (p.id === processStepId) order.push(newPs.id);
|
|
3025
|
+
}
|
|
3026
|
+
order.forEach((id, idx) => {
|
|
3027
|
+
const probe = s.processSteps.find(p => p.id === id);
|
|
3028
|
+
if (!probe || probe.sortOrder === idx) return;
|
|
3029
|
+
const ps = cowProcessStep(s, id);
|
|
3030
|
+
ps.sortOrder = idx;
|
|
3031
|
+
if (id !== newPs.id) bumpAudit(ps, actor);
|
|
3032
|
+
});
|
|
3033
|
+
// Move the chosen epics; contained stories follow via the SM-67 cascade.
|
|
3034
|
+
for (const id of epicIds) {
|
|
3035
|
+
const epic = findTicket(s, id); // COW-aware
|
|
3036
|
+
epic.position = normalizePosition(Object.assign({}, epic.position, {
|
|
3037
|
+
processStepId: newPs.id
|
|
3038
|
+
}));
|
|
3039
|
+
bumpAudit(epic, actor);
|
|
3040
|
+
_cascadeEpicPositionToContainedStories(s, id, actor);
|
|
3041
|
+
}
|
|
3042
|
+
return s;
|
|
3043
|
+
},
|
|
3044
|
+
|
|
3045
|
+
/**
|
|
3046
|
+
* Reorder tickets within a scope (epic, cell, or backlog). Mirrors the
|
|
3047
|
+
* exact shape of reorderProcessSteps: takes an `orderedIds` array and
|
|
3048
|
+
* assigns sortOrder = idx to each. If `scope` is provided (with
|
|
3049
|
+
* releaseId/processStepId/epicId fields), it is ALSO applied to every
|
|
3050
|
+
* ticket in the list — which covers cross-container moves cleanly.
|
|
3051
|
+
* Tickets not in orderedIds are untouched.
|
|
3052
|
+
*/
|
|
3053
|
+
reorderTickets(snap, orderedIds, scope, actor) {
|
|
3054
|
+
const s = cowSnap(snap);
|
|
3055
|
+
const byId = new Map(s.tickets.map(t => [t.id, t]));
|
|
3056
|
+
// SM-52: scope.epicId is interpreted as the target container; it never
|
|
3057
|
+
// lands on position.epicId. We re-parent via contains-links instead.
|
|
3058
|
+
const scopeEpicId = scope && typeof scope.epicId === "string" ? scope.epicId : null;
|
|
3059
|
+
// SM-67: when scope re-parents into an epic, the epic's release+processStep
|
|
3060
|
+
// dictate the final position for contained stories — override whatever
|
|
3061
|
+
// releaseId/processStepId scope passed in.
|
|
3062
|
+
const epicPos = scopeEpicId ? _inheritPositionFromEpic(s, scopeEpicId, {}) : null;
|
|
3063
|
+
orderedIds.forEach((id, idx) => {
|
|
3064
|
+
const probe = byId.get(id);
|
|
3065
|
+
if (!probe || probe.isDeleted) return;
|
|
3066
|
+
const t = cowTicket(s, id); // COW-aware (SM-240)
|
|
3067
|
+
if (scope) {
|
|
3068
|
+
const useInherit = (t.type !== "epic" && epicPos);
|
|
3069
|
+
t.position = normalizePosition({
|
|
3070
|
+
releaseId: useInherit ? (epicPos.releaseId || null)
|
|
3071
|
+
: (scope.releaseId != null ? scope.releaseId : null),
|
|
3072
|
+
processStepId: useInherit ? (epicPos.processStepId || null)
|
|
3073
|
+
: (scope.processStepId != null ? scope.processStepId : null),
|
|
3074
|
+
epicId: null, // never persisted
|
|
3075
|
+
sortOrder: idx
|
|
3076
|
+
});
|
|
3077
|
+
if (t.type !== "epic") _setContainerEpic(s, id, scopeEpicId, actor);
|
|
3078
|
+
// SM-67: if an epic is among the reordered tickets and scope changed
|
|
3079
|
+
// its position, cascade to its contained stories.
|
|
3080
|
+
if (t.type === "epic") {
|
|
3081
|
+
_cascadeEpicPositionToContainedStories(s, id, actor);
|
|
3082
|
+
}
|
|
3083
|
+
} else {
|
|
3084
|
+
t.position = normalizePosition(Object.assign({}, t.position, { sortOrder: idx, epicId: null }));
|
|
3085
|
+
}
|
|
3086
|
+
bumpAudit(t, actor);
|
|
3087
|
+
});
|
|
3088
|
+
recomputeEpicStatuses(s); // SM-236 (scope.epicId re-parents → roll up)
|
|
3089
|
+
return s;
|
|
3090
|
+
},
|
|
3091
|
+
|
|
3092
|
+
// SM-44 — typed ticket links --------------------------------------------
|
|
3093
|
+
//
|
|
3094
|
+
// ticket.links is an array of `{id, linkTypeId, targetTicketId, label?,
|
|
3095
|
+
// createdAt, createdBy}`. linkTypeId references a project.linkTypes entry
|
|
3096
|
+
// (SM-45 will introduce the per-project list; defaults work without it).
|
|
3097
|
+
//
|
|
3098
|
+
// Validation enforced on add/setLinks:
|
|
3099
|
+
// - self-link forbidden (sourceId === targetId)
|
|
3100
|
+
// - target must exist in snapshot.tickets
|
|
3101
|
+
// - duplicate forbidden (same linkTypeId + targetTicketId)
|
|
3102
|
+
// - for cycle-checked types (blocks, predecessor-of, contains): adding
|
|
3103
|
+
// the link must not introduce a cycle in the same-type subgraph.
|
|
3104
|
+
|
|
3105
|
+
addLink(snap, sourceTicketId, link, actor) {
|
|
3106
|
+
const s = cowSnap(snap);
|
|
3107
|
+
const source = findTicket(s, sourceTicketId); // COW-aware (SM-240)
|
|
3108
|
+
if (!source) {
|
|
3109
|
+
const e = new Error("source ticket not found: " + sourceTicketId);
|
|
3110
|
+
e.statusCode = 404;
|
|
3111
|
+
throw e;
|
|
3112
|
+
}
|
|
3113
|
+
const normalized = normalizeLink(link || {});
|
|
3114
|
+
// Stamp the link's createdBy from the acting actor when the caller didn't
|
|
3115
|
+
// supply one explicitly — otherwise normalizeLink defaults it to the
|
|
3116
|
+
// "unknown" fallback even though we know who is adding the link. (SM-149)
|
|
3117
|
+
if (actor != null && (!link || link.createdBy == null)) {
|
|
3118
|
+
normalized.createdBy = normalizeActor(actor);
|
|
3119
|
+
}
|
|
3120
|
+
validateLink(s, sourceTicketId, normalized, source.links || []);
|
|
3121
|
+
source.links = (source.links || []).concat([normalized]);
|
|
3122
|
+
bumpAudit(source, actor);
|
|
3123
|
+
recomputeEpicStatuses(s); // SM-236 (a contains-link adds a child to an epic)
|
|
3124
|
+
return s;
|
|
3125
|
+
},
|
|
3126
|
+
|
|
3127
|
+
removeLink(snap, sourceTicketId, linkId, actor) {
|
|
3128
|
+
const s = cowSnap(snap);
|
|
3129
|
+
const source = findTicket(s, sourceTicketId); // COW-aware (SM-240)
|
|
3130
|
+
if (!source) {
|
|
3131
|
+
const e = new Error("source ticket not found: " + sourceTicketId);
|
|
3132
|
+
e.statusCode = 404;
|
|
3133
|
+
throw e;
|
|
3134
|
+
}
|
|
3135
|
+
const before = (source.links || []).length;
|
|
3136
|
+
source.links = (source.links || []).filter(l => l.id !== linkId);
|
|
3137
|
+
if (source.links.length !== before) bumpAudit(source, actor);
|
|
3138
|
+
recomputeEpicStatuses(s); // SM-236 (removing a contains-link drops a child)
|
|
3139
|
+
return s;
|
|
3140
|
+
},
|
|
3141
|
+
|
|
3142
|
+
setLinks(snap, sourceTicketId, links, actor) {
|
|
3143
|
+
const s = cowSnap(snap);
|
|
3144
|
+
const source = findTicket(s, sourceTicketId); // COW-aware (SM-240)
|
|
3145
|
+
if (!source) {
|
|
3146
|
+
const e = new Error("source ticket not found: " + sourceTicketId);
|
|
3147
|
+
e.statusCode = 404;
|
|
3148
|
+
throw e;
|
|
3149
|
+
}
|
|
3150
|
+
const normalized = (Array.isArray(links) ? links : []).map(normalizeLink);
|
|
3151
|
+
// Validate each link against the snapshot. Run validation against the
|
|
3152
|
+
// accumulating set so duplicates within the new list are also caught.
|
|
3153
|
+
const accepted = [];
|
|
3154
|
+
for (const link of normalized) {
|
|
3155
|
+
validateLink(s, sourceTicketId, link, accepted);
|
|
3156
|
+
accepted.push(link);
|
|
3157
|
+
}
|
|
3158
|
+
source.links = accepted;
|
|
3159
|
+
bumpAudit(source, actor);
|
|
3160
|
+
recomputeEpicStatuses(s); // SM-236 (contains-links may have changed)
|
|
3161
|
+
return s;
|
|
3162
|
+
}
|
|
3163
|
+
};
|
|
3164
|
+
|
|
3165
|
+
// ---------------------------------------------------------------------------
|
|
3166
|
+
// SM-254 — default scaffold. Every project should be immediately usable: a
|
|
3167
|
+
// fresh project gets one default release + one default process step, so the
|
|
3168
|
+
// Story Map has a real cell from the first load. Seeded by BOTH server and MCP
|
|
3169
|
+
// create paths (the UI no longer double-seeds). Idempotent: only fills what's
|
|
3170
|
+
// missing, so re-running it (or running it on a non-empty snapshot) is a no-op.
|
|
3171
|
+
const DEFAULT_RELEASE_NAME = "v1.0";
|
|
3172
|
+
const DEFAULT_PROCESS_STEP_NAME = "Activities";
|
|
3173
|
+
|
|
3174
|
+
function seedDefaultScaffold(snap, actor) {
|
|
3175
|
+
let s = snap;
|
|
3176
|
+
const hasRelease = (s.releases || []).some(r => !r.isDeleted);
|
|
3177
|
+
const hasStep = (s.processSteps || []).some(p => !p.isDeleted);
|
|
3178
|
+
if (!hasRelease) s = ops.createRelease(s, { name: DEFAULT_RELEASE_NAME }, actor);
|
|
3179
|
+
if (!hasStep) s = ops.createProcessStep(s, { name: DEFAULT_PROCESS_STEP_NAME }, actor);
|
|
3180
|
+
return s;
|
|
3181
|
+
}
|
|
3182
|
+
|
|
3183
|
+
// ---------------------------------------------------------------------------
|
|
3184
|
+
// SM-240 — op-path normalize-invariance: derivedHealth post-pass.
|
|
3185
|
+
//
|
|
3186
|
+
// normalizeSnapshot annotates every test-definition with its derivedHealth
|
|
3187
|
+
// (_annotateDerivedHealth). With the store no longer running a full normalize
|
|
3188
|
+
// on the op path, the ops themselves must leave that projection consistent —
|
|
3189
|
+
// empirically the ONLY normalize-divergence an op can produce (the COW
|
|
3190
|
+
// guardrail suite proves it per op). Instead of analysing 35 ops one by one
|
|
3191
|
+
// (and re-analysing every future op), every op is wrapped: when its output is
|
|
3192
|
+
// a COW snapshot, refresh derivedHealth copy-on-write — touching only the
|
|
3193
|
+
// test-definitions whose value actually changed, so structural sharing for
|
|
3194
|
+
// everything else is preserved.
|
|
3195
|
+
// ---------------------------------------------------------------------------
|
|
3196
|
+
|
|
3197
|
+
function refreshDerivedHealthCow(s) {
|
|
3198
|
+
const tickets = s.tickets || [];
|
|
3199
|
+
for (const probe of tickets.slice()) {
|
|
3200
|
+
if (probe.type !== "test-definition" || probe.isDeleted) continue;
|
|
3201
|
+
const h = computeDerivedHealth(s, probe);
|
|
3202
|
+
if ((probe.derivedHealth || "") === h) continue;
|
|
3203
|
+
const td = cowTicket(s, probe.id);
|
|
3204
|
+
td.derivedHealth = h;
|
|
3205
|
+
}
|
|
3206
|
+
}
|
|
3207
|
+
|
|
3208
|
+
for (const _opName of Object.keys(ops)) {
|
|
3209
|
+
const _orig = ops[_opName];
|
|
3210
|
+
ops[_opName] = function () {
|
|
3211
|
+
const out = _orig.apply(this, arguments);
|
|
3212
|
+
if (out && out.__cowFresh) refreshDerivedHealthCow(out);
|
|
3213
|
+
return out;
|
|
3214
|
+
};
|
|
3215
|
+
}
|
|
3216
|
+
|
|
3217
|
+
// ---------------------------------------------------------------------------
|
|
3218
|
+
// SM-44 — link validation helpers (pure, throwing)
|
|
3219
|
+
// ---------------------------------------------------------------------------
|
|
3220
|
+
|
|
3221
|
+
function validateLink(snap, sourceTicketId, link, existingLinks) {
|
|
3222
|
+
if (!link.targetTicketId) {
|
|
3223
|
+
const e = new Error("link.targetTicketId is required");
|
|
3224
|
+
e.statusCode = 400; e.kind = "LINK_TARGET_REQUIRED";
|
|
3225
|
+
throw e;
|
|
3226
|
+
}
|
|
3227
|
+
if (link.targetTicketId === sourceTicketId) {
|
|
3228
|
+
const e = new Error("self-link forbidden (source === target)");
|
|
3229
|
+
e.statusCode = 400; e.kind = "LINK_SELF";
|
|
3230
|
+
throw e;
|
|
3231
|
+
}
|
|
3232
|
+
const target = snap.tickets.find(t => t.id === link.targetTicketId);
|
|
3233
|
+
if (!target) {
|
|
3234
|
+
const e = new Error("link.targetTicketId not found: " + link.targetTicketId);
|
|
3235
|
+
e.statusCode = 404; e.kind = "LINK_TARGET_MISSING";
|
|
3236
|
+
throw e;
|
|
3237
|
+
}
|
|
3238
|
+
// Duplicate-check on (linkTypeId, targetTicketId).
|
|
3239
|
+
for (const existing of existingLinks) {
|
|
3240
|
+
if (existing.linkTypeId === link.linkTypeId && existing.targetTicketId === link.targetTicketId) {
|
|
3241
|
+
const e = new Error("duplicate link (" + link.linkTypeId + " -> " + link.targetTicketId + ")");
|
|
3242
|
+
e.statusCode = 409; e.kind = "LINK_DUPLICATE";
|
|
3243
|
+
throw e;
|
|
3244
|
+
}
|
|
3245
|
+
}
|
|
3246
|
+
// Cycle-check for directional semantics. SM-45: resolve via the
|
|
3247
|
+
// project's linkType catalogue when available — falls back to the
|
|
3248
|
+
// hardcoded ID list (SM-44 behaviour) if linkTypes is missing or the
|
|
3249
|
+
// ID isn't registered. A cycle in the same-linkType subgraph is
|
|
3250
|
+
// rejected.
|
|
3251
|
+
if (isCycleChecked(snap.project, link.linkTypeId)) {
|
|
3252
|
+
if (wouldCreateCycle(snap, sourceTicketId, link.linkTypeId, link.targetTicketId)) {
|
|
3253
|
+
const e = new Error("link would create a cycle (" + link.linkTypeId + ")");
|
|
3254
|
+
e.statusCode = 409; e.kind = "LINK_CYCLE";
|
|
3255
|
+
throw e;
|
|
3256
|
+
}
|
|
3257
|
+
}
|
|
3258
|
+
}
|
|
3259
|
+
|
|
3260
|
+
/**
|
|
3261
|
+
* SM-45: resolve whether a given linkTypeId is cycle-checked. Prefer the
|
|
3262
|
+
* project's linkType.semantic lookup; fall back to the SM-44 hardcoded
|
|
3263
|
+
* ID list (so snapshots created before SM-45 still behave identically).
|
|
3264
|
+
*/
|
|
3265
|
+
function isCycleChecked(project, linkTypeId) {
|
|
3266
|
+
if (project && Array.isArray(project.linkTypes)) {
|
|
3267
|
+
const lt = project.linkTypes.find(t => t.id === linkTypeId);
|
|
3268
|
+
if (lt) {
|
|
3269
|
+
// SM-94: explicit per-linkType cycleCheck:true overrides semantic check.
|
|
3270
|
+
if (lt.cycleCheck === true) return true;
|
|
3271
|
+
return CYCLE_CHECKED_SEMANTICS.indexOf(lt.semantic) >= 0;
|
|
3272
|
+
}
|
|
3273
|
+
}
|
|
3274
|
+
// Fallback for snapshots/tests without a registered linkTypes catalogue.
|
|
3275
|
+
return CYCLE_CHECKED_LINK_TYPES.indexOf(linkTypeId) >= 0;
|
|
3276
|
+
}
|
|
3277
|
+
|
|
3278
|
+
/**
|
|
3279
|
+
* Build the existing same-linkTypeId adjacency (source → set<target>) and
|
|
3280
|
+
* check if `target` can reach `source` by following edges of the same type.
|
|
3281
|
+
* If yes, adding source → target would close a cycle.
|
|
3282
|
+
*/
|
|
3283
|
+
function wouldCreateCycle(snap, sourceId, linkTypeId, targetId) {
|
|
3284
|
+
if (sourceId === targetId) return true; // belt+suspenders; caller already checked
|
|
3285
|
+
const adj = new Map();
|
|
3286
|
+
for (const t of snap.tickets) {
|
|
3287
|
+
for (const l of t.links || []) {
|
|
3288
|
+
if (l.linkTypeId !== linkTypeId) continue;
|
|
3289
|
+
if (!adj.has(t.id)) adj.set(t.id, new Set());
|
|
3290
|
+
adj.get(t.id).add(l.targetTicketId);
|
|
3291
|
+
}
|
|
3292
|
+
}
|
|
3293
|
+
// BFS from target — if we can reach source, the new edge source→target
|
|
3294
|
+
// would form a cycle.
|
|
3295
|
+
const visited = new Set([targetId]);
|
|
3296
|
+
const queue = [targetId];
|
|
3297
|
+
while (queue.length > 0) {
|
|
3298
|
+
const cur = queue.shift();
|
|
3299
|
+
if (cur === sourceId) return true;
|
|
3300
|
+
const neighbours = adj.get(cur);
|
|
3301
|
+
if (!neighbours) continue;
|
|
3302
|
+
for (const n of neighbours) {
|
|
3303
|
+
if (visited.has(n)) continue;
|
|
3304
|
+
visited.add(n);
|
|
3305
|
+
queue.push(n);
|
|
3306
|
+
}
|
|
3307
|
+
}
|
|
3308
|
+
return false;
|
|
3309
|
+
}
|
|
3310
|
+
|
|
3311
|
+
// ---------------------------------------------------------------------------
|
|
3312
|
+
// Transition rule catalog + walker (SM-102 / SM-103)
|
|
3313
|
+
// ---------------------------------------------------------------------------
|
|
3314
|
+
//
|
|
3315
|
+
// A closed, declarative catalog of transition gates. Each rule owns BOTH its
|
|
3316
|
+
// applicability (`enabledFor(type, project)`) and its check
|
|
3317
|
+
// (`check(ticket, project, ctx)`). This makes the SM-100 / SM-101 bug class
|
|
3318
|
+
// structurally impossible: a rule cannot fire for a type whose backing field
|
|
3319
|
+
// is configured off, because the same predicate that hides the field is the
|
|
3320
|
+
// one that gates the rule.
|
|
3321
|
+
//
|
|
3322
|
+
// Rule shape:
|
|
3323
|
+
// {
|
|
3324
|
+
// id: string, // stable catalog key
|
|
3325
|
+
// label: string, // human-readable
|
|
3326
|
+
// enabledFor(type, project): boolean, // does this rule apply to the type?
|
|
3327
|
+
// check(ticket, project, ctx): // null = satisfied,
|
|
3328
|
+
// null | { kind, missing?, message? } // non-null = violation
|
|
3329
|
+
// }
|
|
3330
|
+
//
|
|
3331
|
+
// `ctx` is the transition context { currIdx, nextIdx, toStatus, toCategory }.
|
|
3332
|
+
//
|
|
3333
|
+
// SM-103 shipped the seam (catalog + walker). SM-104 fills in the DoR/DoD
|
|
3334
|
+
// rules and wires the walker into validateStatusTransition; the SM-101
|
|
3335
|
+
// lockstep skip now lives INSIDE each rule's `enabledFor`. SM-105/106 add the
|
|
3336
|
+
// global test-type rules and remove the last hardcoded gate blocks.
|
|
3337
|
+
|
|
3338
|
+
const TRANSITION_RULES = {
|
|
3339
|
+
"dor.allRequiredMet": {
|
|
3340
|
+
id: "dor.allRequiredMet",
|
|
3341
|
+
label: "Definition of Ready met",
|
|
3342
|
+
enabledFor: (type, project) =>
|
|
3343
|
+
getEntityTypeConfig(project || {}, type).showDefinitionOfReady !== false,
|
|
3344
|
+
check: (ticket) => {
|
|
3345
|
+
const items = (ticket && ticket.definitionOfReady && ticket.definitionOfReady.items) || [];
|
|
3346
|
+
const missing = items.filter(it => it.required !== false && !it.checked);
|
|
3347
|
+
if (missing.length === 0) return null;
|
|
3348
|
+
return {
|
|
3349
|
+
kind: "DoR",
|
|
3350
|
+
message: "definition of ready not met",
|
|
3351
|
+
missing: missing.map(it => ({ id: it.id, label: it.label }))
|
|
3352
|
+
};
|
|
3353
|
+
}
|
|
3354
|
+
},
|
|
3355
|
+
"dod.allRequiredMet": {
|
|
3356
|
+
id: "dod.allRequiredMet",
|
|
3357
|
+
label: "Definition of Done met",
|
|
3358
|
+
enabledFor: (type, project) =>
|
|
3359
|
+
getEntityTypeConfig(project || {}, type).showDefinitionOfDone !== false,
|
|
3360
|
+
check: (ticket) => {
|
|
3361
|
+
const items = (ticket && ticket.definitionOfDone && ticket.definitionOfDone.items) || [];
|
|
3362
|
+
const missing = items.filter(it => it.required !== false && !it.checked);
|
|
3363
|
+
if (missing.length === 0) return null;
|
|
3364
|
+
return {
|
|
3365
|
+
kind: "DoD",
|
|
3366
|
+
message: "definition of done not met",
|
|
3367
|
+
missing: missing.map(it => ({ id: it.id, label: it.label }))
|
|
3368
|
+
};
|
|
3369
|
+
}
|
|
3370
|
+
},
|
|
3371
|
+
// SM-105: test-definition backlog-exit gate. A test must point at ≥1 ticket
|
|
3372
|
+
// it validates (outbound `tests` link) before leaving backlog. This is a
|
|
3373
|
+
// GLOBAL rule (see GLOBAL_TRANSITION_RULES) — it isn't attached to a
|
|
3374
|
+
// workflow transition; the backlog-exit condition lives in check via ctx.
|
|
3375
|
+
"links.hasTestTarget": {
|
|
3376
|
+
id: "links.hasTestTarget",
|
|
3377
|
+
label: "Test-definition links to at least one target",
|
|
3378
|
+
enabledFor: (type) => type === "test-definition",
|
|
3379
|
+
check: (ticket, project, ctx) => {
|
|
3380
|
+
// Only gate the transition OUT of backlog (currIdx === 0). A test can
|
|
3381
|
+
// leave backlog once; later forward moves aren't re-checked (a user may
|
|
3382
|
+
// have removed the last link after the fact — the gate is on the exit).
|
|
3383
|
+
if (!ctx || ctx.currIdx !== 0) return null;
|
|
3384
|
+
const links = Array.isArray(ticket && ticket.links) ? ticket.links : [];
|
|
3385
|
+
const hasTarget = links.some(l => l && l.linkTypeId === TEST_TARGET_LINK_TYPE_ID);
|
|
3386
|
+
if (hasTarget) return null;
|
|
3387
|
+
return {
|
|
3388
|
+
kind: "TEST_TARGETS",
|
|
3389
|
+
message: "a test-definition must link to at least one ticket it tests (linkType: 'tests') before leaving backlog"
|
|
3390
|
+
};
|
|
3391
|
+
}
|
|
3392
|
+
},
|
|
3393
|
+
// SM-106: test-execution outcome gate. Moving a test-execution to a
|
|
3394
|
+
// done-category status requires a non-pending outcome (the outcome IS the
|
|
3395
|
+
// gate — the DoD checklist isn't the right tool for tests). enabledFor uses
|
|
3396
|
+
// showTestOutcome — lockstep by design: hide the outcome section ⇒ gate N/A.
|
|
3397
|
+
"outcome.notPending": {
|
|
3398
|
+
id: "outcome.notPending",
|
|
3399
|
+
label: "Test-execution outcome is not pending",
|
|
3400
|
+
enabledFor: (type, project) =>
|
|
3401
|
+
getEntityTypeConfig(project || {}, type).showTestOutcome !== false,
|
|
3402
|
+
check: (ticket, project, ctx) => {
|
|
3403
|
+
if (!ctx || ctx.toCategory !== "done") return null;
|
|
3404
|
+
if (getEffectiveOutcome(ticket) !== "pending") return null;
|
|
3405
|
+
return {
|
|
3406
|
+
kind: "TEST_OUTCOME",
|
|
3407
|
+
message: "test-execution outcome is still pending — record per-step results or set outcomeOverride before closing"
|
|
3408
|
+
};
|
|
3409
|
+
}
|
|
3410
|
+
}
|
|
3411
|
+
};
|
|
3412
|
+
|
|
3413
|
+
// Rules evaluated on EVERY forward transition, in addition to the matched
|
|
3414
|
+
// transition's own rules. Their applicability is decided entirely by each
|
|
3415
|
+
// rule's enabledFor + the transition ctx inside its check (e.g. backlog-exit
|
|
3416
|
+
// only, or done-category only). These are the gates that used to be hardcoded
|
|
3417
|
+
// as type-checks inside validateStatusTransition.
|
|
3418
|
+
const GLOBAL_TRANSITION_RULES = ["links.hasTestTarget", "outcome.notPending"];
|
|
3419
|
+
|
|
3420
|
+
/**
|
|
3421
|
+
* Legacy adapter: map a workflow transition to its catalog rule ids. Existing
|
|
3422
|
+
* snapshots carry `requireGate: 'DoR'|'DoD'`; newer ones may carry an explicit
|
|
3423
|
+
* `rules: [...]`. requireGate wins (back-compat); otherwise the transition's
|
|
3424
|
+
* own rules[] is used. No snapshot migration needed.
|
|
3425
|
+
*/
|
|
3426
|
+
function transitionRuleIds(transition) {
|
|
3427
|
+
if (!transition) return [];
|
|
3428
|
+
if (transition.requireGate === "DoR") return ["dor.allRequiredMet"];
|
|
3429
|
+
if (transition.requireGate === "DoD") return ["dod.allRequiredMet"];
|
|
3430
|
+
return Array.isArray(transition.rules) ? transition.rules : [];
|
|
3431
|
+
}
|
|
3432
|
+
|
|
3433
|
+
/**
|
|
3434
|
+
* Pure walker. Evaluates the given rule ids against the catalog and returns
|
|
3435
|
+
* the list of violations in ruleIds order. A rule is skipped when its id is
|
|
3436
|
+
* not in the catalog or when `enabledFor(type, project)` is false.
|
|
3437
|
+
*
|
|
3438
|
+
* @param {string[]} ruleIds
|
|
3439
|
+
* @param {{ ticket: object, project: object, ctx?: object }} context
|
|
3440
|
+
* @param {object} [catalog] defaults to TRANSITION_RULES; tests inject their own.
|
|
3441
|
+
* @returns {Array<{ kind, missing?, message? }>}
|
|
3442
|
+
*/
|
|
3443
|
+
function evaluateTransitionRules(ruleIds, context, catalog) {
|
|
3444
|
+
catalog = catalog || TRANSITION_RULES;
|
|
3445
|
+
const ticket = context && context.ticket;
|
|
3446
|
+
const project = context && context.project;
|
|
3447
|
+
const ctx = context && context.ctx;
|
|
3448
|
+
const type = ticket && ticket.type;
|
|
3449
|
+
const failures = [];
|
|
3450
|
+
const ids = Array.isArray(ruleIds) ? ruleIds : [];
|
|
3451
|
+
for (const id of ids) {
|
|
3452
|
+
const rule = catalog[id];
|
|
3453
|
+
if (!rule) continue;
|
|
3454
|
+
if (typeof rule.enabledFor === "function" && !rule.enabledFor(type, project)) continue;
|
|
3455
|
+
const result = typeof rule.check === "function" ? rule.check(ticket, project, ctx) : null;
|
|
3456
|
+
if (result) failures.push(result);
|
|
3457
|
+
}
|
|
3458
|
+
return failures;
|
|
3459
|
+
}
|
|
3460
|
+
|
|
3461
|
+
// ---------------------------------------------------------------------------
|
|
3462
|
+
// validateStatusTransition — DoR / DoD-Gate-Check (pure, throwing)
|
|
3463
|
+
// ---------------------------------------------------------------------------
|
|
3464
|
+
//
|
|
3465
|
+
// Identische Logik wie server/validation.js#changeStatusTransition (das ist
|
|
3466
|
+
// jetzt ein Thin-Delegate). Wurde nach core gezogen, damit das Frontend
|
|
3467
|
+
// dieselbe Validierung VOR dem `store.changeStatus`-Commit fahren kann —
|
|
3468
|
+
// snapshot-PUT bypasst Transition-Validation by design (state-replace,
|
|
3469
|
+
// nicht workflow-step), also muss das Gate clientseitig greifen, bevor
|
|
3470
|
+
// der Commit überhaupt entsteht. Wirft `{statusCode, kind, missing}` bei
|
|
3471
|
+
// Verletzung.
|
|
3472
|
+
|
|
3473
|
+
function validateStatusTransition(ticket, newStatus, project) {
|
|
3474
|
+
// SM-237: an epic's status is DERIVED (roll-up from its contained stories) —
|
|
3475
|
+
// no path may set it manually. This is the single choke-point shared by the
|
|
3476
|
+
// REST status route, store.changeStatusGated and the MCP status tools.
|
|
3477
|
+
if (ticket && ticket.type === "epic") {
|
|
3478
|
+
const e = new Error("epic status is derived from its contained stories — move the stories instead");
|
|
3479
|
+
e.statusCode = 422;
|
|
3480
|
+
e.kind = "EPIC_STATUS_DERIVED";
|
|
3481
|
+
throw e;
|
|
3482
|
+
}
|
|
3483
|
+
if (typeof newStatus !== "string") {
|
|
3484
|
+
const e = new Error("status invalid: " + newStatus);
|
|
3485
|
+
e.statusCode = 400;
|
|
3486
|
+
throw e;
|
|
3487
|
+
}
|
|
3488
|
+
const wf = getWorkflowForType(project || {}, ticket && ticket.type);
|
|
3489
|
+
const statusIds = wf.statuses.map(s => (typeof s === "string" ? s : s.id));
|
|
3490
|
+
if (!statusIds.includes(newStatus)) {
|
|
3491
|
+
const e = new Error("status invalid: " + newStatus + " (workflow: " + statusIds.join(", ") + ")");
|
|
3492
|
+
e.statusCode = 400;
|
|
3493
|
+
throw e;
|
|
3494
|
+
}
|
|
3495
|
+
// Reverse moves (target index < current index) and same-state moves are
|
|
3496
|
+
// unrestricted: a Reopen-style move shouldn't require gates. Forward
|
|
3497
|
+
// moves go through the named-transition matcher.
|
|
3498
|
+
const order = {};
|
|
3499
|
+
statusIds.forEach((id, i) => { order[id] = i; });
|
|
3500
|
+
const currIdx = order[ticket.status];
|
|
3501
|
+
const nextIdx = order[newStatus];
|
|
3502
|
+
if (currIdx == null || nextIdx == null || nextIdx <= currIdx) return;
|
|
3503
|
+
// Find matching named transition: target equals newStatus AND
|
|
3504
|
+
// (allowFromAny OR ticket.status in fromStatuses).
|
|
3505
|
+
const transitions = Array.isArray(wf.transitions) ? wf.transitions : [];
|
|
3506
|
+
const matching = transitions.filter(tr =>
|
|
3507
|
+
tr.toStatus === newStatus &&
|
|
3508
|
+
(tr.allowFromAny || (Array.isArray(tr.fromStatuses) && tr.fromStatuses.includes(ticket.status)))
|
|
3509
|
+
);
|
|
3510
|
+
if (matching.length === 0) {
|
|
3511
|
+
const e = new Error("transition not allowed: " + ticket.status + " → " + newStatus);
|
|
3512
|
+
e.statusCode = 422; e.kind = "TRANSITION";
|
|
3513
|
+
throw e;
|
|
3514
|
+
}
|
|
3515
|
+
// If multiple match (source-specific + allowFromAny), prefer the source-
|
|
3516
|
+
// specific one — that's the more restrictive author intent. Both should
|
|
3517
|
+
// typically carry the same gate, but if not, the specific transition's
|
|
3518
|
+
// gate wins.
|
|
3519
|
+
matching.sort((a, b) => Number(!!a.allowFromAny) - Number(!!b.allowFromAny));
|
|
3520
|
+
const matched = matching[0];
|
|
3521
|
+
// SM-104: gate evaluation is table-driven. The matched transition's
|
|
3522
|
+
// requireGate (or explicit rules[]) maps to catalog rule ids; each rule
|
|
3523
|
+
// decides its OWN applicability via enabledFor — the SM-101 lockstep skip
|
|
3524
|
+
// (entityTypeConfig hides the section ⇒ gate N/A) now lives inside the
|
|
3525
|
+
// rule, not here. The first violation is thrown with the same shape as
|
|
3526
|
+
// before ({ statusCode: 422, kind, missing? }).
|
|
3527
|
+
const wfStatus = wf.statuses.find(s => (typeof s === "string" ? s : s.id) === newStatus);
|
|
3528
|
+
const toCategory = wfStatus && typeof wfStatus === "object" ? wfStatus.category : null;
|
|
3529
|
+
const ctx = { currIdx, nextIdx, toStatus: newStatus, toCategory };
|
|
3530
|
+
const failures = evaluateTransitionRules(
|
|
3531
|
+
[...transitionRuleIds(matched), ...GLOBAL_TRANSITION_RULES],
|
|
3532
|
+
{ ticket, project, ctx });
|
|
3533
|
+
if (failures.length > 0) {
|
|
3534
|
+
const f = failures[0];
|
|
3535
|
+
const e = new Error(f.message || "transition gate not met");
|
|
3536
|
+
e.statusCode = 422;
|
|
3537
|
+
e.kind = f.kind;
|
|
3538
|
+
if (f.missing) e.missing = f.missing;
|
|
3539
|
+
throw e;
|
|
3540
|
+
}
|
|
3541
|
+
// SM-106: validateStatusTransition is now fully walker-driven — every gate
|
|
3542
|
+
// (DoR, DoD, test-targets, test-outcome) is a declarative catalog rule. No
|
|
3543
|
+
// type-specific gate code remains here.
|
|
3544
|
+
}
|
|
3545
|
+
|
|
3546
|
+
// ---------------------------------------------------------------------------
|
|
3547
|
+
// tickets — render-aggregation helpers (consumed by the Story-Map renderer)
|
|
3548
|
+
// ---------------------------------------------------------------------------
|
|
3549
|
+
//
|
|
3550
|
+
// Diese Helper sind PURE — sie selektieren/sortieren nur, mutieren nichts.
|
|
3551
|
+
// Sie konsumieren das normalisierte Snapshot-Format und liefern Listen,
|
|
3552
|
+
// die der Renderer 1:1 in Zellen / Spalten / Zeilen umsetzt. Tests gegen
|
|
3553
|
+
// die Helper sind robuster als Tests gegen den Renderer.
|
|
3554
|
+
|
|
3555
|
+
function _byTypeStory(t) {
|
|
3556
|
+
// SM-196: spec-layer types (requirement/spec-module) are NOT story cards —
|
|
3557
|
+
// they never appear in story-map cells or the backlog. isBoardWorkItem also
|
|
3558
|
+
// excludes epics (containers), preserving the prior behaviour.
|
|
3559
|
+
return !t.isDeleted && isBoardWorkItem(t.type);
|
|
3560
|
+
}
|
|
3561
|
+
function _byTypeEpic(t) {
|
|
3562
|
+
return !t.isDeleted && t.type === "epic";
|
|
3563
|
+
}
|
|
3564
|
+
function _sortBySortOrder(a, b) {
|
|
3565
|
+
return ((a.position && a.position.sortOrder) || 0) - ((b.position && b.position.sortOrder) || 0);
|
|
3566
|
+
}
|
|
3567
|
+
|
|
3568
|
+
// SM-196 R-2: natural compare of dotted section paths ("2.1" < "2.10" < "10.1").
|
|
3569
|
+
// Each dot-segment is compared numerically when both sides are numeric, else
|
|
3570
|
+
// lexically — so "2.1a" still orders sanely. Empty paths sort first.
|
|
3571
|
+
function compareSectionPath(a, b) {
|
|
3572
|
+
const pa = String(a == null ? "" : a).split(".");
|
|
3573
|
+
const pb = String(b == null ? "" : b).split(".");
|
|
3574
|
+
const n = Math.max(pa.length, pb.length);
|
|
3575
|
+
for (let i = 0; i < n; i++) {
|
|
3576
|
+
const sa = pa[i] === undefined ? "" : pa[i];
|
|
3577
|
+
const sb = pb[i] === undefined ? "" : pb[i];
|
|
3578
|
+
const na = parseInt(sa, 10), nb = parseInt(sb, 10);
|
|
3579
|
+
const bothNum = String(na) === sa && String(nb) === sb;
|
|
3580
|
+
if (bothNum) {
|
|
3581
|
+
if (na !== nb) return na - nb;
|
|
3582
|
+
} else if (sa !== sb) {
|
|
3583
|
+
return sa < sb ? -1 : 1;
|
|
3584
|
+
}
|
|
3585
|
+
}
|
|
3586
|
+
return 0;
|
|
3587
|
+
}
|
|
3588
|
+
|
|
3589
|
+
const tickets = {
|
|
3590
|
+
/**
|
|
3591
|
+
* SM-58: history of test-execution tickets for a given test-definition,
|
|
3592
|
+
* sorted by `runAt` descending (newest first). Reads the definition's
|
|
3593
|
+
* inbound "executes" links from every test-execution ticket. Optional
|
|
3594
|
+
* `limit` caps the result list.
|
|
3595
|
+
*/
|
|
3596
|
+
testExecHistory(snapshot, definitionId, opts) {
|
|
3597
|
+
if (!snapshot || !Array.isArray(snapshot.tickets) || typeof definitionId !== "string") {
|
|
3598
|
+
return [];
|
|
3599
|
+
}
|
|
3600
|
+
const out = [];
|
|
3601
|
+
for (const t of snapshot.tickets) {
|
|
3602
|
+
if (t.isDeleted || t.type !== "test-execution") continue;
|
|
3603
|
+
if (!Array.isArray(t.links)) continue;
|
|
3604
|
+
const has = t.links.some(l =>
|
|
3605
|
+
(l.linkTypeId || l.type) === "executes" && l.targetTicketId === definitionId);
|
|
3606
|
+
if (has || t.referencedTestDefinitionId === definitionId) out.push(t);
|
|
3607
|
+
}
|
|
3608
|
+
out.sort((a, b) => (b.runAt || 0) - (a.runAt || 0));
|
|
3609
|
+
const limit = opts && typeof opts.limit === "number" ? opts.limit : null;
|
|
3610
|
+
return limit != null ? out.slice(0, Math.max(0, limit)) : out;
|
|
3611
|
+
},
|
|
3612
|
+
|
|
3613
|
+
/** Epic-Karten in einer konkreten (releaseId, processStepId)-Zelle. */
|
|
3614
|
+
epicsInCell(snapshot, releaseId, processStepId) {
|
|
3615
|
+
return (snapshot.tickets || [])
|
|
3616
|
+
.filter(_byTypeEpic)
|
|
3617
|
+
.filter(t => t.position
|
|
3618
|
+
&& t.position.releaseId === releaseId
|
|
3619
|
+
&& t.position.processStepId === processStepId)
|
|
3620
|
+
.slice()
|
|
3621
|
+
.sort(_sortBySortOrder);
|
|
3622
|
+
},
|
|
3623
|
+
|
|
3624
|
+
/**
|
|
3625
|
+
* SM-248 — every epic positioned in a process step ACROSS ALL RELEASES,
|
|
3626
|
+
* each annotated with its release (id+name) and contained-story count.
|
|
3627
|
+
* Powers the Process-Step editor's "epic hull" + the split-dialog epic
|
|
3628
|
+
* picker. Sorted by release sortOrder then epic sortOrder so the list
|
|
3629
|
+
* reads top-to-bottom like the story-map column.
|
|
3630
|
+
*/
|
|
3631
|
+
epicsInProcessStep(snapshot, processStepId) {
|
|
3632
|
+
const releaseById = new Map(
|
|
3633
|
+
(snapshot.releases || []).map(r => [r.id, r]));
|
|
3634
|
+
return (snapshot.tickets || [])
|
|
3635
|
+
.filter(_byTypeEpic)
|
|
3636
|
+
.filter(t => t.position && t.position.processStepId === processStepId)
|
|
3637
|
+
.slice()
|
|
3638
|
+
.sort((a, b) => {
|
|
3639
|
+
const ra = releaseById.get(a.position && a.position.releaseId);
|
|
3640
|
+
const rb = releaseById.get(b.position && b.position.releaseId);
|
|
3641
|
+
const rd = ((ra && ra.sortOrder) || 0) - ((rb && rb.sortOrder) || 0);
|
|
3642
|
+
return rd !== 0 ? rd : _sortBySortOrder(a, b);
|
|
3643
|
+
})
|
|
3644
|
+
.map(epic => {
|
|
3645
|
+
const rel = releaseById.get(epic.position && epic.position.releaseId) || null;
|
|
3646
|
+
return {
|
|
3647
|
+
epic: epic,
|
|
3648
|
+
releaseId: rel ? rel.id : null,
|
|
3649
|
+
releaseName: rel ? rel.name : null,
|
|
3650
|
+
storyCount: tickets.storiesInEpic(snapshot, epic.id).length
|
|
3651
|
+
};
|
|
3652
|
+
});
|
|
3653
|
+
},
|
|
3654
|
+
|
|
3655
|
+
/** Story-Karten, die zu einem bestimmten Epic gehören. SM-52: containment
|
|
3656
|
+
* is canonical via the epic's outgoing `contains` links. */
|
|
3657
|
+
storiesInEpic(snapshot, epicId) {
|
|
3658
|
+
const epic = (snapshot.tickets || []).find(t => t.id === epicId);
|
|
3659
|
+
if (!epic || !epic.links || !epic.links.length) return [];
|
|
3660
|
+
const containedIds = new Set();
|
|
3661
|
+
for (const l of epic.links) {
|
|
3662
|
+
const lt = l.linkTypeId || l.type;
|
|
3663
|
+
if (lt === CONTAINS_LINK_TYPE_ID) containedIds.add(l.targetTicketId);
|
|
3664
|
+
}
|
|
3665
|
+
return (snapshot.tickets || [])
|
|
3666
|
+
.filter(_byTypeStory)
|
|
3667
|
+
.filter(t => containedIds.has(t.id))
|
|
3668
|
+
.slice()
|
|
3669
|
+
.sort(_sortBySortOrder);
|
|
3670
|
+
},
|
|
3671
|
+
|
|
3672
|
+
/** Return the epic that currently contains the given story (via inbound
|
|
3673
|
+
* contains-link), or null. SM-52. */
|
|
3674
|
+
epicForStory(snapshot, storyId) {
|
|
3675
|
+
for (const t of (snapshot.tickets || [])) {
|
|
3676
|
+
if (t.isDeleted || t.type !== "epic" || !t.links) continue;
|
|
3677
|
+
for (const l of t.links) {
|
|
3678
|
+
const lt = l.linkTypeId || l.type;
|
|
3679
|
+
if (lt === CONTAINS_LINK_TYPE_ID && l.targetTicketId === storyId) return t;
|
|
3680
|
+
}
|
|
3681
|
+
}
|
|
3682
|
+
return null;
|
|
3683
|
+
},
|
|
3684
|
+
|
|
3685
|
+
/**
|
|
3686
|
+
* Lose Tickets in einer (releaseId, processStepId)-Zelle: alle Nicht-Epic-
|
|
3687
|
+
* Tickets, die in der Zelle platziert sind, aber KEINEM Epic angehören.
|
|
3688
|
+
* SM-52: containment is read from the contains-link, not position.epicId.
|
|
3689
|
+
*/
|
|
3690
|
+
looseTicketsInCell(snapshot, releaseId, processStepId) {
|
|
3691
|
+
const containerByStory = _containerEpicByStoryId(snapshot);
|
|
3692
|
+
return (snapshot.tickets || [])
|
|
3693
|
+
.filter(_byTypeStory)
|
|
3694
|
+
.filter(t => t.position
|
|
3695
|
+
&& t.position.releaseId === releaseId
|
|
3696
|
+
&& t.position.processStepId === processStepId
|
|
3697
|
+
&& !containerByStory.has(t.id))
|
|
3698
|
+
.slice()
|
|
3699
|
+
.sort(_sortBySortOrder);
|
|
3700
|
+
},
|
|
3701
|
+
|
|
3702
|
+
/** Epics, die noch keiner Release zugeordnet sind — gehören in die
|
|
3703
|
+
* Backlog-Section am Ende der Story-Map (Sub-Gruppe "Unscheduled"). */
|
|
3704
|
+
backlogEpics(snapshot) {
|
|
3705
|
+
return (snapshot.tickets || [])
|
|
3706
|
+
.filter(_byTypeEpic)
|
|
3707
|
+
.filter(t => !t.position || t.position.releaseId == null)
|
|
3708
|
+
.slice()
|
|
3709
|
+
.sort(_sortBySortOrder);
|
|
3710
|
+
},
|
|
3711
|
+
|
|
3712
|
+
/** Stories ohne Release UND ohne Epic — orphan-Tickets (Backlog
|
|
3713
|
+
* "Unscheduled"-Gruppe). SM-52: containment via link. */
|
|
3714
|
+
orphanStories(snapshot) {
|
|
3715
|
+
const containerByStory = _containerEpicByStoryId(snapshot);
|
|
3716
|
+
return (snapshot.tickets || [])
|
|
3717
|
+
.filter(_byTypeStory)
|
|
3718
|
+
.filter(t => (!t.position || t.position.releaseId == null) && !containerByStory.has(t.id))
|
|
3719
|
+
.slice()
|
|
3720
|
+
.sort(_sortBySortOrder);
|
|
3721
|
+
},
|
|
3722
|
+
|
|
3723
|
+
/**
|
|
3724
|
+
* Tickets die EINER Release zugeordnet sind, aber noch nicht voll in der
|
|
3725
|
+
* Matrix platziert (Epic ohne processStepId, Story ohne epicId). Werden
|
|
3726
|
+
* im Backlog unter der jeweiligen Release als "Scheduled for X" gruppiert.
|
|
3727
|
+
*
|
|
3728
|
+
* Returnt eine Map<releaseId, {epics: Ticket[], stories: Ticket[]}>.
|
|
3729
|
+
*/
|
|
3730
|
+
partiallyAssignedByRelease(snapshot) {
|
|
3731
|
+
const containerByStory = _containerEpicByStoryId(snapshot);
|
|
3732
|
+
const out = new Map();
|
|
3733
|
+
for (const t of (snapshot.tickets || [])) {
|
|
3734
|
+
if (t.isDeleted) continue;
|
|
3735
|
+
if (isSpecType(t.type)) continue; // SM-196: spec objects never sit on the board
|
|
3736
|
+
const pos = t.position || {};
|
|
3737
|
+
if (!pos.releaseId) continue; // unscheduled goes elsewhere
|
|
3738
|
+
if (t.type === "epic") {
|
|
3739
|
+
if (pos.processStepId) continue; // fully placed → in cell
|
|
3740
|
+
if (!out.has(pos.releaseId)) out.set(pos.releaseId, { epics: [], stories: [] });
|
|
3741
|
+
out.get(pos.releaseId).epics.push(t);
|
|
3742
|
+
} else {
|
|
3743
|
+
// SM-52: a story is "fully placed" if it has an epic container (via
|
|
3744
|
+
// contains-link) — not via the legacy position.epicId.
|
|
3745
|
+
if (containerByStory.has(t.id)) continue;
|
|
3746
|
+
if (pos.processStepId) continue; // loose ticket placed in cell — rendered there, not in backlog
|
|
3747
|
+
if (!out.has(pos.releaseId)) out.set(pos.releaseId, { epics: [], stories: [] });
|
|
3748
|
+
out.get(pos.releaseId).stories.push(t);
|
|
3749
|
+
}
|
|
3750
|
+
}
|
|
3751
|
+
for (const v of out.values()) {
|
|
3752
|
+
v.epics.sort(_sortBySortOrder);
|
|
3753
|
+
v.stories.sort(_sortBySortOrder);
|
|
3754
|
+
}
|
|
3755
|
+
return out;
|
|
3756
|
+
},
|
|
3757
|
+
|
|
3758
|
+
/** SM-196 R-2: all spec-module tickets, ordered by sortOrder. */
|
|
3759
|
+
specModules(snapshot) {
|
|
3760
|
+
return (snapshot.tickets || [])
|
|
3761
|
+
.filter(t => !t.isDeleted && t.type === "spec-module")
|
|
3762
|
+
.slice()
|
|
3763
|
+
.sort(_sortBySortOrder);
|
|
3764
|
+
},
|
|
3765
|
+
|
|
3766
|
+
/**
|
|
3767
|
+
* SM-196 R-2: the requirement-slices contained by a spec module, in stable
|
|
3768
|
+
* DOCUMENT order — by sectionPath first (the DOORS object id), sortOrder as
|
|
3769
|
+
* tiebreak. Containment is the module's outgoing `contains` links (same
|
|
3770
|
+
* canonical mechanism as epic→story). Returns [] for an unknown module.
|
|
3771
|
+
*/
|
|
3772
|
+
requirementsInModule(snapshot, moduleId) {
|
|
3773
|
+
const mod = (snapshot.tickets || []).find(t => t.id === moduleId);
|
|
3774
|
+
if (!mod || !Array.isArray(mod.links)) return [];
|
|
3775
|
+
const contained = new Set();
|
|
3776
|
+
for (const l of mod.links) {
|
|
3777
|
+
if ((l.linkTypeId || l.type) === CONTAINS_LINK_TYPE_ID) contained.add(l.targetTicketId);
|
|
3778
|
+
}
|
|
3779
|
+
return (snapshot.tickets || [])
|
|
3780
|
+
.filter(t => !t.isDeleted && t.type === "requirement" && contained.has(t.id))
|
|
3781
|
+
.slice()
|
|
3782
|
+
.sort((a, b) => compareSectionPath(a.sectionPath, b.sectionPath) || _sortBySortOrder(a, b));
|
|
3783
|
+
}
|
|
3784
|
+
};
|
|
3785
|
+
|
|
3786
|
+
// ---------------------------------------------------------------------------
|
|
3787
|
+
// diffSnapshots
|
|
3788
|
+
// ---------------------------------------------------------------------------
|
|
3789
|
+
|
|
3790
|
+
function diffList(prev, next) {
|
|
3791
|
+
const prevById = new Map(prev.map(x => [x.id, x]));
|
|
3792
|
+
const nextById = new Map(next.map(x => [x.id, x]));
|
|
3793
|
+
const added = [], updated = [], removed = [];
|
|
3794
|
+
for (const [id, n] of nextById) {
|
|
3795
|
+
const p = prevById.get(id);
|
|
3796
|
+
if (!p) added.push(n);
|
|
3797
|
+
else if (JSON.stringify(p) !== JSON.stringify(n)) updated.push(n);
|
|
3798
|
+
}
|
|
3799
|
+
for (const [id, p] of prevById) {
|
|
3800
|
+
if (!nextById.has(id)) removed.push(p);
|
|
3801
|
+
}
|
|
3802
|
+
return { added, updated, removed };
|
|
3803
|
+
}
|
|
3804
|
+
|
|
3805
|
+
function diffSnapshots(prev, next) {
|
|
3806
|
+
prev = normalizeSnapshot(prev);
|
|
3807
|
+
next = normalizeSnapshot(next);
|
|
3808
|
+
return {
|
|
3809
|
+
tickets: diffList(prev.tickets, next.tickets),
|
|
3810
|
+
releases: diffList(prev.releases, next.releases),
|
|
3811
|
+
processSteps: diffList(prev.processSteps, next.processSteps)
|
|
3812
|
+
};
|
|
3813
|
+
}
|
|
3814
|
+
|
|
3815
|
+
// ---------------------------------------------------------------------------
|
|
3816
|
+
// Exports
|
|
3817
|
+
// ---------------------------------------------------------------------------
|
|
3818
|
+
|
|
3819
|
+
return {
|
|
3820
|
+
SCHEMA_VERSION,
|
|
3821
|
+
EPIC_BACKLOG_ID,
|
|
3822
|
+
STORY_BACKLOG_ID,
|
|
3823
|
+
DEFAULT_TICKET_TYPES,
|
|
3824
|
+
// SM-196 R-1 — spec-layer type helpers.
|
|
3825
|
+
SPEC_TYPES,
|
|
3826
|
+
isSpecType,
|
|
3827
|
+
isBoardWorkItem,
|
|
3828
|
+
normalizeSourceAnchor,
|
|
3829
|
+
compareSectionPath,
|
|
3830
|
+
DEFAULT_STATUSES,
|
|
3831
|
+
DEFAULT_RELEASE_STATUSES,
|
|
3832
|
+
DEFAULT_RELEASE_NAME,
|
|
3833
|
+
DEFAULT_PROCESS_STEP_NAME,
|
|
3834
|
+
seedDefaultScaffold,
|
|
3835
|
+
STORYMAPPER_DEFAULT_DEFINITIONS,
|
|
3836
|
+
STORYMAPPER_DEFAULT_WORKFLOW,
|
|
3837
|
+
STATUS_CATEGORIES,
|
|
3838
|
+
defaultCategoryForStatusId,
|
|
3839
|
+
humanizeStatusId,
|
|
3840
|
+
normalizeStatusItem,
|
|
3841
|
+
LIMITS,
|
|
3842
|
+
uid,
|
|
3843
|
+
now,
|
|
3844
|
+
normalizeActor,
|
|
3845
|
+
normalizeProject,
|
|
3846
|
+
getEntityTypeConfig,
|
|
3847
|
+
getWorkflowForType,
|
|
3848
|
+
normalizeTicket,
|
|
3849
|
+
// SM-53 / SM-56 — test-type helpers (pure)
|
|
3850
|
+
normalizeTestStep,
|
|
3851
|
+
normalizeTestPrerequisite,
|
|
3852
|
+
normalizeTestExecStep,
|
|
3853
|
+
deriveOutcome,
|
|
3854
|
+
getEffectiveOutcome,
|
|
3855
|
+
TEST_EXEC_STEP_STATUSES,
|
|
3856
|
+
TEST_EXEC_OUTCOMES,
|
|
3857
|
+
normalizeRelease,
|
|
3858
|
+
normalizeProcessStep,
|
|
3859
|
+
normalizeSnapshot,
|
|
3860
|
+
normalizeDefinitions,
|
|
3861
|
+
normalizeWorkflow,
|
|
3862
|
+
resolveDefinitions,
|
|
3863
|
+
buildTicketChecklists,
|
|
3864
|
+
ops,
|
|
3865
|
+
tickets,
|
|
3866
|
+
// SM-236 — derived epic status (roll-up)
|
|
3867
|
+
deriveEpicStatus,
|
|
3868
|
+
epicChildStats,
|
|
3869
|
+
recomputeEpicStatuses,
|
|
3870
|
+
releaseProgress,
|
|
3871
|
+
// SM-242 — cancel workflow helpers
|
|
3872
|
+
firstStatusOfCategory,
|
|
3873
|
+
statusCategoryOf,
|
|
3874
|
+
isTerminalStatus,
|
|
3875
|
+
validateStatusTransition,
|
|
3876
|
+
// SM-102 / SM-103 / SM-104 / SM-105 / SM-106 — declarative transition-rule catalog + walker
|
|
3877
|
+
TRANSITION_RULES,
|
|
3878
|
+
GLOBAL_TRANSITION_RULES,
|
|
3879
|
+
evaluateTransitionRules,
|
|
3880
|
+
transitionRuleIds,
|
|
3881
|
+
diffSnapshots,
|
|
3882
|
+
// SM-44
|
|
3883
|
+
CYCLE_CHECKED_LINK_TYPES,
|
|
3884
|
+
normalizeLink,
|
|
3885
|
+
validateLink,
|
|
3886
|
+
wouldCreateCycle,
|
|
3887
|
+
// SM-45
|
|
3888
|
+
LINK_SEMANTICS,
|
|
3889
|
+
CYCLE_CHECKED_SEMANTICS,
|
|
3890
|
+
STORYMAPPER_DEFAULT_LINK_TYPES,
|
|
3891
|
+
normalizeLinkType,
|
|
3892
|
+
// SM-52 / SM-54-followup
|
|
3893
|
+
CONTAINS_LINK_TYPE_ID,
|
|
3894
|
+
TEST_TARGET_LINK_TYPE_ID,
|
|
3895
|
+
containerEpicIdOf,
|
|
3896
|
+
// SM-93 — Governance layer (predicates + evaluator + templater + defaults)
|
|
3897
|
+
TEST_DEFINITION_LIFECYCLES,
|
|
3898
|
+
TEST_DEFINITION_HEALTH_STATES,
|
|
3899
|
+
STORYMAPPER_DEFAULT_GOVERNANCE,
|
|
3900
|
+
normalizeGovernance,
|
|
3901
|
+
GOVERNANCE_PREDICATES,
|
|
3902
|
+
evaluateGates,
|
|
3903
|
+
evaluateWarnings,
|
|
3904
|
+
renderMessage,
|
|
3905
|
+
computeDerivedHealth
|
|
3906
|
+
};
|
|
3907
|
+
|
|
3908
|
+
}));
|