From adce159c75e9aa8b7325a7dbc31e7167ca69e47d Mon Sep 17 00:00:00 2001 From: "Brennan Wilkes (Text Groove)" Date: Thu, 22 Jan 2026 11:44:51 -0800 Subject: [PATCH] feat: Better insertion to JSON --- tools/stviz_apply_issue_edits.js | 388 +++++++++++++++++++++++++++---- 1 file changed, 348 insertions(+), 40 deletions(-) diff --git a/tools/stviz_apply_issue_edits.js b/tools/stviz_apply_issue_edits.js index 3989953..2f7c8e3 100644 --- a/tools/stviz_apply_issue_edits.js +++ b/tools/stviz_apply_issue_edits.js @@ -20,7 +20,9 @@ const REPO = process.env.REPO || ""; if (!ISSUE_NUMBER) die("Missing ISSUE_NUMBER"); if (!REPO) die("Missing REPO"); -const m = ISSUE_BODY.match(/\s*([\s\S]*?)\s*/); +const m = ISSUE_BODY.match( + /\s*([\s\S]*?)\s*/ +); if (!m) die("No stviz payload found in issue body."); let payload; @@ -38,70 +40,376 @@ const ignoresIn = Array.isArray(payload?.ignores) ? payload.ignores : []; function normSku(s) { return String(s || "").trim(); } -function linkKey(x) { - const a = normSku(x?.fromSku); - const b = normSku(x?.toSku); - return a && b && a !== b ? `${a}→${b}` : ""; + +function linkKeyFrom(a, b) { + const x = normSku(a); + const y = normSku(b); + return x && y && x !== y ? `${x}→${y}` : ""; } + +function linkKey(x) { + return linkKeyFrom(x?.fromSku, x?.toSku); +} + function pairKey(a, b) { - const x = normSku(a), y = normSku(b); + const x = normSku(a), + y = normSku(b); if (!x || !y || x === y) return ""; return x < y ? `${x}|${y}` : `${y}|${x}`; } -const filePath = path.join("data", "sku_links.json"); -let base = { generatedAt: "", links: [], ignores: [] }; +/* ---------------- Minimal, merge-friendly JSON array insertion ---------------- */ -if (fs.existsSync(filePath)) { - try { - base = JSON.parse(fs.readFileSync(filePath, "utf8")); - } catch { - // keep defaults +function findJsonArraySpan(src, propName) { + // Finds the [ ... ] span for `"propName": [ ... ]` and returns { start, end, open, close, fieldIndent } + const re = new RegExp(`(^[ \\t]*)"${propName}"\\s*:\\s*\\[`, "m"); + const mm = src.match(re); + if (!mm) return null; + + const fieldIndent = mm[1] || ""; + const at = mm.index || 0; + const open = src.indexOf("[", at); + if (open < 0) return null; + + // scan to matching ']' + let i = open; + let depth = 0; + let inStr = false; + let esc = false; + + for (; i < src.length; i++) { + const ch = src[i]; + + if (inStr) { + if (esc) { + esc = false; + } else if (ch === "\\") { + esc = true; + } else if (ch === '"') { + inStr = false; + } + continue; + } + + if (ch === '"') { + inStr = true; + continue; + } + + if (ch === "[") depth++; + else if (ch === "]") { + depth--; + if (depth === 0) { + const close = i; + return { start: at, open, close, end: close + 1, fieldIndent }; + } + } } + + return null; } -const baseLinks = Array.isArray(base?.links) ? base.links : []; -const baseIgnores = Array.isArray(base?.ignores) ? base.ignores : []; +function splitArrayObjectBlocks(arrayInnerText) { + // arrayInnerText is text between '[' and ']' (can include whitespace/newlines/commas) + // returns raw blocks (each block is the exact text for a JSON object, preserving formatting) + const blocks = []; -const seenLinks = new Set(baseLinks.map(linkKey).filter(Boolean)); -for (const x of linksIn) { - const k = linkKey(x); - if (!k || seenLinks.has(k)) continue; - seenLinks.add(k); - baseLinks.push({ fromSku: normSku(x.fromSku), toSku: normSku(x.toSku) }); + let i = 0; + const s = arrayInnerText; + + function skipWsAndCommas() { + while (i < s.length) { + const ch = s[i]; + if (ch === "," || ch === " " || ch === "\t" || ch === "\n" || ch === "\r") i++; + else break; + } + } + + skipWsAndCommas(); + + while (i < s.length) { + if (s[i] !== "{") { + // if something unexpected, advance a bit + i++; + skipWsAndCommas(); + continue; + } + + const start = i; + let depth = 0; + let inStr = false; + let esc = false; + + for (; i < s.length; i++) { + const ch = s[i]; + + if (inStr) { + if (esc) { + esc = false; + } else if (ch === "\\") { + esc = true; + } else if (ch === '"') { + inStr = false; + } + continue; + } + + if (ch === '"') { + inStr = true; + continue; + } + + if (ch === "{") depth++; + else if (ch === "}") { + depth--; + if (depth === 0) { + i++; // include '}' + const raw = s.slice(start, i); + blocks.push(raw); + break; + } + } + } + + skipWsAndCommas(); + } + + return blocks; } -const seenIg = new Set( - baseIgnores - .map((x) => pairKey(x?.skuA || x?.a || x?.left, x?.skuB || x?.b || x?.right)) - .filter(Boolean) -); -for (const x of ignoresIn) { - const k = pairKey(x?.skuA, x?.skuB); - if (!k || seenIg.has(k)) continue; - seenIg.add(k); - baseIgnores.push({ skuA: normSku(x.skuA), skuB: normSku(x.skuB) }); +function detectItemIndent(arrayInnerText, fieldIndent) { + // Try to infer indentation for the '{' line inside the array. + // If empty array, default to fieldIndent + 2 spaces. + const m = arrayInnerText.match(/\n([ \t]*)\{/); + if (m) return m[1]; + return fieldIndent + " "; } -const out = { - generatedAt: new Date().toISOString(), - links: baseLinks, - ignores: baseIgnores, -}; +function makePrettyObjBlock(objIndent, obj) { + // Match JSON.stringify(..., 2) object formatting inside arrays + const a = objIndent; + const b = objIndent + " "; + const fromSku = normSku(obj?.fromSku); + const toSku = normSku(obj?.toSku); + const skuA = normSku(obj?.skuA); + const skuB = normSku(obj?.skuB); -fs.mkdirSync(path.dirname(filePath), { recursive: true }); -fs.writeFileSync(filePath, JSON.stringify(out, null, 2) + "\n", "utf8"); + if (fromSku && toSku) { + return ( + `${a}{\n` + + `${b}"fromSku": ${JSON.stringify(fromSku)},\n` + + `${b}"toSku": ${JSON.stringify(toSku)}\n` + + `${a}}` + ); + } + + if (skuA && skuB) { + return ( + `${a}{\n` + + `${b}"skuA": ${JSON.stringify(skuA)},\n` + + `${b}"skuB": ${JSON.stringify(skuB)}\n` + + `${a}}` + ); + } + + return `${a}{}`; +} + +function applyInsertionsToArrayText({ + src, + propName, + incoming, + keyFn, + normalizeFn, +}) { + const span = findJsonArraySpan(src, propName); + if (!span) die(`Could not find "${propName}" array in ${filePath}`); + + const before = src.slice(0, span.open + 1); // includes '[' + const inner = src.slice(span.open + 1, span.close); // between [ and ] + const after = src.slice(span.close); // starts with ']' + + const itemIndent = detectItemIndent(inner, span.fieldIndent); + + const rawBlocks = splitArrayObjectBlocks(inner); + + const existing = []; + const seen = new Set(); + + for (const raw of rawBlocks) { + try { + const obj = JSON.parse(raw); + const k = keyFn(obj); + existing.push({ raw, obj, key: k }); + if (k) seen.add(k); + } catch { + // If parsing fails, keep the raw block as-is, but don't use it for keying + existing.push({ raw, obj: null, key: "" }); + } + } + + const toAdd = []; + for (const x of incoming) { + const nx = normalizeFn(x); + const k = keyFn(nx); + if (!k || seen.has(k)) continue; + seen.add(k); + toAdd.push({ obj: nx, key: k }); + } + + if (!toAdd.length) return src; // nothing to do + + // Insert each new item into sorted position by key (lex) + // We rebuild the list of raw blocks but preserve existing raw blocks untouched. + const outBlocks = existing.slice(); // keep {raw,obj,key} + + function findInsertIndex(k) { + for (let i = 0; i < outBlocks.length; i++) { + const kk = outBlocks[i]?.key || ""; + if (!kk) continue; // unknown blocks: keep them where they are + if (kk > k) return i; + } + return outBlocks.length; + } + + // Sort additions so results are deterministic + toAdd.sort((a, b) => a.key.localeCompare(b.key)); + + for (const add of toAdd) { + const idx = findInsertIndex(add.key); + const raw = makePrettyObjBlock(itemIndent, add.obj); + outBlocks.splice(idx, 0, { raw, obj: add.obj, key: add.key }); + } + + // Rebuild inner text, preserving inline-empty formatting if it was empty + let newInner = ""; + if (outBlocks.length === 0) { + newInner = inner; // shouldn't happen, but keep original + } else { + // Determine if original was inline empty: "links": [] + const wasInlineEmpty = /^\s*$/.test(inner); + if (wasInlineEmpty) { + // Convert to pretty multi-line on first insert (minimal and stable) + newInner = + "\n" + + outBlocks.map((x) => x.raw).join(",\n") + + "\n" + + span.fieldIndent; + } else { + // Keep pretty multi-line (same join style as JSON.stringify) + // Ensure leading/trailing newlines similar to original + const trimmed = inner.replace(/^\s+|\s+$/g, ""); + const hadLeadingNL = /^\s*\n/.test(inner); + const hadTrailingNL = /\n\s*$/.test(inner); + + const body = outBlocks.map((x) => x.raw).join(",\n"); + newInner = + (hadLeadingNL ? "\n" : "") + + body + + (hadTrailingNL ? "\n" + span.fieldIndent : ""); + // If original didn't have trailing newline before ']', keep it tight + if (!hadTrailingNL) newInner = "\n" + body + "\n" + span.fieldIndent; + } + } + + return before + newInner + after; +} + +/* ---------------- Apply edits ---------------- */ + +const filePath = path.join("data", "sku_links.json"); + +function ensureFileExists() { + if (fs.existsSync(filePath)) return; + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + // Create with stable formatting; generatedAt intentionally blank (we do not mutate it later) + const seed = { generatedAt: "", links: [], ignores: [] }; + fs.writeFileSync(filePath, JSON.stringify(seed, null, 2) + "\n", "utf8"); +} + +ensureFileExists(); + +let text = fs.readFileSync(filePath, "utf8"); + +// IMPORTANT: do NOT touch generatedAt at all. +// Also: do NOT re-stringify entire JSON; we only surgically insert into arrays. + +const normLinksIn = linksIn.map((x) => ({ + fromSku: normSku(x?.fromSku), + toSku: normSku(x?.toSku), +})); + +const normIgnoresIn = ignoresIn.map((x) => { + const a = normSku(x?.skuA); + const b = normSku(x?.skuB); + const k = pairKey(a, b); + if (!k) return { skuA: "", skuB: "" }; + const [p, q] = k.split("|"); + return { skuA: p, skuB: q }; +}); + +// Insert links (sorted by from→to) +text = applyInsertionsToArrayText({ + src: text, + propName: "links", + incoming: normLinksIn, + keyFn: (o) => linkKeyFrom(o?.fromSku, o?.toSku), + normalizeFn: (o) => ({ fromSku: normSku(o?.fromSku), toSku: normSku(o?.toSku) }), +}); + +// Insert ignores (sorted by canonical pair) +text = applyInsertionsToArrayText({ + src: text, + propName: "ignores", + incoming: normIgnoresIn, + keyFn: (o) => pairKey(o?.skuA, o?.skuB), + normalizeFn: (o) => { + const a = normSku(o?.skuA); + const b = normSku(o?.skuB); + const k = pairKey(a, b); + if (!k) return { skuA: "", skuB: "" }; + const [p, q] = k.split("|"); + return { skuA: p, skuB: q }; + }, +}); + +fs.writeFileSync(filePath, text, "utf8"); + +/* ---------------- Git ops + PR + close issue ---------------- */ + +// Ensure git identity is set for commit (Actions runners often lack it) +try { + sh(`git config user.name "github-actions[bot]"`); + sh(`git config user.email "41898282+github-actions[bot]@users.noreply.github.com"`); +} catch { + // ignore +} const ts = new Date().toISOString().replace(/[:.]/g, "-"); const branch = `stviz/issue-${ISSUE_NUMBER}-${ts}`; sh(`git checkout -b "${branch}"`); sh(`git add "${filePath}"`); -sh(`git commit -m "stviz: apply sku edits (issue #${ISSUE_NUMBER})"`); +// If no diffs (all edits were duplicates), don't create PR or close issue. +const diff = sh(`git status --porcelain "${filePath}"`); +if (!diff) { + console.log("No changes to commit (all edits already present). Leaving issue open."); + process.exit(0); +} + +sh(`git commit -m "stviz: apply sku edits (issue #${ISSUE_NUMBER})"`); sh(`git push -u origin "${branch}"`); const prTitle = `STVIZ: SKU link updates (issue #${ISSUE_NUMBER})`; const prBody = `Automated PR created from issue #${ISSUE_NUMBER}: ${ISSUE_TITLE}`; -sh(`gh -R "${REPO}" pr create --base data --head "${branch}" --title "${prTitle}" --body "${prBody}"`); +// Create PR and capture URL/number deterministically (no search/index lag) +const prUrl = sh( + `gh -R "${REPO}" pr create --base data --head "${branch}" --title "${prTitle}" --body "${prBody}" --json url --jq .url` +); +const prNumber = sh(`gh -R "${REPO}" pr view "${prUrl}" --json number --jq .number`); + +sh( + `gh -R "${REPO}" issue close "${ISSUE_NUMBER}" -c "Processed by STVIZ automation. Opened PR #${prNumber}: ${prUrl}"` +);