diff --git a/.github/workflows/stviz_create_pr_from_issue.yml b/.github/workflows/stviz_create_pr_from_issue.yml
new file mode 100644
index 0000000..bd9e943
--- /dev/null
+++ b/.github/workflows/stviz_create_pr_from_issue.yml
@@ -0,0 +1,47 @@
+name: STVIZ - Create PR from Issue
+
+on:
+ issues:
+ types: [opened]
+
+permissions:
+ contents: write
+ pull-requests: write
+ issues: write
+
+jobs:
+ apply:
+ if: contains(github.event.issue.title, '[stviz]') || contains(github.event.issue.body, 'stviz-sku-edits:BEGIN')
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout data branch
+ uses: actions/checkout@v4
+ with:
+ ref: data
+ fetch-depth: 0
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: "20"
+
+ - name: Apply edits, push branch, open PR
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ ISSUE_NUMBER: ${{ github.event.issue.number }}
+ ISSUE_TITLE: ${{ github.event.issue.title }}
+ ISSUE_BODY: ${{ github.event.issue.body }}
+ REPO: ${{ github.repository }}
+ run: |
+ set -euo pipefail
+ node tools/stviz_apply_issue_edits.js
+
+ - name: Close issue
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ ISSUE_NUMBER: ${{ github.event.issue.number }}
+ REPO: ${{ github.repository }}
+ run: |
+ set -euo pipefail
+ gh -R "$REPO" issue close "$ISSUE_NUMBER" -c "Processed by STVIZ automation. A PR has been opened."
diff --git a/tools/stviz_apply_issue_edits.js b/tools/stviz_apply_issue_edits.js
new file mode 100644
index 0000000..3989953
--- /dev/null
+++ b/tools/stviz_apply_issue_edits.js
@@ -0,0 +1,107 @@
+// 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*([\s\S]*?)\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 linkKey(x) {
+ const a = normSku(x?.fromSku);
+ const b = normSku(x?.toSku);
+ return a && b && a !== b ? `${a}→${b}` : "";
+}
+function pairKey(a, 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: [] };
+
+if (fs.existsSync(filePath)) {
+ try {
+ base = JSON.parse(fs.readFileSync(filePath, "utf8"));
+ } catch {
+ // keep defaults
+ }
+}
+
+const baseLinks = Array.isArray(base?.links) ? base.links : [];
+const baseIgnores = Array.isArray(base?.ignores) ? base.ignores : [];
+
+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) });
+}
+
+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) });
+}
+
+const out = {
+ generatedAt: new Date().toISOString(),
+ links: baseLinks,
+ ignores: baseIgnores,
+};
+
+fs.mkdirSync(path.dirname(filePath), { recursive: true });
+fs.writeFileSync(filePath, JSON.stringify(out, null, 2) + "\n", "utf8");
+
+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})"`);
+
+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}"`);
diff --git a/viz/app/linker_page.js b/viz/app/linker_page.js
index 4e8ea89..8d228c5 100644
--- a/viz/app/linker_page.js
+++ b/viz/app/linker_page.js
@@ -9,13 +9,9 @@ import {
} from "./sku.js";
import { loadIndex } from "./state.js";
import { aggregateBySku } from "./catalog.js";
-import {
- isLocalWriteMode,
- loadSkuMetaBestEffort,
- apiWriteSkuLink,
- apiWriteSkuIgnore,
-} from "./api.js";
import { loadSkuRules, clearSkuRulesCache } from "./mapping.js";
+import { inferGithubOwnerRepo, isLocalWriteMode, loadSkuMetaBestEffort, apiWriteSkuLink, apiWriteSkuIgnore } from "./api.js";
+import { addPendingLink, addPendingIgnore, pendingCounts, loadPendingEdits } from "./pending.js";
/* ---------------- Similarity helpers ---------------- */
@@ -382,7 +378,7 @@ export async function renderSkuLinker($app) {
SKU Linker
- ${esc(localWrite ? "LOCAL WRITE" : "READ-ONLY")}
+ ${localWrite ? `LOCAL WRITE` : ``}
@@ -586,53 +582,102 @@ export async function renderSkuLinker($app) {
}
function updateButtons() {
- if (!localWrite) {
- $linkBtn.disabled = true;
- $ignoreBtn.disabled = true;
- $status.textContent = "Write disabled on GitHub Pages. Use: node viz/serve.js and open 127.0.0.1.";
- return;
+ const isPages = !localWrite;
+
+ // Pages: keep Create PR button state in sync with pending edits
+ const $pr = isPages ? document.getElementById("createPrBtn") : null;
+ if ($pr) {
+ const c0 = pendingCounts();
+ $pr.disabled = c0.total === 0;
}
-
+
if (!(pinnedL && pinnedR)) {
$linkBtn.disabled = true;
$ignoreBtn.disabled = true;
- if (!$status.textContent) $status.textContent = "Pin one item on each side to enable linking / ignoring.";
+
+ if (isPages) {
+ const c = pendingCounts();
+ $status.textContent = c.total
+ ? `Pending changes: ${c.links} link(s), ${c.ignores} ignore(s). Create PR when ready.`
+ : "Pin one item on each side to enable linking / ignoring.";
+ } else {
+ if (!$status.textContent) $status.textContent = "Pin one item on each side to enable linking / ignoring.";
+ }
return;
}
-
+
const a = String(pinnedL.sku || "");
const b = String(pinnedR.sku || "");
-
+
if (a === b) {
$linkBtn.disabled = true;
$ignoreBtn.disabled = true;
$status.textContent = "Not allowed: both sides cannot be the same SKU.";
return;
}
-
+
if (storesOverlap(pinnedL, pinnedR)) {
$linkBtn.disabled = true;
$ignoreBtn.disabled = true;
$status.textContent = "Not allowed: both items belong to the same store.";
return;
}
-
+
if (sameGroup(a, b)) {
$linkBtn.disabled = true;
$ignoreBtn.disabled = true;
$status.textContent = "Already linked: both SKUs are in the same group.";
return;
}
-
+
$linkBtn.disabled = false;
$ignoreBtn.disabled = false;
-
+
if (isIgnoredPair(a, b)) {
$status.textContent = "This pair is already ignored.";
} else if ($status.textContent === "Pin one item on each side to enable linking / ignoring.") {
$status.textContent = "";
}
+
+ // Refresh PR button state after any status changes
+ if ($pr) {
+ const c = pendingCounts();
+ $pr.disabled = c.total === 0;
+ }
}
+
+
+ const $createPrBtn = document.getElementById("createPrBtn");
+ if ($createPrBtn) {
+ $createPrBtn.addEventListener("click", () => {
+ const c = pendingCounts();
+ if (c.total === 0) return;
+
+ const { owner, repo } = inferGithubOwnerRepo();
+ const edits = loadPendingEdits();
+
+ // Payload is small (ops list), action will merge into data/sku_links.json
+ const payload = JSON.stringify(
+ { schema: "stviz-sku-edits-v1", createdAt: edits.createdAt || new Date().toISOString(), links: edits.links, ignores: edits.ignores },
+ null,
+ 2
+ );
+
+ const title = `[stviz] sku link updates (${c.links} link, ${c.ignores} ignore)`;
+ const body =
+ `Automated request from GitHub Pages SKU Linker.\n\n` +
+ `\n` +
+ payload +
+ `\n\n`;
+
+ const u =
+ `https://github.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}` +
+ `/issues/new?title=${encodeURIComponent(title)}&body=${encodeURIComponent(body)}`;
+
+ window.open(u, "_blank", "noopener,noreferrer");
+ });
+ }
+
function updateAll() {
renderSide("L");
@@ -657,11 +702,11 @@ export async function renderSkuLinker($app) {
});
$linkBtn.addEventListener("click", async () => {
- if (!(pinnedL && pinnedR) || !localWrite) return;
-
+ if (!(pinnedL && pinnedR)) return;
+
const a = String(pinnedL.sku || "");
const b = String(pinnedR.sku || "");
-
+
if (!a || !b) {
$status.textContent = "Not allowed: missing SKU.";
return;
@@ -682,16 +727,16 @@ export async function renderSkuLinker($app) {
$status.textContent = "This pair is already ignored.";
return;
}
-
+
const aCanon = rules.canonicalSku(a);
const bCanon = rules.canonicalSku(b);
-
+
const preferred = pickPreferredCanonical(allRows, [a, b, aCanon, bCanon]);
if (!preferred) {
$status.textContent = "Write failed: could not choose a canonical SKU.";
return;
}
-
+
const writes = [];
function addWrite(fromSku, toSku) {
const f = String(fromSku || "").trim();
@@ -700,12 +745,12 @@ export async function renderSkuLinker($app) {
if (rules.canonicalSku(f) === t) return;
writes.push({ fromSku: f, toSku: t });
}
-
+
addWrite(aCanon, preferred);
addWrite(bCanon, preferred);
addWrite(a, preferred);
addWrite(b, preferred);
-
+
const seenW = new Set();
const uniq = [];
for (const w of writes) {
@@ -714,25 +759,51 @@ export async function renderSkuLinker($app) {
seenW.add(k);
uniq.push(w);
}
-
+
+ // ---------------- GitHub Pages mode: stage edits locally ----------------
+ if (!localWrite) {
+ for (const w of uniq) addPendingLink(w.fromSku, w.toSku);
+
+ clearSkuRulesCache();
+ rules = await loadSkuRules();
+ ignoreSet = rules.ignoreSet;
+
+ // rebuild mappedSkus based on merged rules (includes pending)
+ const rebuilt = buildMappedSkuSet(rules.links || []);
+ mappedSkus.clear();
+ for (const x of rebuilt) mappedSkus.add(x);
+
+ const c = pendingCounts();
+ $status.textContent = `Staged locally. Pending: ${c.links} link(s), ${c.ignores} ignore(s).`;
+
+ const $pr = document.getElementById("createPrBtn");
+ if ($pr) $pr.disabled = c.total === 0;
+
+ pinnedL = null;
+ pinnedR = null;
+ updateAll();
+ return;
+ }
+
+ // ---------------- Local mode: write via disk-backed API ----------------
$status.textContent = `Writing ${uniq.length} link(s) to canonical ${displaySku(preferred)} …`;
-
+
try {
for (let i = 0; i < uniq.length; i++) {
const w = uniq[i];
$status.textContent = `Writing (${i + 1}/${uniq.length}): ${displaySku(w.fromSku)} → ${displaySku(w.toSku)} …`;
await apiWriteSkuLink(w.fromSku, w.toSku);
}
-
+
clearSkuRulesCache();
rules = await loadSkuRules();
ignoreSet = rules.ignoreSet;
-
+
const meta2 = await loadSkuMetaBestEffort();
const rebuilt = buildMappedSkuSet(meta2?.links || []);
mappedSkus.clear();
for (const x of rebuilt) mappedSkus.add(x);
-
+
$status.textContent = `Saved. Canonical is now ${displaySku(preferred)}.`;
pinnedL = null;
pinnedR = null;
@@ -741,13 +812,14 @@ export async function renderSkuLinker($app) {
$status.textContent = `Write failed: ${String(e && e.message ? e.message : e)}`;
}
});
+
$ignoreBtn.addEventListener("click", async () => {
- if (!(pinnedL && pinnedR) || !localWrite) return;
-
+ if (!(pinnedL && pinnedR)) return;
+
const a = String(pinnedL.sku || "");
const b = String(pinnedR.sku || "");
-
+
if (!a || !b) {
$status.textContent = "Not allowed: missing SKU.";
return;
@@ -768,9 +840,32 @@ export async function renderSkuLinker($app) {
$status.textContent = "This pair is already ignored.";
return;
}
-
+
+ // ---------------- GitHub Pages mode: stage ignore locally ----------------
+ if (!localWrite) {
+ $status.textContent = `Staging ignore: ${displaySku(a)} × ${displaySku(b)} …`;
+
+ addPendingIgnore(a, b);
+
+ clearSkuRulesCache();
+ rules = await loadSkuRules();
+ ignoreSet = rules.ignoreSet;
+
+ const c = pendingCounts();
+ $status.textContent = `Staged locally. Pending: ${c.links} link(s), ${c.ignores} ignore(s).`;
+
+ const $pr = document.getElementById("createPrBtn");
+ if ($pr) $pr.disabled = c.total === 0;
+
+ pinnedL = null;
+ pinnedR = null;
+ updateAll();
+ return;
+ }
+
+ // ---------------- Local mode: write via disk-backed API ----------------
$status.textContent = `Ignoring: ${displaySku(a)} × ${displaySku(b)} …`;
-
+
try {
const out = await apiWriteSkuIgnore(a, b);
ignoreSet.add(rules.canonicalPairKey(a, b));
@@ -782,6 +877,7 @@ export async function renderSkuLinker($app) {
$status.textContent = `Ignore failed: ${String(e && e.message ? e.message : e)}`;
}
});
+
updateAll();
}
diff --git a/viz/app/mapping.js b/viz/app/mapping.js
index c056ad2..f2b3a2e 100644
--- a/viz/app/mapping.js
+++ b/viz/app/mapping.js
@@ -1,5 +1,6 @@
// viz/app/mapping.js
-import { loadSkuMetaBestEffort } from "./api.js";
+import { loadSkuMetaBestEffort, isLocalWriteMode } from "./api.js";
+import { applyPendingToMeta } from "./pending.js";
let CACHED = null;
@@ -169,7 +170,10 @@ function buildGroupsAndCanonicalMap(links) {
export async function loadSkuRules() {
if (CACHED) return CACHED;
- const meta = await loadSkuMetaBestEffort();
+ let meta = await loadSkuMetaBestEffort();
+ if (!isLocalWriteMode()) {
+ meta = applyPendingToMeta(meta);
+ }
const links = Array.isArray(meta?.links) ? meta.links : [];
const ignores = Array.isArray(meta?.ignores) ? meta.ignores : [];
diff --git a/viz/app/pending.js b/viz/app/pending.js
new file mode 100644
index 0000000..e69de29