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.
Files changed (61) hide show
  1. package/ARCHITECTURE.md +334 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +6 -0
  4. package/README.md +239 -0
  5. package/frontend/css/storymap.css +4637 -0
  6. package/frontend/js/adapters.js +423 -0
  7. package/frontend/js/card-animate.js +384 -0
  8. package/frontend/js/core/graph.js +825 -0
  9. package/frontend/js/core.js +3908 -0
  10. package/frontend/js/dialog-ticket-import.js +506 -0
  11. package/frontend/js/dnd.js +322 -0
  12. package/frontend/js/filter.js +215 -0
  13. package/frontend/js/main.js +2499 -0
  14. package/frontend/js/project-io.js +109 -0
  15. package/frontend/js/query-autocomplete.js +196 -0
  16. package/frontend/js/query-engine.js +339 -0
  17. package/frontend/js/query.js +280 -0
  18. package/frontend/js/renderer-card.js +639 -0
  19. package/frontend/js/renderer-dependencies.js +974 -0
  20. package/frontend/js/renderer-kanban.js +505 -0
  21. package/frontend/js/renderer-storymap.js +2530 -0
  22. package/frontend/js/renderer-ticket-editor.js +455 -0
  23. package/frontend/js/renderer-ticket-modal.js +758 -0
  24. package/frontend/js/save-pipeline.js +170 -0
  25. package/frontend/js/smartbar-autocomplete.js +162 -0
  26. package/frontend/js/store.js +197 -0
  27. package/frontend/js/ticket-editor-boot.js +24 -0
  28. package/frontend/js/ticket-form.js +2095 -0
  29. package/frontend/js/ticket-import.js +477 -0
  30. package/frontend/js/ui-shell.js +441 -0
  31. package/frontend/js/view-process-steps.js +233 -0
  32. package/frontend/js/view-requirements.js +361 -0
  33. package/frontend/js/view-settings.js +1864 -0
  34. package/frontend/js/view-table.js +659 -0
  35. package/frontend/js/wheel-pan.js +65 -0
  36. package/frontend/storymap.html +87 -0
  37. package/frontend/ticket-editor.html +29 -0
  38. package/package.json +76 -0
  39. package/server/bus.js +16 -0
  40. package/server/core/graph.js +10 -0
  41. package/server/core.js +10 -0
  42. package/server/identity.js +134 -0
  43. package/server/index.js +283 -0
  44. package/server/ingest.js +212 -0
  45. package/server/mcp.js +2510 -0
  46. package/server/server.js +1599 -0
  47. package/server/slice.js +103 -0
  48. package/server/storage.js +571 -0
  49. package/server/validation.js +225 -0
  50. package/shared/core/graph.js +825 -0
  51. package/shared/core.js +3908 -0
  52. package/shared/project-io.js +109 -0
  53. package/shared/query-autocomplete.js +196 -0
  54. package/shared/query-engine.js +339 -0
  55. package/shared/query.js +280 -0
  56. package/shared/ticket-import.js +477 -0
  57. package/skill/SKILL.md +458 -0
  58. package/skill/reference/anatomy.md +196 -0
  59. package/skill/reference/spec-evolution.md +52 -0
  60. package/skill/reference/tools.md +156 -0
  61. 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
+ }));