spirit-tracker/tools/stviz_apply_issue_edits.js
Brennan Wilkes (Text Groove) 7a33d51c90 UX Improvements
2026-02-10 16:45:22 -08:00

375 lines
9.7 KiB
JavaScript

// tools/stviz_apply_issue_edits.js
import fs from "node:fs";
import path from "node:path";
import { execSync } from "node:child_process";
function die(msg) {
console.error(msg);
process.exit(1);
}
function sh(cmd) {
return execSync(cmd, { stdio: "pipe", encoding: "utf8" }).trim();
}
const ISSUE_BODY = process.env.ISSUE_BODY || "";
const ISSUE_NUMBER = String(process.env.ISSUE_NUMBER || "").trim();
const ISSUE_TITLE = process.env.ISSUE_TITLE || "";
const REPO = process.env.REPO || "";
if (!ISSUE_NUMBER) die("Missing ISSUE_NUMBER");
if (!REPO) die("Missing REPO");
const m = ISSUE_BODY.match(/<!--\s*stviz-sku-edits:BEGIN\s*-->\s*([\s\S]*?)\s*<!--\s*stviz-sku-edits:END\s*-->/);
if (!m) die("No stviz payload found in issue body.");
let payload;
try {
payload = JSON.parse(m[1]);
} catch (e) {
die(`Invalid JSON payload: ${e?.message || e}`);
}
if (payload?.schema !== "stviz-sku-edits-v1") die("Unsupported payload schema.");
const linksIn = Array.isArray(payload?.links) ? payload.links : [];
const ignoresIn = Array.isArray(payload?.ignores) ? payload.ignores : [];
function normSku(s) {
return String(s || "").trim();
}
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);
if (!x || !y || x === y) return "";
return x < y ? `${x}|${y}` : `${y}|${x}`;
}
/* ---------------- Minimal, merge-friendly JSON array insertion ---------------- */
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;
}
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 = [];
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;
}
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 + " ";
}
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);
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);
// Parse existing objects to build a dedupe set (does NOT modify inner text)
const rawBlocks = splitArrayObjectBlocks(inner);
const seen = new Set();
for (const raw of rawBlocks) {
try {
const obj = JSON.parse(raw);
const k = keyFn(obj);
if (k) seen.add(k);
} catch {
// ignore unparsable blocks for dedupe purposes
}
}
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(nx);
}
if (!toAdd.length) return src;
// Deterministic order for new items only (doesn't reorder existing)
const addBlocks = toAdd
.map((obj) => ({ obj, key: keyFn(obj) }))
.sort((a, b) => String(a.key).localeCompare(String(b.key)))
.map((x) => makePrettyObjBlock(itemIndent, x.obj));
const wasInlineEmpty = /^\s*$/.test(inner);
let newInner;
if (wasInlineEmpty) {
// "links": [] -> pretty multiline
newInner = "\n" + addBlocks.join(",\n") + "\n" + span.fieldIndent;
} else {
// Keep existing whitespace EXACTLY; append before trailing whitespace
const m = inner.match(/\s*$/);
const tail = m ? m[0] : "";
const body = inner.slice(0, inner.length - tail.length).replace(/\s*$/, ""); // end at last non-ws
newInner = body + ",\n" + addBlocks.join(",\n") + tail;
}
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}"`);
// 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}`;
function extractPrUrl(out) {
// gh pr create usually prints the PR URL to stdout; be robust in case extra text appears.
const m = String(out || "").match(/https?:\/\/\S+\/pull\/\d+\S*/);
if (!m) die(`Could not find PR URL in gh output:\n${out}`);
return m[0];
}
// Create PR and capture URL/number without relying on unsupported flags
const prCreateOut = sh(
`gh -R "${REPO}" pr create --base data --head "${branch}" --title "${prTitle}" --body "${prBody}"`,
);
const prUrl = extractPrUrl(prCreateOut);
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}"`,
);