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