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
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SM-288 — Ticket-Import engine (pure).
|
|
3
|
+
*
|
|
4
|
+
* Single source (SM-139 pattern): this file backs BOTH the browser import
|
|
5
|
+
* dialog (SM-289, via the frontend symlink + script tag) and the MCP
|
|
6
|
+
* `import_tickets` tool (SM-290, via require). No I/O, no DOM.
|
|
7
|
+
*
|
|
8
|
+
* parseTicketImport(text, format?) → { rows: [{line, data}], error }
|
|
9
|
+
* CSV (RFC-4180 tolerant; header row = field keys, compatible with the
|
|
10
|
+
* SM-285 export) or JSON (array of ticket objects). format is
|
|
11
|
+
* auto-detected by the leading bracket when omitted.
|
|
12
|
+
*
|
|
13
|
+
* planTicketImport(snapshot, rows, mode) → { creates, updates, errors }
|
|
14
|
+
* mode 'create-only' | 'upsert'. Classifies every row; a row with any
|
|
15
|
+
* error contributes ONLY its error (atomic per row). Ticket keys are
|
|
16
|
+
* server-minted: unknown keys become creates, the provided key is
|
|
17
|
+
* dropped. Ref fields (release/processStep/epic) resolve by NAME or
|
|
18
|
+
* ID (epic: ticketKey or id), analogous to the query engine's ref
|
|
19
|
+
* fields. Empty CSV cells mean ABSENT (untouched), never "clear".
|
|
20
|
+
*
|
|
21
|
+
* The APPLY step lives with the surfaces (dialog / MCP tool) — this module
|
|
22
|
+
* only parses and plans.
|
|
23
|
+
*
|
|
24
|
+
* Caller contract:
|
|
25
|
+
* - check parse.error BEFORE planning — a failed parse yields rows:[] and
|
|
26
|
+
* planTicketImport would report an indistinguishable empty plan.
|
|
27
|
+
* - the snapshot must be NORMALIZED (normalizeSnapshot) — raw snapshots
|
|
28
|
+
* without workflow/ticketTypes fail every enum resolution.
|
|
29
|
+
* - an unknown mode falls back to 'create-only' (the non-destructive one).
|
|
30
|
+
* - name resolution is last-wins on duplicates (two releases with the same
|
|
31
|
+
* name resolve to the later one) — ids are always unambiguous.
|
|
32
|
+
* - JSON `labels: []` is PRESENT and therefore clears labels; only absent /
|
|
33
|
+
* empty-string values are skipped.
|
|
34
|
+
* - POLICY: import is a MIGRATION surface. Statuses are planned/applied
|
|
35
|
+
* WITHOUT DoR/DoD gates (a re-imported export must be able to carry
|
|
36
|
+
* done tickets); only the epic-status-derived rule is enforced.
|
|
37
|
+
*
|
|
38
|
+
* UMD-wrapped — same file loads in Node (require) and browser (script tag).
|
|
39
|
+
*/
|
|
40
|
+
(function (root, factory) {
|
|
41
|
+
if (typeof module === "object" && module.exports) {
|
|
42
|
+
module.exports = factory(require("./core.js"));
|
|
43
|
+
} else {
|
|
44
|
+
(root.STORYMAP = root.STORYMAP || {}).ticketImport = factory(root.STORYMAP && root.STORYMAP.core);
|
|
45
|
+
}
|
|
46
|
+
}(typeof self !== "undefined" ? self : this, function (core) {
|
|
47
|
+
"use strict";
|
|
48
|
+
|
|
49
|
+
const TICKET_IMPORT = {
|
|
50
|
+
MODES: ["create-only", "upsert"],
|
|
51
|
+
// Canonical field keys the planner understands. Aliases map onto them;
|
|
52
|
+
// anything else in a header is ignored silently (server-managed exports
|
|
53
|
+
// like updated/created/acCount must round-trip without noise).
|
|
54
|
+
FIELDS: ["key", "type", "title", "description", "status", "release", "processStep", "epic", "labels"],
|
|
55
|
+
ALIASES: { ticketkey: "key", processstep: "processStep", label: "labels" },
|
|
56
|
+
// SM-296: auto-mapping heuristic for FOREIGN exports (Jira, Excel, German
|
|
57
|
+
// sheets). Applied when no explicit headerMapping entry exists; the
|
|
58
|
+
// mapping panel prefills from the resolved result and can override.
|
|
59
|
+
FOREIGN_ALIASES: {
|
|
60
|
+
"summary": "title",
|
|
61
|
+
"issue key": "key",
|
|
62
|
+
"issue id": "key",
|
|
63
|
+
"issue type": "type",
|
|
64
|
+
"issuetype": "type",
|
|
65
|
+
"fix version": "release",
|
|
66
|
+
"fix version/s": "release",
|
|
67
|
+
"fixversion": "release",
|
|
68
|
+
"titel": "title",
|
|
69
|
+
"beschreibung": "description",
|
|
70
|
+
"typ": "type",
|
|
71
|
+
},
|
|
72
|
+
// SM-297: VALUE heuristic for foreign exports — applied only when the
|
|
73
|
+
// TARGET actually exists in the project (guarded at resolution time),
|
|
74
|
+
// overridable via opts.valueMapping.
|
|
75
|
+
FOREIGN_VALUE_ALIASES: {
|
|
76
|
+
status: {
|
|
77
|
+
"open": "backlog", "to do": "backlog", "todo": "backlog", "reopened": "backlog",
|
|
78
|
+
"closed": "done", "resolved": "done",
|
|
79
|
+
"in review": "review",
|
|
80
|
+
},
|
|
81
|
+
type: { "story": "user-story" },
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// ---- CSV (RFC 4180) --------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
/** Parse CSV text into rows of cell arrays + the 1-based file line each ROW starts on. */
|
|
88
|
+
function parseCsvCells(text) {
|
|
89
|
+
const rows = [];
|
|
90
|
+
let cells = [], cell = "", inQuotes = false, line = 1, rowLine = 1, sawAny = false;
|
|
91
|
+
for (let i = 0; i < text.length; i++) {
|
|
92
|
+
const ch = text[i];
|
|
93
|
+
if (inQuotes) {
|
|
94
|
+
if (ch === '"') {
|
|
95
|
+
if (text[i + 1] === '"') { cell += '"'; i++; }
|
|
96
|
+
else inQuotes = false;
|
|
97
|
+
} else {
|
|
98
|
+
if (ch === "\n") line++;
|
|
99
|
+
cell += ch;
|
|
100
|
+
}
|
|
101
|
+
} else if (ch === '"') {
|
|
102
|
+
inQuotes = true;
|
|
103
|
+
} else if (ch === ",") {
|
|
104
|
+
cells.push(cell); cell = ""; sawAny = true;
|
|
105
|
+
} else if (ch === "\n" || ch === "\r") {
|
|
106
|
+
if (ch === "\r" && text[i + 1] === "\n") i++;
|
|
107
|
+
line++;
|
|
108
|
+
if (cell !== "" || cells.length || sawAny) rows.push({ line: rowLine, cells: cells.concat([cell]) });
|
|
109
|
+
cells = []; cell = ""; sawAny = false; rowLine = line;
|
|
110
|
+
} else {
|
|
111
|
+
cell += ch;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (cell !== "" || cells.length || sawAny) rows.push({ line: rowLine, cells: cells.concat([cell]) });
|
|
115
|
+
// EOF inside quotes: a stray quote silently swallowing the rest of the
|
|
116
|
+
// file mangles rows AND line numbers — report it (review finding).
|
|
117
|
+
return { rows: rows, unterminated: inQuotes };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const hasOwn = Object.prototype.hasOwnProperty;
|
|
121
|
+
|
|
122
|
+
function canonicalField(name) {
|
|
123
|
+
const raw = String(name == null ? "" : name).trim();
|
|
124
|
+
const lc = raw.toLowerCase();
|
|
125
|
+
// hasOwn guard: a header named 'constructor' must not resolve via the
|
|
126
|
+
// prototype chain (review finding on aac77aa).
|
|
127
|
+
if (hasOwn.call(TICKET_IMPORT.ALIASES, lc)) return TICKET_IMPORT.ALIASES[lc];
|
|
128
|
+
const hit = TICKET_IMPORT.FIELDS.find((f) => f.toLowerCase() === lc);
|
|
129
|
+
return hit || null; // null = unknown header → ignored
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* SM-296: resolve a CSV header to a field. Precedence: explicit
|
|
134
|
+
* headerMapping entry (keys lowercased; null/''/'ignore' = drop the
|
|
135
|
+
* column, a value must name a known field) → canonical field/alias →
|
|
136
|
+
* FOREIGN_ALIASES heuristic → null (unused). Pure.
|
|
137
|
+
*/
|
|
138
|
+
function resolveHeader(name, headerMapping) {
|
|
139
|
+
const raw = String(name == null ? "" : name).trim();
|
|
140
|
+
const lcName = raw.toLowerCase();
|
|
141
|
+
if (headerMapping && hasOwn.call(headerMapping, lcName)) {
|
|
142
|
+
const v = headerMapping[lcName];
|
|
143
|
+
if (v == null || v === "" || v === "ignore") return null;
|
|
144
|
+
return canonicalField(v); // unknown mapping target → null, never a crash
|
|
145
|
+
}
|
|
146
|
+
return canonicalField(raw)
|
|
147
|
+
|| (hasOwn.call(TICKET_IMPORT.FOREIGN_ALIASES, lcName) ? TICKET_IMPORT.FOREIGN_ALIASES[lcName] : null);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Parse import text into { rows: [{line, data}], headers, error }. `data`
|
|
152
|
+
* maps CANONICAL field keys to raw values (strings from CSV; anything from
|
|
153
|
+
* JSON). `headers` (CSV only; [] for JSON) lists every column as
|
|
154
|
+
* {raw, field|null} — the base for the SM-296 mapping panel; field=null
|
|
155
|
+
* means the column is unused. opts.headerMapping ({lcHeader → field|null})
|
|
156
|
+
* overrides the built-in resolution per column.
|
|
157
|
+
*/
|
|
158
|
+
function parseTicketImport(text, format, opts) {
|
|
159
|
+
const src = String(text == null ? "" : text);
|
|
160
|
+
// Normalize mapping KEYS once (trim + lowercase): a caller copying the
|
|
161
|
+
// exact header casing from their CSV ({"Summary": …}) must not be
|
|
162
|
+
// silently ignored — or worse, lose against the heuristic (review
|
|
163
|
+
// finding on aac77aa).
|
|
164
|
+
let headerMapping = (opts && opts.headerMapping) || null;
|
|
165
|
+
if (headerMapping) {
|
|
166
|
+
const norm = {};
|
|
167
|
+
Object.keys(headerMapping).forEach((k) => {
|
|
168
|
+
norm[String(k).trim().toLowerCase()] = headerMapping[k];
|
|
169
|
+
});
|
|
170
|
+
headerMapping = norm;
|
|
171
|
+
}
|
|
172
|
+
const fmt = format || (src.trim().charAt(0) === "[" || src.trim().charAt(0) === "{" ? "json" : "csv");
|
|
173
|
+
if (fmt === "json") {
|
|
174
|
+
let data;
|
|
175
|
+
try { data = JSON.parse(src); }
|
|
176
|
+
catch (e) { return { rows: [], headers: [], error: "not valid JSON: " + e.message }; }
|
|
177
|
+
if (!Array.isArray(data)) return { rows: [], headers: [], error: "JSON import must be an ARRAY of ticket objects" };
|
|
178
|
+
const rows = data.map((obj, i) => {
|
|
179
|
+
const out = {};
|
|
180
|
+
if (obj && typeof obj === "object") {
|
|
181
|
+
Object.keys(obj).forEach((k) => {
|
|
182
|
+
const f = canonicalField(k);
|
|
183
|
+
if (f) out[f] = obj[k];
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
return { line: i + 1, data: out };
|
|
187
|
+
});
|
|
188
|
+
return { rows: rows, headers: [], error: null };
|
|
189
|
+
}
|
|
190
|
+
const parsed = parseCsvCells(src);
|
|
191
|
+
if (parsed.unterminated) return { rows: [], headers: [], error: "unterminated quoted cell — check for a stray \" in the file" };
|
|
192
|
+
if (!parsed.rows.length) return { rows: [], headers: [], error: "empty CSV — a header row with field keys is required" };
|
|
193
|
+
const headers = parsed.rows[0].cells.map((raw) => ({
|
|
194
|
+
raw: String(raw == null ? "" : raw).trim(),
|
|
195
|
+
field: resolveHeader(raw, headerMapping)
|
|
196
|
+
}));
|
|
197
|
+
const rows = parsed.rows.slice(1).map((r) => {
|
|
198
|
+
const out = {};
|
|
199
|
+
headers.forEach((h, i) => { if (h.field) out[h.field] = r.cells[i] != null ? r.cells[i] : ""; });
|
|
200
|
+
return { line: r.line, data: out };
|
|
201
|
+
});
|
|
202
|
+
return { rows: rows, headers: headers, error: null };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ---- planning ---------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
function lc(s) { return String(s == null ? "" : s).trim().toLowerCase(); }
|
|
208
|
+
|
|
209
|
+
/** Resolution maps from the snapshot: name OR id → id (SM-260 ref-field convention). */
|
|
210
|
+
function buildResolution(snapshot) {
|
|
211
|
+
const proj = (snapshot && snapshot.project) || {};
|
|
212
|
+
const res = { release: new Map(), processStep: new Map(), epic: new Map(), byKey: new Map(), epicOfStory: new Map() };
|
|
213
|
+
((snapshot && snapshot.releases) || []).filter((r) => !r.isDeleted).forEach((r) => {
|
|
214
|
+
res.release.set(lc(r.id), r.id); res.release.set(lc(r.name), r.id);
|
|
215
|
+
});
|
|
216
|
+
((snapshot && snapshot.processSteps) || []).filter((p) => !p.isDeleted).forEach((p) => {
|
|
217
|
+
res.processStep.set(lc(p.id), p.id); res.processStep.set(lc(p.name), p.id);
|
|
218
|
+
});
|
|
219
|
+
const tickets = ((snapshot && snapshot.tickets) || []).filter((t) => !t.isDeleted);
|
|
220
|
+
tickets.forEach((t) => {
|
|
221
|
+
res.byKey.set(lc(t.ticketKey), t);
|
|
222
|
+
if (t.type === "epic") { res.epic.set(lc(t.ticketKey), t.id); res.epic.set(lc(t.id), t.id); }
|
|
223
|
+
});
|
|
224
|
+
// containment: contains-links live on the EPIC (SM-52: position.epicId is dead)
|
|
225
|
+
tickets.forEach((t) => {
|
|
226
|
+
if (t.type !== "epic") return;
|
|
227
|
+
(t.links || []).forEach((l) => {
|
|
228
|
+
if (l && l.linkTypeId === "contains") res.epicOfStory.set(l.targetTicketId, t.id);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
// Map lc → CANONICAL id (like every other enum/ref field): 'Bug' must
|
|
232
|
+
// plan as 'bug', not carry its casing into type-sensitive downstream
|
|
233
|
+
// comparisons (review finding on da889f1).
|
|
234
|
+
res.types = new Map((proj.ticketTypes || []).map((t) => [lc(t), t]));
|
|
235
|
+
res.statuses = new Map();
|
|
236
|
+
(((proj.workflow || {}).statuses) || []).forEach((s) => {
|
|
237
|
+
const id = s && typeof s === "object" ? s.id : s;
|
|
238
|
+
const name = s && typeof s === "object" ? s.name : s;
|
|
239
|
+
res.statuses.set(lc(id), id); res.statuses.set(lc(name), id);
|
|
240
|
+
});
|
|
241
|
+
return res;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** CSV empty string / JSON null-undefined → absent. */
|
|
245
|
+
function present(v) {
|
|
246
|
+
if (v == null) return false;
|
|
247
|
+
if (typeof v === "string") return v.trim() !== "";
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function asLabels(v) {
|
|
252
|
+
if (Array.isArray(v)) return v.map((x) => String(x).trim()).filter(Boolean);
|
|
253
|
+
return String(v).split(",").map((x) => x.trim()).filter(Boolean);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Classify rows → { creates: [{line, ticket}], updates: [{line, ticketId,
|
|
258
|
+
* ticketKey, patch}], errors: [{line, field?, message}] }. Pure.
|
|
259
|
+
*/
|
|
260
|
+
function planTicketImport(snapshot, rows, mode, opts) {
|
|
261
|
+
const m = TICKET_IMPORT.MODES.indexOf(mode) >= 0 ? mode : "create-only";
|
|
262
|
+
const res = buildResolution(snapshot);
|
|
263
|
+
// SM-297: normalize valueMapping keys once ({field: {lcValue → target|null}}).
|
|
264
|
+
const rawVm = (opts && opts.valueMapping) || null;
|
|
265
|
+
const vm = {};
|
|
266
|
+
if (rawVm && typeof rawVm === "object") {
|
|
267
|
+
Object.keys(rawVm).forEach((field) => {
|
|
268
|
+
const entries = rawVm[field];
|
|
269
|
+
if (!entries || typeof entries !== "object") return;
|
|
270
|
+
const norm = {};
|
|
271
|
+
Object.keys(entries).forEach((k) => { norm[lc(k)] = entries[k]; });
|
|
272
|
+
vm[field] = norm;
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
const unknownSeen = { status: new Map(), type: new Map(), release: new Map(), processStep: new Map() };
|
|
276
|
+
const plan = { creates: [], updates: [], errors: [], unknownValues: {} };
|
|
277
|
+
|
|
278
|
+
// SM-297 resolution precedence per enum/ref value:
|
|
279
|
+
// explicit valueMapping (null = drop the field) → direct name/id hit →
|
|
280
|
+
// FOREIGN_VALUE_ALIASES heuristic (only if its target resolves).
|
|
281
|
+
// → { id } | { ignored: true } | null (unknown).
|
|
282
|
+
function recordUnknown(field, rawVal) {
|
|
283
|
+
const key = lc(rawVal);
|
|
284
|
+
if (unknownSeen[field] && !unknownSeen[field].has(key)) unknownSeen[field].set(key, String(rawVal).trim());
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function resolveValue(field, rawVal, resolveFn) {
|
|
288
|
+
const lcv = lc(rawVal);
|
|
289
|
+
if (vm[field] && hasOwn.call(vm[field], lcv)) {
|
|
290
|
+
const t = vm[field][lcv];
|
|
291
|
+
if (t == null || String(t).trim() === "") return { ignored: true };
|
|
292
|
+
const viaMap = resolveFn(lc(t));
|
|
293
|
+
if (viaMap) return { id: viaMap };
|
|
294
|
+
// Mapped onto a target that (no longer) exists — e.g. a persisted
|
|
295
|
+
// mapping after a release rename. The SOURCE value must land in
|
|
296
|
+
// unknownValues so the dialog's panel reappears and the user can
|
|
297
|
+
// re-map (self-healing; review finding on 2a40a63).
|
|
298
|
+
recordUnknown(field, rawVal);
|
|
299
|
+
return { badTarget: String(t) };
|
|
300
|
+
}
|
|
301
|
+
const direct = resolveFn(lcv);
|
|
302
|
+
if (direct) return { id: direct };
|
|
303
|
+
const aliases = TICKET_IMPORT.FOREIGN_VALUE_ALIASES[field];
|
|
304
|
+
if (aliases && hasOwn.call(aliases, lcv)) {
|
|
305
|
+
const viaAlias = resolveFn(lc(aliases[lcv]));
|
|
306
|
+
if (viaAlias) return { id: viaAlias };
|
|
307
|
+
}
|
|
308
|
+
recordUnknown(field, rawVal);
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const FIELD_LABEL = { status: "status", type: "ticket type", release: "release", processStep: "process step" };
|
|
313
|
+
|
|
314
|
+
// Shared error wording: a bad mapping TARGET is named as such — 'unknown
|
|
315
|
+
// release: Sprint 1' would point at the (known, mapped) source.
|
|
316
|
+
function applyEnum(field, rawVal, resolveFn, onId, errs, line) {
|
|
317
|
+
const r = resolveValue(field, rawVal, resolveFn);
|
|
318
|
+
if (r && r.id) { onId(r.id); return; }
|
|
319
|
+
if (r && r.ignored) return;
|
|
320
|
+
const src = String(rawVal).trim();
|
|
321
|
+
if (r && r.badTarget != null) {
|
|
322
|
+
errs.push({ line: line, field: field,
|
|
323
|
+
message: 'mapping target not found: "' + r.badTarget + '" (for "' + src + '")', value: src });
|
|
324
|
+
} else {
|
|
325
|
+
errs.push({ line: line, field: field, message: "unknown " + FIELD_LABEL[field] + ": " + src, value: src });
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
(rows || []).forEach((row) => {
|
|
330
|
+
const d = (row && row.data) || {};
|
|
331
|
+
const line = row && row.line;
|
|
332
|
+
const errs = [];
|
|
333
|
+
const fail = (field, message, value) => errs.push({ line: line, field: field, message: message, value: value });
|
|
334
|
+
|
|
335
|
+
// resolve shared ref/enum fields once
|
|
336
|
+
let releaseId, processStepId, epicId, status, type;
|
|
337
|
+
if (present(d.release)) applyEnum("release", d.release, (v) => res.release.get(v), (id) => { releaseId = id; }, errs, line);
|
|
338
|
+
if (present(d.processStep)) applyEnum("processStep", d.processStep, (v) => res.processStep.get(v), (id) => { processStepId = id; }, errs, line);
|
|
339
|
+
if (present(d.epic)) {
|
|
340
|
+
epicId = res.epic.get(lc(d.epic));
|
|
341
|
+
if (!epicId) fail("epic", "unknown epic: " + d.epic, String(d.epic).trim());
|
|
342
|
+
}
|
|
343
|
+
if (present(d.status)) applyEnum("status", d.status, (v) => res.statuses.get(v), (id) => { status = id; }, errs, line);
|
|
344
|
+
if (present(d.type)) applyEnum("type", d.type, (v) => res.types.get(v), (id) => { type = id; }, errs, line);
|
|
345
|
+
|
|
346
|
+
const existing = present(d.key) ? res.byKey.get(lc(d.key)) : null;
|
|
347
|
+
|
|
348
|
+
if (existing && m === "create-only") {
|
|
349
|
+
// The conflict IS the row's verdict — don't pile create-validation
|
|
350
|
+
// errors (missing type etc.) on top of it.
|
|
351
|
+
fail("key", "ticket key already exists (mode create-only): " + d.key);
|
|
352
|
+
plan.errors.push.apply(plan.errors, errs);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (existing && m === "upsert") {
|
|
357
|
+
// UPDATE: patch only the present columns, and DIFF OUT values equal
|
|
358
|
+
// to the current ticket — a straight re-import of the SM-285 export
|
|
359
|
+
// must plan as no-op patches, not as writes (review finding: epics
|
|
360
|
+
// export their DERIVED status; an equal-value status patch would
|
|
361
|
+
// throw EPIC_STATUS_DERIVED at apply time).
|
|
362
|
+
const patch = {};
|
|
363
|
+
if (present(d.title) && String(d.title).trim() !== existing.title) patch.title = String(d.title).trim();
|
|
364
|
+
if (present(d.description) && String(d.description) !== (existing.description || "")) patch.description = String(d.description);
|
|
365
|
+
if (status && status !== existing.status) {
|
|
366
|
+
if (existing.type === "epic" && !(type && type !== "epic")) {
|
|
367
|
+
fail("status", "epic status is derived from its stories — move them instead (SM-237)");
|
|
368
|
+
} else {
|
|
369
|
+
patch.status = status;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
if (type && type !== existing.type) patch.type = type;
|
|
373
|
+
if (present(d.labels)) {
|
|
374
|
+
const labels = asLabels(d.labels);
|
|
375
|
+
if (JSON.stringify(labels) !== JSON.stringify(existing.labels || [])) patch.labels = labels;
|
|
376
|
+
}
|
|
377
|
+
const curPos = existing.position || {};
|
|
378
|
+
const pos = {};
|
|
379
|
+
if (releaseId && releaseId !== curPos.releaseId) pos.releaseId = releaseId;
|
|
380
|
+
if (processStepId && processStepId !== curPos.processStepId) pos.processStepId = processStepId;
|
|
381
|
+
if (Object.keys(pos).length) {
|
|
382
|
+
// SM-67: a CONTAINED story inherits release/processStep from its
|
|
383
|
+
// epic — updateTicket would silently revert this patch. Flag it at
|
|
384
|
+
// plan time instead of showing a change that will not stick.
|
|
385
|
+
if (res.epicOfStory.get(existing.id)) {
|
|
386
|
+
fail(pos.releaseId ? "release" : "processStep",
|
|
387
|
+
"contained story follows its epic's release/process step (SM-67) — move the epic instead");
|
|
388
|
+
} else {
|
|
389
|
+
patch.position = pos;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
if (epicId) {
|
|
393
|
+
// v1: the import cannot REASSIGN containment (that is link surgery,
|
|
394
|
+
// not a field patch). Equal values pass silently so the SM-285 CSV
|
|
395
|
+
// export round-trips; different values are an explicit error.
|
|
396
|
+
const current = res.epicOfStory.get(existing.id) || null;
|
|
397
|
+
if (current !== epicId) fail("epic", "epic reassignment is not supported by import (use ticket links): " + d.epic);
|
|
398
|
+
}
|
|
399
|
+
if (errs.length) { plan.errors.push.apply(plan.errors, errs); return; }
|
|
400
|
+
plan.updates.push({ line: line, ticketId: existing.id, ticketKey: existing.ticketKey, patch: patch });
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// CREATE (no key, unknown key, or create-only without conflicts)
|
|
405
|
+
if (!present(d.title)) fail("title", "title is required");
|
|
406
|
+
// required against the RESOLVED type: absent column, ignored-by-mapping
|
|
407
|
+
// and empty cell all end here; an unknown value already carries its
|
|
408
|
+
// own error (no double-report).
|
|
409
|
+
if (!type && !errs.some((e) => e.field === "type")) fail("type", "type is required");
|
|
410
|
+
if (epicId && type === "epic") fail("epic", "an epic cannot be contained in another epic");
|
|
411
|
+
if (errs.length) { plan.errors.push.apply(plan.errors, errs); return; }
|
|
412
|
+
const ticket = { type: type, title: String(d.title).trim() };
|
|
413
|
+
if (present(d.description)) ticket.description = String(d.description);
|
|
414
|
+
if (status) ticket.status = status;
|
|
415
|
+
if (present(d.labels)) ticket.labels = asLabels(d.labels);
|
|
416
|
+
const pos = {};
|
|
417
|
+
if (releaseId) pos.releaseId = releaseId;
|
|
418
|
+
if (processStepId) pos.processStepId = processStepId;
|
|
419
|
+
ticket.position = pos;
|
|
420
|
+
if (epicId) ticket.epicId = epicId;
|
|
421
|
+
plan.creates.push({ line: line, ticket: ticket });
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
// SM-297: distinct unresolved values per field (original casing) — the
|
|
425
|
+
// base for the dialog's value-mapping panel and the MCP dryRun response.
|
|
426
|
+
Object.keys(unknownSeen).forEach((field) => {
|
|
427
|
+
if (unknownSeen[field].size) plan.unknownValues[field] = Array.from(unknownSeen[field].values());
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
return plan;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* SM-290: apply a plan via core.ops — PURE (returns next snapshot +
|
|
435
|
+
* counts, input untouched). Shared by the browser dialog (which commits
|
|
436
|
+
* once via store.applySnapshot) and the MCP import_tickets tool (which
|
|
437
|
+
* persists once via storage.mutate — one revision either way).
|
|
438
|
+
*
|
|
439
|
+
* Creates pass ticket.epicId through position.epicId: createTicket's
|
|
440
|
+
* explicit-parent path enforces the single-parent invariant, applies the
|
|
441
|
+
* SM-67 inheritance and suppresses the E9b cell auto-assign. Diffed-out
|
|
442
|
+
* empty update patches are skipped (not counted, not written).
|
|
443
|
+
*/
|
|
444
|
+
function applyImportPlan(snapshot, plan, actor) {
|
|
445
|
+
if (!core) throw new Error("ticket-import: core module missing (applyImportPlan)");
|
|
446
|
+
let snap = snapshot;
|
|
447
|
+
let created = 0, updated = 0;
|
|
448
|
+
(plan.creates || []).forEach((c) => {
|
|
449
|
+
const pos = Object.assign({}, c.ticket.position || {});
|
|
450
|
+
if (c.ticket.epicId) pos.epicId = c.ticket.epicId;
|
|
451
|
+
const partial = {
|
|
452
|
+
type: c.ticket.type,
|
|
453
|
+
title: c.ticket.title,
|
|
454
|
+
description: c.ticket.description,
|
|
455
|
+
status: c.ticket.status,
|
|
456
|
+
labels: c.ticket.labels,
|
|
457
|
+
position: pos,
|
|
458
|
+
};
|
|
459
|
+
snap = core.ops.createTicket(snap, partial, actor);
|
|
460
|
+
created++;
|
|
461
|
+
});
|
|
462
|
+
(plan.updates || []).forEach((u) => {
|
|
463
|
+
if (!u.patch || !Object.keys(u.patch).length) return; // diffed-out no-op rows
|
|
464
|
+
snap = core.ops.updateTicket(snap, u.ticketId, u.patch, actor);
|
|
465
|
+
updated++;
|
|
466
|
+
});
|
|
467
|
+
return { snapshot: snap, created: created, updated: updated };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return {
|
|
471
|
+
TICKET_IMPORT: TICKET_IMPORT,
|
|
472
|
+
parseTicketImport: parseTicketImport,
|
|
473
|
+
planTicketImport: planTicketImport,
|
|
474
|
+
applyImportPlan: applyImportPlan,
|
|
475
|
+
_parseCsvCells: parseCsvCells,
|
|
476
|
+
};
|
|
477
|
+
}));
|