mirror of
https://github.com/samsonjs/spirit-tracker.git
synced 2026-04-27 15:07:43 +00:00
feat: Better insertion to JSON
This commit is contained in:
parent
e2e145816b
commit
adce159c75
1 changed files with 348 additions and 40 deletions
|
|
@ -20,7 +20,9 @@ const REPO = process.env.REPO || "";
|
||||||
if (!ISSUE_NUMBER) die("Missing ISSUE_NUMBER");
|
if (!ISSUE_NUMBER) die("Missing ISSUE_NUMBER");
|
||||||
if (!REPO) die("Missing REPO");
|
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*-->/);
|
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.");
|
if (!m) die("No stviz payload found in issue body.");
|
||||||
|
|
||||||
let payload;
|
let payload;
|
||||||
|
|
@ -38,70 +40,376 @@ const ignoresIn = Array.isArray(payload?.ignores) ? payload.ignores : [];
|
||||||
function normSku(s) {
|
function normSku(s) {
|
||||||
return String(s || "").trim();
|
return String(s || "").trim();
|
||||||
}
|
}
|
||||||
function linkKey(x) {
|
|
||||||
const a = normSku(x?.fromSku);
|
function linkKeyFrom(a, b) {
|
||||||
const b = normSku(x?.toSku);
|
const x = normSku(a);
|
||||||
return a && b && a !== b ? `${a}→${b}` : "";
|
const y = normSku(b);
|
||||||
|
return x && y && x !== y ? `${x}→${y}` : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function linkKey(x) {
|
||||||
|
return linkKeyFrom(x?.fromSku, x?.toSku);
|
||||||
|
}
|
||||||
|
|
||||||
function pairKey(a, b) {
|
function pairKey(a, b) {
|
||||||
const x = normSku(a), y = normSku(b);
|
const x = normSku(a),
|
||||||
|
y = normSku(b);
|
||||||
if (!x || !y || x === y) return "";
|
if (!x || !y || x === y) return "";
|
||||||
return x < y ? `${x}|${y}` : `${y}|${x}`;
|
return x < y ? `${x}|${y}` : `${y}|${x}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const filePath = path.join("data", "sku_links.json");
|
/* ---------------- Minimal, merge-friendly JSON array insertion ---------------- */
|
||||||
let base = { generatedAt: "", links: [], ignores: [] };
|
|
||||||
|
|
||||||
if (fs.existsSync(filePath)) {
|
function findJsonArraySpan(src, propName) {
|
||||||
try {
|
// Finds the [ ... ] span for `"propName": [ ... ]` and returns { start, end, open, close, fieldIndent }
|
||||||
base = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
const re = new RegExp(`(^[ \\t]*)"${propName}"\\s*:\\s*\\[`, "m");
|
||||||
} catch {
|
const mm = src.match(re);
|
||||||
// keep defaults
|
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 : [];
|
function splitArrayObjectBlocks(arrayInnerText) {
|
||||||
const baseIgnores = Array.isArray(base?.ignores) ? base.ignores : [];
|
// 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));
|
let i = 0;
|
||||||
for (const x of linksIn) {
|
const s = arrayInnerText;
|
||||||
const k = linkKey(x);
|
|
||||||
if (!k || seenLinks.has(k)) continue;
|
function skipWsAndCommas() {
|
||||||
seenLinks.add(k);
|
while (i < s.length) {
|
||||||
baseLinks.push({ fromSku: normSku(x.fromSku), toSku: normSku(x.toSku) });
|
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(
|
function detectItemIndent(arrayInnerText, fieldIndent) {
|
||||||
baseIgnores
|
// Try to infer indentation for the '{' line inside the array.
|
||||||
.map((x) => pairKey(x?.skuA || x?.a || x?.left, x?.skuB || x?.b || x?.right))
|
// If empty array, default to fieldIndent + 2 spaces.
|
||||||
.filter(Boolean)
|
const m = arrayInnerText.match(/\n([ \t]*)\{/);
|
||||||
);
|
if (m) return m[1];
|
||||||
for (const x of ignoresIn) {
|
return fieldIndent + " ";
|
||||||
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) });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const out = {
|
function makePrettyObjBlock(objIndent, obj) {
|
||||||
generatedAt: new Date().toISOString(),
|
// Match JSON.stringify(..., 2) object formatting inside arrays
|
||||||
links: baseLinks,
|
const a = objIndent;
|
||||||
ignores: baseIgnores,
|
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 });
|
if (fromSku && toSku) {
|
||||||
fs.writeFileSync(filePath, JSON.stringify(out, null, 2) + "\n", "utf8");
|
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 ts = new Date().toISOString().replace(/[:.]/g, "-");
|
||||||
const branch = `stviz/issue-${ISSUE_NUMBER}-${ts}`;
|
const branch = `stviz/issue-${ISSUE_NUMBER}-${ts}`;
|
||||||
|
|
||||||
sh(`git checkout -b "${branch}"`);
|
sh(`git checkout -b "${branch}"`);
|
||||||
sh(`git add "${filePath}"`);
|
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}"`);
|
sh(`git push -u origin "${branch}"`);
|
||||||
|
|
||||||
const prTitle = `STVIZ: SKU link updates (issue #${ISSUE_NUMBER})`;
|
const prTitle = `STVIZ: SKU link updates (issue #${ISSUE_NUMBER})`;
|
||||||
const prBody = `Automated PR created from issue #${ISSUE_NUMBER}: ${ISSUE_TITLE}`;
|
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}"`
|
||||||
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue