mirror of
https://github.com/samsonjs/spirit-tracker.git
synced 2026-03-25 09:25:51 +00:00
Prod can create PRs
This commit is contained in:
parent
22f96065a9
commit
b8062b010f
5 changed files with 294 additions and 40 deletions
47
.github/workflows/stviz_create_pr_from_issue.yml
vendored
Normal file
47
.github/workflows/stviz_create_pr_from_issue.yml
vendored
Normal 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."
|
||||
107
tools/stviz_apply_issue_edits.js
Normal file
107
tools/stviz_apply_issue_edits.js
Normal 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}"`);
|
||||
|
|
@ -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) {
|
|||
<button id="back" class="btn">← Back</button>
|
||||
<div style="flex:1"></div>
|
||||
<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 class="card" style="padding:14px;">
|
||||
|
|
@ -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` +
|
||||
`<!-- 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() {
|
||||
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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 : [];
|
||||
|
||||
|
|
|
|||
0
viz/app/pending.js
Normal file
0
viz/app/pending.js
Normal file
Loading…
Reference in a new issue