Files

459 lines
16 KiB
JavaScript

#!/usr/bin/env node
/**
* Mirror unconverted game-status issues from the KytyPS5 repo as issues in
* THIS repo (label `compat`) — one mirror per (game, OS) — so GitHub Actions
* can react to them here. A maintainer comments `/compat` on a mirror to
* convert it into its own report PR (see .github/workflows/compat-convert.yml);
* the poller never converts reports or opens PRs itself. This is what the old
* fetch-status-issues.mjs + convert-in-PR flow was replaced with: one game per
* issue, one game per PR, instead of a single standing report PR.
*
* A KytyPS5 issue is skipped when it is already handled:
* - a mirror issue exists for it (open = pending conversion, closed = was
* converted via /compat), or
* - a report on main for the same (game, OS) is already as new or newer
* than the issue. Reports are per (game, OS) and record ONE source issue
* + its date, so converting a newer issue for the same game overwrites
* the older one's marker — dates are compared, not issue numbers. The one
* exception: a run that finds TWO OR MORE issues for the same (game, OS)
* is a fresh batch and mirrors all of them.
*
* Scheduled runs create new mirrors and refresh OPEN mirrors whose upstream
* body changed (so fixing the upstream issue is picked up). Manual runs
* (--issue-number, workflow_dispatch) also reopen a CLOSED mirror, so a
* verified / corrected issue can be converted again via /compat.
*
* A refresh rebuilds the upstream snapshot but PRESERVES the `## Overrides`
* section — manual /setos /setid /settitle corrections (see
* scripts/set-compat-override.mjs) survive even when the upstream issue
* changes.
*
* Usage:
* GITHUB_TOKEN=… node scripts/sync-status-issues.mjs \
* [--repo KytyPS5/KytyPS5] [--this-repo owner/repo] [--issue-number 42]
*
* The token needs issues:write on --this-repo (the workflow's GITHUB_TOKEN).
* The KytyPS5 side is read through the PUBLIC API (no token needed).
*/
import { appendFileSync } from "node:fs";
import { readdir, readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import path from "node:path";
import {
appendOverrides,
buildMirrorBody,
buildUpdatedMirrorBody,
gameKeyFor,
isCandidateIssue,
isMatchingGame,
issueGameTitle,
issueOs,
issueStatus,
issueTitleId,
issueVersion,
MIRROR_LABEL,
mirrorSlug,
mirrorSource,
mirrorTitle,
normalizeGameTitle,
readOverrides,
refreshMirrorBody,
reportOs,
reportSourceNumber,
reportStatus,
reportTestedDate,
reportTitle,
reportTitleId,
reportTrusted,
reportVersion,
shouldCreateMirror,
titleIdKey,
UPDATED_LABEL,
} from "./lib/status-issues.mjs";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const COMPAT_DIR = path.join(ROOT, "src", "content", "compat");
function arg(name) {
const idx = process.argv.indexOf(`--${name}`);
return idx > -1 && process.argv[idx + 1] ? process.argv[idx + 1] : undefined;
}
const upstreamRepo = arg("repo") || "KytyPS5/KytyPS5";
const thisRepo = arg("this-repo") || process.env.GITHUB_REPOSITORY;
const issueNumber = arg("issue-number");
const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
if (!thisRepo) {
console.error("[sync-status-issues] --this-repo is required (or set GITHUB_REPOSITORY)");
process.exit(1);
}
if (!token) {
console.error("[sync-status-issues] GITHUB_TOKEN is required to create issues on " + thisRepo);
process.exit(1);
}
async function api(url, options) {
const res = await fetch(url, options);
if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${url}`);
return res.json();
}
/** One KytyPS5 issue, or recent issues via GraphQL / REST. */
async function fetchCandidates() {
const [owner, name] = upstreamRepo.split("/");
if (token) {
try {
if (issueNumber) {
const query = `query {
repository(owner: "${owner}", name: "${name}") {
issue(number: ${issueNumber}) {
number
title
state
createdAt
lastEditedAt
updatedAt
body
url
}
}
}`;
const res = await api("https://api.github.com/graphql", {
method: "POST",
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
body: JSON.stringify({ query }),
});
const issue = res?.data?.repository?.issue;
if (issue) {
const cand = { ...issue, html_url: issue.url };
return isCandidateIssue(cand) ? [cand] : [];
}
} else {
const all = [];
let cursor = null;
for (let page = 1; page <= 5; page++) {
const afterClause = cursor ? `, after: "${cursor}"` : "";
const query = `query {
repository(owner: "${owner}", name: "${name}") {
issues(first: 100, states: [OPEN, CLOSED], orderBy: { field: UPDATED_AT, direction: DESC }${afterClause}) {
pageInfo {
hasNextPage
endCursor
}
nodes {
number
title
state
createdAt
lastEditedAt
updatedAt
body
url
}
}
}
}`;
const res = await api("https://api.github.com/graphql", {
method: "POST",
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
body: JSON.stringify({ query }),
});
const issuesData = res?.data?.repository?.issues;
if (!issuesData?.nodes?.length) break;
all.push(...issuesData.nodes.map((i) => ({ ...i, html_url: i.url })));
if (!issuesData.pageInfo.hasNextPage) break;
cursor = issuesData.pageInfo.endCursor;
}
return all.filter(isCandidateIssue);
}
} catch (err) {
console.warn(`[sync-status-issues] GraphQL query failed (${err.message}), falling back to REST API`);
}
}
const headers = token ? { authorization: `Bearer ${token}` } : {};
if (issueNumber) {
const issue = await api(`https://api.github.com/repos/${upstreamRepo}/issues/${issueNumber}`, { headers });
return isCandidateIssue(issue) ? [issue] : [];
}
const all = [];
for (let page = 1; page <= 5; page++) {
const batch = await api(
`https://api.github.com/repos/${upstreamRepo}/issues?state=all&sort=updated&direction=desc&per_page=100&page=${page}`,
{ headers },
);
all.push(...batch);
if (batch.length < 100) break;
}
return all.filter(isCandidateIssue);
}
/** Reports on this checkout (main): report slug → { title, trusted, titleId, os, status, version, sourceNumber, testedDate }. */
async function reportIndex() {
const reports = new Map();
for (const file of await readdir(COMPAT_DIR)) {
if (!file.endsWith(".md")) continue;
const raw = await readFile(path.join(COMPAT_DIR, file), "utf8");
reports.set(file.slice(0, -3), {
title: reportTitle(raw),
trusted: reportTrusted(raw),
titleId: reportTitleId(raw),
os: reportOs(raw),
status: reportStatus(raw),
version: reportVersion(raw),
sourceNumber: reportSourceNumber(raw),
testedDate: reportTestedDate(raw),
});
}
return reports;
}
/** Existing mirrors in this repo: KytyPS5 issue number → { number, state, body, labels }. */
async function fetchMirrors() {
const headers = { authorization: `Bearer ${token}` };
const mirrors = new Map();
for (let page = 1; page <= 50; page++) {
const batch = await api(
`https://api.github.com/repos/${thisRepo}/issues?state=all&labels=${MIRROR_LABEL}&per_page=100&page=${page}`,
{ headers },
);
for (const issue of batch) {
if (issue.pull_request) continue;
const src = mirrorSource(issue.body);
const labels = (issue.labels ?? []).map((l) => (typeof l === "string" ? l : l.name));
if (src) mirrors.set(src.number, { number: issue.number, title: issue.title ?? "", state: issue.state, body: issue.body ?? "", labels });
}
if (batch.length < 100) break;
}
return mirrors;
}
async function patchIssue(number, fields) {
await api(`https://api.github.com/repos/${thisRepo}/issues/${number}`, {
method: "PATCH",
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
body: JSON.stringify(fields),
});
}
async function postComment(number, body) {
await api(`https://api.github.com/repos/${thisRepo}/issues/${number}/comments`, {
method: "POST",
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
body: JSON.stringify({ body }),
});
}
async function createIssue(title, body, labels = [MIRROR_LABEL]) {
await api(`https://api.github.com/repos/${thisRepo}/issues`, {
method: "POST",
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
body: JSON.stringify({ title, body, labels }),
});
}
const candidates = await fetchCandidates();
const games = JSON.parse(await readFile(path.join(ROOT, "src", "data", "games.json"), "utf8"));
const reports = await reportIndex();
// Reports keyed by (game, OS)
const byGameOs = new Map();
for (const report of reports.values()) {
if (!report.titleId || !report.os) continue;
const gameKey = gameKeyFor(report.titleId, games);
byGameOs.set(`${gameKey}|${report.os}`, report);
byGameOs.set(`${report.titleId}|${report.os}`, report);
}
const candidateKey = (issue) => {
const body = issue.body ?? "";
const titleId = issueTitleId(body, issue.title);
const os = issueOs(body);
if (titleId && os) return `${gameKeyFor(titleId, games)}|${os}`;
const slug = mirrorSlug(body, issue.title);
return slug ? `slug:${slug}` : undefined;
};
const mirrors = await fetchMirrors();
let created = 0;
let updated = 0;
let skipped = 0;
const trustedUpdates = [];
const seenGameUpdates = new Set();
for (const issue of candidates) {
const number = issue.number;
const mirror = mirrors.get(number);
const createdDate = String(issue.createdAt || issue.created_at || "").slice(0, 10);
const lastEditDate = issue.lastEditedAt ? String(issue.lastEditedAt).slice(0, 10) : undefined;
const effectiveDate = lastEditDate || createdDate;
const isEdited = issue.lastEditedAt !== null && issue.lastEditedAt !== undefined;
const key = candidateKey(issue);
const report = key
? key.startsWith("slug:")
? reports.get(key.slice(5))
: (byGameOs.get(key) ?? undefined)
: undefined;
const candStatus = issueStatus(issue.body, issue.title);
const candVersion = issueVersion(issue.body);
const isSourceIssue = report?.sourceNumber !== undefined && report.sourceNumber === number;
const statusChanged = Boolean(isSourceIssue && candStatus && report?.status && candStatus !== report.status);
const versionChanged = Boolean(isSourceIssue && candVersion && report?.version && candVersion !== report.version);
const isNewerEdit = Boolean(
isSourceIssue && isEdited && lastEditDate && (!report?.testedDate || lastEditDate > report.testedDate),
);
const isUpdate = Boolean(isNewerEdit && candStatus && (statusChanged || versionChanged));
const mirrorOverrides = mirror ? readOverrides(mirror.body) : {};
const baseBody = isUpdate
? buildUpdatedMirrorBody(
issue.body,
{
oldStatus: report?.status,
newStatus: candStatus,
oldVersion: report?.version,
newVersion: candVersion,
},
{ number, url: issue.html_url, created: createdDate },
)
: refreshMirrorBody(issue.body, mirror?.body, {
number,
url: issue.html_url,
created: createdDate,
});
const newBody = isUpdate && mirror?.body
? appendOverrides(baseBody, mirrorOverrides)
: baseBody;
const newTitle = mirrorTitle(issue.body, issue.title, mirrorOverrides);
if (mirror) {
const bodyMatches = mirror.body === newBody;
const titleMatches = mirror.title === newTitle;
// Closed mirrors are skipped unless an un-mirrored upstream update arrived,
// or a maintainer explicitly triggered a manual refresh with --issue-number.
if (mirror.state === "closed" && !issueNumber && (!isUpdate || bodyMatches)) {
skipped++;
continue;
}
const existingLabels = new Set(mirror.labels ?? []);
existingLabels.add(MIRROR_LABEL);
if (isUpdate) existingLabels.add(UPDATED_LABEL);
if (mirrorOverrides.trusted === true) {
existingLabels.add("trusted");
} else if (mirrorOverrides.trusted === false) {
existingLabels.delete("trusted");
}
const labelsMatch =
existingLabels.size === (mirror.labels ?? []).length &&
(mirror.labels ?? []).every((l) => existingLabels.has(l));
// Active patch is needed if fields/labels changed, if title changed on an open mirror,
// if reopening a closed mirror on update, or if explicitly requested via --issue-number.
const needsPatch =
Boolean(issueNumber) ||
!bodyMatches ||
!labelsMatch ||
(mirror.state === "open" && !titleMatches) ||
(mirror.state === "closed" && isUpdate);
// If nothing changed, skip immediately: prevents re-dispatching open mirrors
// and eliminates warning comment spam.
if (!needsPatch) {
skipped++;
continue;
}
const patch = bodyMatches ? {} : { body: newBody };
if (!titleMatches && mirror.state === "open") patch.title = newTitle;
if (mirror.state === "closed") patch.state = "open";
patch.labels = Array.from(existingLabels);
await patchIssue(mirror.number, patch);
updated++;
console.log(
`[sync-status-issues] refreshed mirror for KytyPS5 issue #${number} (${issue.title})${isUpdate ? " [updated-existing]" : ""}`,
);
if (isUpdate) {
// Conflict check
if (mirrorOverrides.status && candStatus && mirrorOverrides.status !== candStatus) {
await postComment(
mirror.number,
`⚠️ Upstream issue changed status to \`${candStatus}\`, but this mirror has manual override status: \`${mirrorOverrides.status}\`. Skipping auto-conversion; please reconcile manually.`,
);
continue;
}
// Title & Title ID matching check
const effectiveCandTitle = mirrorOverrides.title || issueGameTitle(issue.body) || issue.title;
const effectiveCandTitleId = mirrorOverrides.titleId || issueTitleId(issue.body, issue.title);
const matchResult = isMatchingGame(effectiveCandTitle, effectiveCandTitleId, report?.title, report?.titleId, games);
if (!matchResult.matches) {
await postComment(
mirror.number,
`⚠️ Game Title or Title ID changed in upstream edit (was: "${report?.title || report?.titleId}", candidate: "${effectiveCandTitle || effectiveCandTitleId}"). Refusing to auto-convert; please verify manually.`,
);
continue;
}
// Check if trusted
const isReportTrusted =
mirrorOverrides.trusted === true || (mirrorOverrides.trusted !== false && Boolean(report?.trusted));
if (isReportTrusted && isSourceIssue) {
const dedupKey = key || `mirror:${mirror.number}`;
if (!seenGameUpdates.has(dedupKey)) {
seenGameUpdates.add(dedupKey);
trustedUpdates.push(mirror.number);
console.log(`[sync-status-issues] queued trusted update for mirror #${mirror.number}`);
}
}
}
continue;
}
if (!issueNumber) {
const decision = shouldCreateMirror(
{
number,
created: createdDate,
isEdited,
editDate: lastEditDate,
statusChanged,
versionChanged,
},
{ report },
);
if (!decision.create) {
skipped++;
console.log(
`[sync-status-issues] skipped KytyPS5 issue #${number} (${newTitle}): ${decision.reason}`,
);
continue;
}
}
const labels = isUpdate ? [MIRROR_LABEL, UPDATED_LABEL] : [MIRROR_LABEL];
if (report?.trusted) labels.push("trusted");
await createIssue(newTitle, newBody, labels);
created++;
console.log(
`[sync-status-issues] created mirror issue for KytyPS5 issue #${number} (${newTitle})${isUpdate ? " [updated-existing]" : ""}`,
);
}
console.log(
`[sync-status-issues] ${candidates.length} candidate(s): ${created} created, ${updated} updated, ${skipped} skipped`,
);
if (process.env.GITHUB_OUTPUT) {
appendFileSync(process.env.GITHUB_OUTPUT, `trusted_updates=${JSON.stringify(trustedUpdates)}\n`);
}