mirror of
https://github.com/KytyPS5/kytyps5.github.io.git
synced 2026-10-09 08:49:37 -07:00
362 lines
17 KiB
JavaScript
362 lines
17 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Convert a structured game-status issue into a Markdown report under
|
|
* src/content/compat/. Invoked by .github/workflows/compat-convert.yml (the
|
|
* `/compat` comment command on a compat mirror issue).
|
|
*
|
|
* Issues now live on the KytyPS5 repo (Game Emulation Status Report template,
|
|
* .github/ISSUE_TEMPLATE/kytyps5-game-emulation.yaml) and are fetched from
|
|
* there — this script parses that template's fields straight out of the raw
|
|
* issue body, with fallbacks for the site repo's legacy 4-tier template so old
|
|
* issues can still be converted.
|
|
*
|
|
* Usage (new template — body parsed from the raw issue):
|
|
* node scripts/issue-to-compat.mjs --issue-body-file /tmp/issue-body.txt \
|
|
* --date 2026-08-10 --source "#123" \
|
|
* --source-url "https://github.com/KytyPS5/KytyPS5/issues/123" \
|
|
* [--title "Astro Bot"] [--slug astro-bot-windows]
|
|
*
|
|
* Usage (explicit flags — legacy / manual runs):
|
|
* node scripts/issue-to-compat.mjs --title "Disgaea 6" --status "in-game" \
|
|
* --version "main" --date 2026-08-10 --os windows --hardware "Ryzen 9 / RTX 5090" \
|
|
* --body "notes…" --steps "1. Boot…" --expected-behavior "…" --extra-notes "settings…" \
|
|
* --source "#123" --source-url "https://github.com/org/repo/issues/123" \
|
|
* [--game-version "1.004"] [--slug disgaea-6] [--title-id PPSA01234]
|
|
*
|
|
* Explicit flags ALWAYS override values parsed from --issue-body-file.
|
|
* Writes (or overwrites) src/content/compat/<slug>.md with the report.
|
|
*
|
|
* Manual overrides: a mirror issue body may carry a `## Overrides` section
|
|
* (recorded by the /setos /setid /settitle comment commands, see
|
|
* scripts/set-compat-override.mjs) with human-corrected os / titleId / title
|
|
* values. They win over the values parsed from the issue body but lose to
|
|
* explicit CLI flags. This is how a mirror whose OS doesn't generalize — or
|
|
* whose PPSA-XXXXX / title still needs a human fix — still converts.
|
|
*/
|
|
import { mkdir, readdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
import { fileURLToPath } from "node:url";
|
|
import path from "node:path";
|
|
import { cleanField, normalizeOs, normalizeStatus, parseIssueBody, STATUSES } from "./lib/issue-form.mjs";
|
|
import {
|
|
extractStatusFromTitle,
|
|
gameKeyFor,
|
|
issueTitleId,
|
|
readOverrides,
|
|
reportOs,
|
|
reportTitleId,
|
|
reportTrusted,
|
|
} from "./lib/status-issues.mjs";
|
|
|
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
const DIR = path.join(ROOT, "src", "content", "compat");
|
|
const GAMES_FILE = path.join(ROOT, "src", "data", "games.json");
|
|
|
|
const OSES = ["windows", "linux", "macos"];
|
|
const TITLE_ID_REGEX = /^PPSA-?\d{5}$/i;
|
|
const TITLE_ID_LIST_REGEX = /PPSA[\s-]?\d{5}/gi;
|
|
|
|
/**
|
|
* Some submitters list several serials for one game (region variants of the
|
|
* same title — the games DB tracks them all as allTitleIds). The report
|
|
* stores a single titleId, so pick the first valid PPSA code; "Unknown" and
|
|
* garbage still fall through to the existing validation errors.
|
|
*/
|
|
function pickTitleId(raw) {
|
|
const v = String(raw ?? "").trim();
|
|
if (!v || /^unknown$/i.test(v)) return v || undefined;
|
|
const codes = v.match(TITLE_ID_LIST_REGEX);
|
|
if (codes && codes.length > 1) {
|
|
console.warn(
|
|
`[issue-to-compat] ⚠ "Game ID / serial" lists ${codes.length} serials (${codes.join(", ")}) — ` +
|
|
`using ${codes[0]} as the report's titleId (the games DB keeps every region variant)`,
|
|
);
|
|
}
|
|
const chosen = codes?.[0] ?? v;
|
|
return chosen.replace(/\s+/g, "").toUpperCase();
|
|
}
|
|
|
|
/**
|
|
* Pull the intake values out of an issue body. Recognizes BOTH the new
|
|
* Game Emulation Status Report template and the legacy 4-tier template, so a
|
|
* legacy issue converted after the migration still works.
|
|
*/
|
|
function readIntake(body, flags) {
|
|
const sections = parseIssueBody(body);
|
|
// Values set by the /setos /setid /settitle comment commands on the mirror
|
|
// issue. Precedence is flags > overrides > parsed, except the title where
|
|
// the parsed "Game title" field stays more reliable than a CLI --title.
|
|
const overrides = readOverrides(body);
|
|
|
|
// New Game Emulation Status Report template headings.
|
|
const title = cleanField(sections, "Game title") || undefined;
|
|
const titleId = cleanField(sections, "Game ID / serial") || undefined;
|
|
const version = cleanField(sections, "KytyPS5 version") || undefined;
|
|
const statusRaw = cleanField(sections, "Compatibility status") || undefined;
|
|
const notes = cleanField(sections, "Result details") || undefined;
|
|
const steps = cleanField(sections, "Steps to reproduce the result") || undefined;
|
|
const expected = cleanField(sections, "Expected behavior") || undefined;
|
|
const regression = cleanField(sections, "Last working build / first broken build") || undefined;
|
|
const extra = cleanField(sections, "Extra notes") || undefined;
|
|
const os = normalizeOs(cleanField(sections, "OS") || undefined);
|
|
const cpu = cleanField(sections, "CPU") || undefined;
|
|
const gpu = cleanField(sections, "GPU") || undefined;
|
|
const ram = cleanField(sections, "RAM / VRAM") || undefined;
|
|
|
|
// Legacy 4-tier template uses different headings for the same concepts
|
|
// (old issues converted after the migration keep working).
|
|
const titleIdLegacy = cleanField(sections, "Title ID") || undefined;
|
|
const versionLegacy = cleanField(sections, "KytyPS5 build (commit or release, not the game version)") || undefined;
|
|
const statusLegacy = cleanField(sections, "Compatibility status") || undefined;
|
|
const notesLegacy = cleanField(sections, "What works / what breaks") || undefined;
|
|
const stepsLegacy = cleanField(sections, "Steps to reproduce") || undefined;
|
|
const extraLegacy = cleanField(sections, "Extra notes") || undefined;
|
|
const osLegacy = normalizeOs(cleanField(sections, "Operating system") || undefined);
|
|
const legacyDate = cleanField(sections, "Test date") || undefined;
|
|
const legacyGameVersion = cleanField(sections, "Game version") || undefined;
|
|
const legacyHardware = cleanField(sections, "Hardware (CPU / GPU)") || undefined;
|
|
|
|
const trustedFlag = flags.trusted !== undefined ? (flags.trusted === "true" || flags.trusted === true) : undefined;
|
|
const trusted = trustedFlag ?? overrides.trusted;
|
|
|
|
// Fallbacks derived from flags.issueTitle and body
|
|
let fallbackTitle = flags.issueTitle ? String(flags.issueTitle).trim() : undefined;
|
|
if (fallbackTitle) {
|
|
// Strip leading prefix tags like [GAME STATUS], [GAME BUG], [KytyPS5], [playable], etc.
|
|
let prev;
|
|
do {
|
|
prev = fallbackTitle;
|
|
fallbackTitle = fallbackTitle
|
|
.replace(/^\s*\[(?:GAME\s+(?:STATUS|BUG)|KytyPS5|playable|in[- ]game|boots|intro|logo|main[- ]menu|doesnt[- ]boot|nothing)\][:\s-]*/i, "")
|
|
.trim();
|
|
} while (fallbackTitle !== prev);
|
|
|
|
// Strip trailing parenthetical / bracketed metadata tags like (windows), (In Game), (v1.0), (PPSA01670), etc.
|
|
do {
|
|
prev = fallbackTitle;
|
|
fallbackTitle = fallbackTitle
|
|
.replace(/\s*\((?:windows|linux|macos|in[- ]game|playable|main[- ]menu|logo|intro|boots?|doesnt[- ]boot|nothing|v?\d+\.\d+(?:\.\d+)?|PPSA[\s-]?\d{5})\)\s*$/i, "")
|
|
.replace(/\s*\[(?:windows|linux|macos|in[- ]game|playable|main[- ]menu|logo|intro|boots?|doesnt[- ]boot|nothing|v?\d+\.\d+(?:\.\d+)?|PPSA[\s-]?\d{5})\]\s*$/i, "")
|
|
.trim();
|
|
} while (fallbackTitle !== prev);
|
|
if (!fallbackTitle) fallbackTitle = undefined;
|
|
}
|
|
|
|
const rawTitleId = titleId ?? titleIdLegacy;
|
|
const hasValidPpsa = Boolean(rawTitleId && /PPSA[\s-]?\d{5}/i.test(rawTitleId));
|
|
const fallbackTitleId = issueTitleId(body, flags.issueTitle);
|
|
const resolvedTitleId = hasValidPpsa ? rawTitleId : (fallbackTitleId ?? rawTitleId);
|
|
|
|
const rawStatus = statusRaw ?? statusLegacy;
|
|
const fallbackStatus = extractStatusFromTitle(flags.issueTitle);
|
|
const resolvedStatus = (rawStatus && normalizeStatus(rawStatus)) ? rawStatus : (fallbackStatus ?? rawStatus);
|
|
|
|
// Explicit flags win over parsed values, except the title (the parsed
|
|
// "Game title" field is more reliable than the issue title) and the date
|
|
// (a legacy "Test date" is the real test date, better than created_at).
|
|
return {
|
|
title: overrides.title ?? flags.title ?? title ?? fallbackTitle,
|
|
titleId: overrides.titleId ?? flags.titleId ?? resolvedTitleId,
|
|
statusRaw: overrides.status ?? flags.status ?? resolvedStatus,
|
|
version: flags.version ?? version ?? versionLegacy,
|
|
date: legacyDate ?? flags.date, // new template has no date — workflow passes issue.created_at
|
|
os: overrides.os ?? flags.os ?? os ?? osLegacy,
|
|
osRaw:
|
|
overrides.os ??
|
|
(cleanField(sections, "OS") || cleanField(sections, "Operating system") || undefined),
|
|
hardware:
|
|
flags.hardware ??
|
|
legacyHardware ??
|
|
([cpu, gpu, ram].filter(Boolean).join(" / ") || undefined),
|
|
gameVersion: flags.gameVersion ?? legacyGameVersion,
|
|
notes: flags.body ?? notes ?? notesLegacy,
|
|
steps: flags.steps ?? steps ?? stepsLegacy,
|
|
expected: flags.expected ?? expected,
|
|
regression,
|
|
extra: flags.extraNotes ?? extra ?? extraLegacy,
|
|
trusted,
|
|
};
|
|
}
|
|
|
|
function arg(name) {
|
|
// Exact-token match: `--title` must not match the `--title-id` flag (a
|
|
// plain indexOf would collide on the shared prefix). Value follows the flag.
|
|
const flag = `--${name}`;
|
|
const idx = process.argv.findIndex((a) => a === flag);
|
|
return idx > -1 && process.argv[idx + 1] ? process.argv[idx + 1] : undefined;
|
|
}
|
|
|
|
const flags = {
|
|
title: arg("title"),
|
|
titleId: arg("title-id"),
|
|
status: arg("status"),
|
|
version: arg("version"),
|
|
date: arg("date"),
|
|
os: arg("os"),
|
|
hardware: arg("hardware"),
|
|
body: arg("body"),
|
|
steps: arg("steps"),
|
|
expected: arg("expected-behavior"),
|
|
extraNotes: arg("extra-notes"),
|
|
source: arg("source"),
|
|
sourceUrl: arg("source-url"),
|
|
gameVersion: arg("game-version"),
|
|
slug: arg("slug"),
|
|
issueBodyFile: arg("issue-body-file"),
|
|
issueTitle: arg("issue-title"),
|
|
trusted: arg("trusted"),
|
|
};
|
|
|
|
const issueBody = flags.issueBodyFile ? await readFile(flags.issueBodyFile, "utf8") : "";
|
|
// Explicit-flag mode maps the CLI flags onto the same shape readIntake
|
|
// returns, so the rest of the script is agnostic to how the values got here.
|
|
const intake = flags.issueBodyFile
|
|
? readIntake(issueBody, flags)
|
|
: {
|
|
title: flags.title,
|
|
titleId: flags.titleId,
|
|
statusRaw: flags.status,
|
|
version: flags.version,
|
|
date: flags.date,
|
|
os: flags.os,
|
|
osRaw: flags.os,
|
|
hardware: flags.hardware,
|
|
notes: flags.body,
|
|
steps: flags.steps,
|
|
expected: flags.expected,
|
|
extra: flags.extraNotes,
|
|
source: flags.source,
|
|
sourceUrl: flags.sourceUrl,
|
|
gameVersion: flags.gameVersion,
|
|
trusted: flags.trusted !== undefined ? (flags.trusted === "true" || flags.trusted === true) : undefined,
|
|
};
|
|
|
|
const title = intake.title;
|
|
const statusRaw = intake.statusRaw;
|
|
const status = normalizeStatus(statusRaw);
|
|
const version = intake.version;
|
|
const date = intake.date;
|
|
const os = intake.os;
|
|
|
|
/**
|
|
* Extract screenshot URLs from the raw issue body. GitHub's editor stores
|
|
* pasted images as `<img src="…">` tags (user-attachments / user-images
|
|
* CDN); legacy issues may use markdown ``. Only those two shapes
|
|
* count — bare URLs in the body are usually uploaded LOG files, and we no
|
|
* longer download anything to tell them apart. URLs stay hotlinked on the
|
|
* site, so no image is copied into the repo (see validate-compat's rule).
|
|
*/
|
|
function extractScreenshotUrls(body) {
|
|
const urls = [];
|
|
const seen = new Set();
|
|
const add = (u) => {
|
|
const clean = String(u ?? "").trim();
|
|
if (/^https?:\/\//i.test(clean) && !seen.has(clean)) {
|
|
seen.add(clean);
|
|
urls.push(clean);
|
|
}
|
|
};
|
|
for (const m of String(body ?? "").matchAll(/<img\b[^>]*\bsrc=["']([^"']+)["']/gi)) add(m[1]);
|
|
for (const m of String(body ?? "").matchAll(/!\[[^\]]*\]\(([^)\s]+)\)/g)) add(m[1]);
|
|
return urls.slice(0, 10); // keep reports sane; an issue rarely has more
|
|
}
|
|
const screenshots = extractScreenshotUrls(issueBody);
|
|
const hardware = intake.hardware;
|
|
const notes = intake.notes || "See the original issue for details.";
|
|
const steps = intake.steps;
|
|
const expected = intake.expected;
|
|
const regression = intake.regression;
|
|
const extra = intake.extra;
|
|
const source = intake.source ?? flags.source;
|
|
const sourceUrl = intake.sourceUrl ?? flags.sourceUrl;
|
|
const gameVersion = intake.gameVersion;
|
|
const titleId = pickTitleId(intake.titleId);
|
|
// One report per (game, OS): the default slug appends the OS so a Windows and
|
|
// a Linux report for the same game live in separate files, and re-running a
|
|
// conversion for the same game + OS overwrites that OS's status in place.
|
|
const slug =
|
|
flags.slug ||
|
|
(title
|
|
? title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") + (os ? `-${os}` : "")
|
|
: "");
|
|
|
|
const errors = [];
|
|
if (!title) errors.push("--title / \"Game title\" is required (or set one with /settitle on the mirror issue)");
|
|
if (!titleId) errors.push("--title-id / \"Game ID / serial\" is required (or set one with /setid on the mirror issue)");
|
|
else if (/^unknown$/i.test(titleId)) errors.push("Game ID / serial is \"Unknown\" — fill in the PPSA-XXXXX on the mirror issue (/setid PPSA-XXXXX) before converting");
|
|
else if (!TITLE_ID_REGEX.test(titleId)) errors.push(`title ID must look like PPSA-XXXXX, got \"${titleId}\"`);
|
|
if (!status) errors.push(`--status / "Compatibility status" is required or unrecognized (got "${String(statusRaw)}") — expected one of: Doesn't boot | Logo | Main menu | In game (or set one with /setstatus on the mirror issue)`);
|
|
else if (statusRaw !== undefined &&
|
|
!STATUSES.includes(String(statusRaw).trim().toLowerCase()) &&
|
|
!["doesn't boot", "doesnt boot", "does not boot", "logo", "main menu", "in game"].includes(String(statusRaw).trim().toLowerCase())) {
|
|
console.warn(`[issue-to-compat] ⚠ remapped status "${statusRaw}" → "${status}"`);
|
|
}
|
|
if (!version) errors.push("--version / \"KytyPS5 version\" is required");
|
|
if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) errors.push("--date must be YYYY-MM-DD (workflow passes the issue's creation date)");
|
|
if (!os) errors.push(`--os / \"OS\" is required and must mention windows, linux or mac (got \"${String(intake.osRaw ?? intake.os)}\") — or set it with /setos windows|linux|macos on the mirror issue`);
|
|
else if (!OSES.includes(os)) errors.push(`--os must be ${OSES.join(" | ")}`);
|
|
if (errors.length) {
|
|
console.error("[issue-to-compat] " + errors.join("; "));
|
|
process.exit(1);
|
|
}
|
|
|
|
// Report body = the issue's result details followed by optional sections
|
|
// (rendered as markdown headings, which the site's markdown parser supports).
|
|
// Sections are omitted when the submitter left the field empty, so reports
|
|
// stay concise. Truncation mirrors the old workflow's 4000-char cap.
|
|
const cap = (s, n = 4000) => String(s ?? "").slice(0, n);
|
|
const bodySections = [cap(notes)];
|
|
if (steps) bodySections.push(`## Steps to reproduce\n\n${cap(steps)}`);
|
|
if (expected) bodySections.push(`## Expected behavior\n\n${cap(expected)}`);
|
|
if (regression) bodySections.push(`## Last working build / first broken build\n\n${cap(regression)}`);
|
|
if (extra) bodySections.push(`## Extra notes\n\n${cap(extra)}`);
|
|
const reportBody = bodySections.join("\n\n");
|
|
|
|
await mkdir(DIR, { recursive: true });
|
|
const games = JSON.parse(await readFile(GAMES_FILE, "utf8"));
|
|
const gameKey = gameKeyFor(titleId, games);
|
|
let existingTrusted = false;
|
|
|
|
// Scan existing reports for the same (game, OS) to retire old slugs and detect trust
|
|
for (const file of await readdir(DIR)) {
|
|
if (!file.endsWith(".md")) continue;
|
|
const raw = await readFile(path.join(DIR, file), "utf8");
|
|
const otherKey = reportTitleId(raw);
|
|
const otherOs = reportOs(raw);
|
|
if (otherKey && otherOs === os && gameKeyFor(otherKey, games) === gameKey) {
|
|
if (reportTrusted(raw)) existingTrusted = true;
|
|
if (file !== `${slug}.md`) {
|
|
await unlink(path.join(DIR, file));
|
|
console.log(`[issue-to-compat] retired ${file} — same (game, OS), replaced by ${slug}.md`);
|
|
}
|
|
}
|
|
}
|
|
|
|
const isTrusted = intake.trusted !== undefined ? intake.trusted : existingTrusted;
|
|
|
|
const frontmatter = [
|
|
"---",
|
|
`title: ${JSON.stringify(title)}`,
|
|
`titleId: ${JSON.stringify(titleId)}`,
|
|
`status: "${status}"`,
|
|
`testedVersion: ${JSON.stringify(version)}`,
|
|
`testedDate: "${date}"`,
|
|
`os: "${os}"`,
|
|
hardware ? `hardware: ${JSON.stringify(hardware)}` : null,
|
|
gameVersion ? `gameVersion: ${JSON.stringify(gameVersion)}` : null,
|
|
screenshots.length ? `screenshots: ${JSON.stringify(screenshots)}` : null,
|
|
isTrusted ? "trusted: true" : null,
|
|
"---",
|
|
"",
|
|
reportBody,
|
|
"",
|
|
source && sourceUrl
|
|
? `> Source: [${source}](${sourceUrl})`
|
|
: source
|
|
? `> Source: GitHub game status report ${source}`
|
|
: "",
|
|
].filter((line) => line !== null);
|
|
|
|
const out = path.join(DIR, `${slug}.md`);
|
|
await writeFile(out, frontmatter.join("\n") + "\n");
|
|
console.log(`[issue-to-compat] wrote ${path.relative(ROOT, out)}`);
|