Prod can create PRs

This commit is contained in:
Brennan Wilkes (Text Groove) 2026-01-22 10:45:55 -08:00
parent 22f96065a9
commit b8062b010f
5 changed files with 294 additions and 40 deletions

View file

@ -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."

View file

@ -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*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 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}"`);

View file

@ -9,13 +9,9 @@ import {
} from "./sku.js"; } from "./sku.js";
import { loadIndex } from "./state.js"; import { loadIndex } from "./state.js";
import { aggregateBySku } from "./catalog.js"; import { aggregateBySku } from "./catalog.js";
import {
isLocalWriteMode,
loadSkuMetaBestEffort,
apiWriteSkuLink,
apiWriteSkuIgnore,
} from "./api.js";
import { loadSkuRules, clearSkuRulesCache } from "./mapping.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 ---------------- */ /* ---------------- Similarity helpers ---------------- */
@ -382,7 +378,7 @@ export async function renderSkuLinker($app) {
<button id="back" class="btn"> Back</button> <button id="back" class="btn"> Back</button>
<div style="flex:1"></div> <div style="flex:1"></div>
<span class="badge">SKU Linker</span> <span class="badge">SKU Linker</span>
<span class="badge mono">${esc(localWrite ? "LOCAL WRITE" : "READ-ONLY")}</span> ${localWrite ? `<span class="badge mono">LOCAL WRITE</span>` : `<button id="createPrBtn" class="btn" disabled>Create PR</button>`}
</div> </div>
<div class="card" style="padding:14px;"> <div class="card" style="padding:14px;">
@ -586,53 +582,102 @@ export async function renderSkuLinker($app) {
} }
function updateButtons() { function updateButtons() {
if (!localWrite) { const isPages = !localWrite;
$linkBtn.disabled = true;
$ignoreBtn.disabled = true; // Pages: keep Create PR button state in sync with pending edits
$status.textContent = "Write disabled on GitHub Pages. Use: node viz/serve.js and open 127.0.0.1."; const $pr = isPages ? document.getElementById("createPrBtn") : null;
return; if ($pr) {
const c0 = pendingCounts();
$pr.disabled = c0.total === 0;
} }
if (!(pinnedL && pinnedR)) { if (!(pinnedL && pinnedR)) {
$linkBtn.disabled = true; $linkBtn.disabled = true;
$ignoreBtn.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; return;
} }
const a = String(pinnedL.sku || ""); const a = String(pinnedL.sku || "");
const b = String(pinnedR.sku || ""); const b = String(pinnedR.sku || "");
if (a === b) { if (a === b) {
$linkBtn.disabled = true; $linkBtn.disabled = true;
$ignoreBtn.disabled = true; $ignoreBtn.disabled = true;
$status.textContent = "Not allowed: both sides cannot be the same SKU."; $status.textContent = "Not allowed: both sides cannot be the same SKU.";
return; return;
} }
if (storesOverlap(pinnedL, pinnedR)) { if (storesOverlap(pinnedL, pinnedR)) {
$linkBtn.disabled = true; $linkBtn.disabled = true;
$ignoreBtn.disabled = true; $ignoreBtn.disabled = true;
$status.textContent = "Not allowed: both items belong to the same store."; $status.textContent = "Not allowed: both items belong to the same store.";
return; return;
} }
if (sameGroup(a, b)) { if (sameGroup(a, b)) {
$linkBtn.disabled = true; $linkBtn.disabled = true;
$ignoreBtn.disabled = true; $ignoreBtn.disabled = true;
$status.textContent = "Already linked: both SKUs are in the same group."; $status.textContent = "Already linked: both SKUs are in the same group.";
return; return;
} }
$linkBtn.disabled = false; $linkBtn.disabled = false;
$ignoreBtn.disabled = false; $ignoreBtn.disabled = false;
if (isIgnoredPair(a, b)) { if (isIgnoredPair(a, b)) {
$status.textContent = "This pair is already ignored."; $status.textContent = "This pair is already ignored.";
} else if ($status.textContent === "Pin one item on each side to enable linking / ignoring.") { } else if ($status.textContent === "Pin one item on each side to enable linking / ignoring.") {
$status.textContent = ""; $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` +
`<!-- stviz-sku-edits:BEGIN -->\n` +
payload +
`\n<!-- stviz-sku-edits:END -->\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() { function updateAll() {
renderSide("L"); renderSide("L");
@ -657,11 +702,11 @@ export async function renderSkuLinker($app) {
}); });
$linkBtn.addEventListener("click", async () => { $linkBtn.addEventListener("click", async () => {
if (!(pinnedL && pinnedR) || !localWrite) return; if (!(pinnedL && pinnedR)) return;
const a = String(pinnedL.sku || ""); const a = String(pinnedL.sku || "");
const b = String(pinnedR.sku || ""); const b = String(pinnedR.sku || "");
if (!a || !b) { if (!a || !b) {
$status.textContent = "Not allowed: missing SKU."; $status.textContent = "Not allowed: missing SKU.";
return; return;
@ -682,16 +727,16 @@ export async function renderSkuLinker($app) {
$status.textContent = "This pair is already ignored."; $status.textContent = "This pair is already ignored.";
return; return;
} }
const aCanon = rules.canonicalSku(a); const aCanon = rules.canonicalSku(a);
const bCanon = rules.canonicalSku(b); const bCanon = rules.canonicalSku(b);
const preferred = pickPreferredCanonical(allRows, [a, b, aCanon, bCanon]); const preferred = pickPreferredCanonical(allRows, [a, b, aCanon, bCanon]);
if (!preferred) { if (!preferred) {
$status.textContent = "Write failed: could not choose a canonical SKU."; $status.textContent = "Write failed: could not choose a canonical SKU.";
return; return;
} }
const writes = []; const writes = [];
function addWrite(fromSku, toSku) { function addWrite(fromSku, toSku) {
const f = String(fromSku || "").trim(); const f = String(fromSku || "").trim();
@ -700,12 +745,12 @@ export async function renderSkuLinker($app) {
if (rules.canonicalSku(f) === t) return; if (rules.canonicalSku(f) === t) return;
writes.push({ fromSku: f, toSku: t }); writes.push({ fromSku: f, toSku: t });
} }
addWrite(aCanon, preferred); addWrite(aCanon, preferred);
addWrite(bCanon, preferred); addWrite(bCanon, preferred);
addWrite(a, preferred); addWrite(a, preferred);
addWrite(b, preferred); addWrite(b, preferred);
const seenW = new Set(); const seenW = new Set();
const uniq = []; const uniq = [];
for (const w of writes) { for (const w of writes) {
@ -714,25 +759,51 @@ export async function renderSkuLinker($app) {
seenW.add(k); seenW.add(k);
uniq.push(w); 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)}`; $status.textContent = `Writing ${uniq.length} link(s) to canonical ${displaySku(preferred)}`;
try { try {
for (let i = 0; i < uniq.length; i++) { for (let i = 0; i < uniq.length; i++) {
const w = uniq[i]; const w = uniq[i];
$status.textContent = `Writing (${i + 1}/${uniq.length}): ${displaySku(w.fromSku)}${displaySku(w.toSku)}`; $status.textContent = `Writing (${i + 1}/${uniq.length}): ${displaySku(w.fromSku)}${displaySku(w.toSku)}`;
await apiWriteSkuLink(w.fromSku, w.toSku); await apiWriteSkuLink(w.fromSku, w.toSku);
} }
clearSkuRulesCache(); clearSkuRulesCache();
rules = await loadSkuRules(); rules = await loadSkuRules();
ignoreSet = rules.ignoreSet; ignoreSet = rules.ignoreSet;
const meta2 = await loadSkuMetaBestEffort(); const meta2 = await loadSkuMetaBestEffort();
const rebuilt = buildMappedSkuSet(meta2?.links || []); const rebuilt = buildMappedSkuSet(meta2?.links || []);
mappedSkus.clear(); mappedSkus.clear();
for (const x of rebuilt) mappedSkus.add(x); for (const x of rebuilt) mappedSkus.add(x);
$status.textContent = `Saved. Canonical is now ${displaySku(preferred)}.`; $status.textContent = `Saved. Canonical is now ${displaySku(preferred)}.`;
pinnedL = null; pinnedL = null;
pinnedR = null; pinnedR = null;
@ -741,13 +812,14 @@ export async function renderSkuLinker($app) {
$status.textContent = `Write failed: ${String(e && e.message ? e.message : e)}`; $status.textContent = `Write failed: ${String(e && e.message ? e.message : e)}`;
} }
}); });
$ignoreBtn.addEventListener("click", async () => { $ignoreBtn.addEventListener("click", async () => {
if (!(pinnedL && pinnedR) || !localWrite) return; if (!(pinnedL && pinnedR)) return;
const a = String(pinnedL.sku || ""); const a = String(pinnedL.sku || "");
const b = String(pinnedR.sku || ""); const b = String(pinnedR.sku || "");
if (!a || !b) { if (!a || !b) {
$status.textContent = "Not allowed: missing SKU."; $status.textContent = "Not allowed: missing SKU.";
return; return;
@ -768,9 +840,32 @@ export async function renderSkuLinker($app) {
$status.textContent = "This pair is already ignored."; $status.textContent = "This pair is already ignored.";
return; 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)}`; $status.textContent = `Ignoring: ${displaySku(a)} × ${displaySku(b)}`;
try { try {
const out = await apiWriteSkuIgnore(a, b); const out = await apiWriteSkuIgnore(a, b);
ignoreSet.add(rules.canonicalPairKey(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)}`; $status.textContent = `Ignore failed: ${String(e && e.message ? e.message : e)}`;
} }
}); });
updateAll(); updateAll();
} }

View file

@ -1,5 +1,6 @@
// viz/app/mapping.js // viz/app/mapping.js
import { loadSkuMetaBestEffort } from "./api.js"; import { loadSkuMetaBestEffort, isLocalWriteMode } from "./api.js";
import { applyPendingToMeta } from "./pending.js";
let CACHED = null; let CACHED = null;
@ -169,7 +170,10 @@ function buildGroupsAndCanonicalMap(links) {
export async function loadSkuRules() { export async function loadSkuRules() {
if (CACHED) return CACHED; 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 links = Array.isArray(meta?.links) ? meta.links : [];
const ignores = Array.isArray(meta?.ignores) ? meta.ignores : []; const ignores = Array.isArray(meta?.ignores) ? meta.ignores : [];

0
viz/app/pending.js Normal file
View file