From 837dd3ad4407a9b6b3c662428ad28e8a3a037805 Mon Sep 17 00:00:00 2001 From: Maaren <80040706+SSoggyTacoMan@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:49:37 +0200 Subject: [PATCH 01/13] fix(ci): clarify duplicate submission guidance and notices --- .github/scripts/duplicate-detection.mjs | 12 ++++++------ .github/scripts/duplicate-detection.test.mjs | 14 +++++++------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/scripts/duplicate-detection.mjs b/.github/scripts/duplicate-detection.mjs index 5dcbf69eb7..230c509082 100644 --- a/.github/scripts/duplicate-detection.mjs +++ b/.github/scripts/duplicate-detection.mjs @@ -23,7 +23,7 @@ export function parseOlderNotice(body) { } export function buildOlderCheckDetail({ number, latestNumber }) { - return `You have multiple open submission PRs (newer: #${latestNumber}). We suggest keeping #${latestNumber} and closing this one (#${number}), but you can keep whichever PR you prefer (just close the others). If no action is taken, this older PR will be closed automatically in ${DUPLICATE_CLOSE_AFTER_DAYS} days.`; + return `You have multiple open submission PRs (newer: #${latestNumber}). Each author may only have one open submission. If this is the same game, keep the PR with your best version and close the other. If these are different games, keep the one you want reviewed first and close the other. If no action is taken, this older PR will close automatically in ${DUPLICATE_CLOSE_AFTER_DAYS} days.`; } export function buildOlderNotice({ number, latestNumber, since }) { @@ -33,13 +33,13 @@ ${olderMarker(latestNumber, since)} You have multiple open submission PRs (newer: **#${latestNumber}**). -- Each author should only have one open submission PR at a time. -- You don't need a new PR to update your game: pushing commits (or editing files) in an existing PR updates it automatically. -- We suggest keeping your newest PR (**#${latestNumber}**) and closing this one (**#${number}**), but you can keep whichever PR you prefer — just close the other one(s). -- If no action is taken and both remain open, this older PR will be closed automatically in ${DUPLICATE_CLOSE_AFTER_DAYS} days. If you'd rather keep this one, close #${latestNumber} instead (or leave a comment if you need help).`; +- You may only have one open submission PR at a time. +- If both PRs are for the same game: you do not need a new PR to update your game. Pushing commits or editing files in your existing PR updates it automatically. Usually, your newer PR (**#${latestNumber}**) contains your latest changes, but please check which PR has your best version, keep that one, and close the other. +- If these are different games: you still must choose only one game to submit at a time. Pick which game you want reviewed first, keep that PR open, and close the other. You can submit your next game after this one is merged or closed. +- If no action is taken and both remain open, this older PR will close automatically in ${DUPLICATE_CLOSE_AFTER_DAYS} days. If you prefer to keep this older PR instead, close #${latestNumber}. Leaving a comment does not stop the timer; you must close the other PR.`; } export function buildLatestWarning({ older }) { const list = older.map((n) => `#${n}`).join(", "); - return `You have older open PRs (${list}) labeled "${DUPLICATE_LABEL}". We suggest keeping this PR and closing older ones, but you can keep whichever PR you prefer. Older inactive duplicates will be closed automatically after ${DUPLICATE_CLOSE_AFTER_DAYS} days.`; + return `You have older open PRs (${list}) labeled "${DUPLICATE_LABEL}". Each author may only submit one game at a time. If these are for the same game, check which PR has your best version, keep that one, and close the others. If these are different games, keep only the game you want reviewed first and close the others. Older inactive duplicates will close automatically after ${DUPLICATE_CLOSE_AFTER_DAYS} days.`; } diff --git a/.github/scripts/duplicate-detection.test.mjs b/.github/scripts/duplicate-detection.test.mjs index 1cef25cff6..f2e9ea4742 100644 --- a/.github/scripts/duplicate-detection.test.mjs +++ b/.github/scripts/duplicate-detection.test.mjs @@ -46,17 +46,17 @@ describe("older notice", () => { it("frames keeping the newest PR as a suggestion rather than an absolute requirement", () => { const notice = buildOlderNotice({ number: 4245, latestNumber: 4248, since: "2026-09-30T10:00:00.000Z" }); - expect(notice).toContain("We suggest keeping your newest PR (**#4248**)"); - expect(notice).toContain("you can keep whichever PR you prefer"); - expect(notice).toContain("close #4248 instead"); + expect(notice).toContain("Usually, your newer PR (**#4248**) contains your latest changes"); + expect(notice).toContain("check which PR has your best version, keep that one, and close the other"); + expect(notice).toContain("close #4248"); const checkDetail = buildOlderCheckDetail({ number: 4245, latestNumber: 4248 }); - expect(checkDetail).toContain("We suggest keeping #4248"); - expect(checkDetail).toContain("you can keep whichever PR you prefer"); + expect(checkDetail).toContain("keep the PR with your best version and close the other"); + expect(checkDetail).toContain("Each author may only have one open submission"); const warning = buildLatestWarning({ older: [4244, 4245] }); - expect(warning).toContain("We suggest keeping this PR"); - expect(warning).toContain("you can keep whichever PR you prefer"); + expect(warning).toContain("Each author may only submit one game at a time"); + expect(warning).toContain("check which PR has your best version, keep that one, and close the others"); }); it("ignores notices without a marker", () => { From 571076ffaa66a035b64043d8a862681c5b767dab Mon Sep 17 00:00:00 2001 From: Maaren <80040706+SSoggyTacoMan@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:54:23 +0200 Subject: [PATCH 02/13] fix(ci): simplify duplicate auto-close text --- .github/scripts/duplicate-detection.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/duplicate-detection.mjs b/.github/scripts/duplicate-detection.mjs index 230c509082..fcfd0cdf42 100644 --- a/.github/scripts/duplicate-detection.mjs +++ b/.github/scripts/duplicate-detection.mjs @@ -36,7 +36,7 @@ You have multiple open submission PRs (newer: **#${latestNumber}**). - You may only have one open submission PR at a time. - If both PRs are for the same game: you do not need a new PR to update your game. Pushing commits or editing files in your existing PR updates it automatically. Usually, your newer PR (**#${latestNumber}**) contains your latest changes, but please check which PR has your best version, keep that one, and close the other. - If these are different games: you still must choose only one game to submit at a time. Pick which game you want reviewed first, keep that PR open, and close the other. You can submit your next game after this one is merged or closed. -- If no action is taken and both remain open, this older PR will close automatically in ${DUPLICATE_CLOSE_AFTER_DAYS} days. If you prefer to keep this older PR instead, close #${latestNumber}. Leaving a comment does not stop the timer; you must close the other PR.`; +- If no action is taken and both remain open, this older PR will close automatically in ${DUPLICATE_CLOSE_AFTER_DAYS} days. If you prefer to keep this older PR instead, close #${latestNumber}.`; } export function buildLatestWarning({ older }) { From 1ef8ace3b9fef77232e7d7f26080148142fdaa96 Mon Sep 17 00:00:00 2001 From: Maaren <80040706+SSoggyTacoMan@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:27:57 +0200 Subject: [PATCH 03/13] fix(ci): rename duplicate label to Multiple Submissions --- .github/scripts/auto-triage.mjs | 4 ++++ .github/scripts/duplicate-detection.mjs | 3 ++- .github/scripts/duplicate-detection.test.mjs | 1 + .github/scripts/review-utils.mjs | 4 ++-- .github/scripts/stale-review-queue.mjs | 22 ++++++++++++++------ 5 files changed, 25 insertions(+), 9 deletions(-) diff --git a/.github/scripts/auto-triage.mjs b/.github/scripts/auto-triage.mjs index 66a39968fe..6173fd61f4 100644 --- a/.github/scripts/auto-triage.mjs +++ b/.github/scripts/auto-triage.mjs @@ -19,6 +19,7 @@ import { import { buildSubmissionManifest } from "./submission-manifest.mjs"; import { DUPLICATE_LABEL, + LEGACY_DUPLICATE_LABEL, DUPLICATE_NOTICE_MARKER, buildLatestWarning, buildOlderCheckDetail, @@ -236,10 +237,12 @@ async function applyDuplicateActions({ group, failed }) { if (failed) return; if (!group) { await removeLabel({ owner, repo, token, issueNumber: prNumber, label: DUPLICATE_LABEL }); + await removeLabel({ owner, repo, token, issueNumber: prNumber, label: LEGACY_DUPLICATE_LABEL }); await removeOlderNotice(prNumber); return; } + await removeLabel({ owner, repo, token, issueNumber: prNumber, label: LEGACY_DUPLICATE_LABEL }); await addLabels({ owner, repo, token, issueNumber: prNumber, labels: [DUPLICATE_LABEL] }); if (!group.isLatest) { await syncOlderNotice({ issueNumber: prNumber, latestNumber: group.latestNumber, restart: event.action === "reopened" }); @@ -248,6 +251,7 @@ async function applyDuplicateActions({ group, failed }) { for (const olderNumber of group.older) { try { + await removeLabel({ owner, repo, token, issueNumber: olderNumber, label: LEGACY_DUPLICATE_LABEL }); await addLabels({ owner, repo, token, issueNumber: olderNumber, labels: [DUPLICATE_LABEL] }); await syncOlderNotice({ issueNumber: olderNumber, latestNumber: prNumber, restart: false }); } catch (error) { diff --git a/.github/scripts/duplicate-detection.mjs b/.github/scripts/duplicate-detection.mjs index fcfd0cdf42..be7c234f48 100644 --- a/.github/scripts/duplicate-detection.mjs +++ b/.github/scripts/duplicate-detection.mjs @@ -1,4 +1,5 @@ -export const DUPLICATE_LABEL = "Potential Duplicate"; +export const DUPLICATE_LABEL = "Multiple Submissions"; +export const LEGACY_DUPLICATE_LABEL = "Potential Duplicate"; export const DUPLICATE_NOTICE_MARKER = ""; export const DUPLICATE_CLOSE_AFTER_DAYS = 3; diff --git a/.github/scripts/duplicate-detection.test.mjs b/.github/scripts/duplicate-detection.test.mjs index f2e9ea4742..0e0ff09151 100644 --- a/.github/scripts/duplicate-detection.test.mjs +++ b/.github/scripts/duplicate-detection.test.mjs @@ -55,6 +55,7 @@ describe("older notice", () => { expect(checkDetail).toContain("Each author may only have one open submission"); const warning = buildLatestWarning({ older: [4244, 4245] }); + expect(warning).toContain('labeled "Multiple Submissions"'); expect(warning).toContain("Each author may only submit one game at a time"); expect(warning).toContain("check which PR has your best version, keep that one, and close the others"); }); diff --git a/.github/scripts/review-utils.mjs b/.github/scripts/review-utils.mjs index c7189411c0..82da7a970c 100644 --- a/.github/scripts/review-utils.mjs +++ b/.github/scripts/review-utils.mjs @@ -46,10 +46,10 @@ export const LABELS = { description: "Reviewer needs author to explain AI usage", aliases: ["review:ai-concern"], }, - "Potential Duplicate": { + "Multiple Submissions": { color: "8250df", description: "Author has more than one open submission PR", - aliases: ["potential-duplicate"], + aliases: ["multiple-submissions", "Potential Duplicate", "potential-duplicate"], }, "Keep Open": { color: "8c959f", diff --git a/.github/scripts/stale-review-queue.mjs b/.github/scripts/stale-review-queue.mjs index 58a7dbf768..1fa7136b1b 100644 --- a/.github/scripts/stale-review-queue.mjs +++ b/.github/scripts/stale-review-queue.mjs @@ -4,6 +4,7 @@ import path from "node:path"; import { DUPLICATE_CLOSE_AFTER_DAYS, DUPLICATE_LABEL, + LEGACY_DUPLICATE_LABEL, DUPLICATE_NOTICE_MARKER, parseOlderNotice, } from "./duplicate-detection.mjs"; @@ -40,13 +41,22 @@ const openPulls = await githubPaginated(token, `/repos/${owner}/${repo}/pulls?st const closedDuplicateNumbers = new Set(); +function hasDuplicateState(labels) { + return hasLabel(labels, DUPLICATE_LABEL) || hasLabel(labels, LEGACY_DUPLICATE_LABEL); +} + +async function removeDuplicateLabels(issueNumber) { + await removeLabel({ owner, repo, token, issueNumber, label: DUPLICATE_LABEL }); + await removeLabel({ owner, repo, token, issueNumber, label: LEGACY_DUPLICATE_LABEL }); +} + for (const pullRequest of openPulls) { const issueNumber = pullRequest.number; const labels = await getIssueLabels({ owner, repo, token, issueNumber }); if (!hasLabel(labels, "Submission")) continue; if (hasLabel(labels, "Keep Open")) continue; - if (hasLabel(labels, DUPLICATE_LABEL) && (await handleOlderDuplicate(pullRequest, labels))) continue; + if (hasDuplicateState(labels) && (await handleOlderDuplicate(pullRequest, labels))) continue; if (hasLabel(labels, "Needs Author") || hasLabel(labels, "Failed")) { await handleNeedsAuthor(pullRequest, labels); @@ -77,10 +87,10 @@ async function handleOlderDuplicate(pullRequest, labels) { pr.number !== pullRequest.number && !closedDuplicateNumbers.has(pr.number) && pr.user?.login?.toLowerCase() === authorLogin && - pr.labels?.some((label) => label.name === DUPLICATE_LABEL) + pr.labels?.some((label) => label.name === DUPLICATE_LABEL || label.name === LEGACY_DUPLICATE_LABEL) ); if (!hasOlderDuplicates) { - await removeLabel({ owner, repo, token, issueNumber: pullRequest.number, label: DUPLICATE_LABEL }); + await removeDuplicateLabels(pullRequest.number); } return false; } @@ -88,7 +98,7 @@ async function handleOlderDuplicate(pullRequest, labels) { const latest = await githubRequest(token, "GET", `/repos/${owner}/${repo}/pulls/${latestNumber}`).catch(() => null); if (!latest || latest.state !== "open" || latest.draft) { - await removeLabel({ owner, repo, token, issueNumber: pullRequest.number, label: DUPLICATE_LABEL }); + await removeDuplicateLabels(pullRequest.number); return false; } @@ -115,10 +125,10 @@ async function handleOlderDuplicate(pullRequest, labels) { pr.number !== latestNumber && !closedDuplicateNumbers.has(pr.number) && pr.user?.login?.toLowerCase() === authorLogin && - pr.labels?.some((label) => label.name === DUPLICATE_LABEL) + pr.labels?.some((label) => label.name === DUPLICATE_LABEL || label.name === LEGACY_DUPLICATE_LABEL) ); if (!stillDuplicated) { - await removeLabel({ owner, repo, token, issueNumber: latestNumber, label: DUPLICATE_LABEL }); + await removeDuplicateLabels(latestNumber); } return true; } From 92239a5991a4355d06fdfda69089b7a70ee5c9df Mon Sep 17 00:00:00 2001 From: Maaren <80040706+SSoggyTacoMan@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:23:47 +0200 Subject: [PATCH 04/13] chore: add cleanup reminder for legacy duplicate label --- .github/scripts/duplicate-detection.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/scripts/duplicate-detection.mjs b/.github/scripts/duplicate-detection.mjs index be7c234f48..29eea2885a 100644 --- a/.github/scripts/duplicate-detection.mjs +++ b/.github/scripts/duplicate-detection.mjs @@ -1,4 +1,5 @@ export const DUPLICATE_LABEL = "Multiple Submissions"; +// ponytail: temporary alias for legacy "Potential Duplicate" label on pre-existing PRs; remove once all PRs opened before October 2026 are closed export const LEGACY_DUPLICATE_LABEL = "Potential Duplicate"; export const DUPLICATE_NOTICE_MARKER = ""; export const DUPLICATE_CLOSE_AFTER_DAYS = 3; From 1e9696fc9b9a4ee302c91724e1097eff2f637de5 Mon Sep 17 00:00:00 2001 From: Maaren <80040706+SSoggyTacoMan@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:44:15 +0200 Subject: [PATCH 05/13] fix(ci): make duplicate submission messages friendlier and more encouraging --- .github/scripts/duplicate-detection.mjs | 15 ++++++++------- .github/scripts/duplicate-detection.test.mjs | 6 +++--- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/.github/scripts/duplicate-detection.mjs b/.github/scripts/duplicate-detection.mjs index 29eea2885a..af13ed2c97 100644 --- a/.github/scripts/duplicate-detection.mjs +++ b/.github/scripts/duplicate-detection.mjs @@ -25,7 +25,7 @@ export function parseOlderNotice(body) { } export function buildOlderCheckDetail({ number, latestNumber }) { - return `You have multiple open submission PRs (newer: #${latestNumber}). Each author may only have one open submission. If this is the same game, keep the PR with your best version and close the other. If these are different games, keep the one you want reviewed first and close the other. If no action is taken, this older PR will close automatically in ${DUPLICATE_CLOSE_AFTER_DAYS} days.`; + return `You have multiple open submission PRs (newer: #${latestNumber}). To keep reviews fair and fast, each author can only have one open submission at a time. If this is the same game, please keep the PR with your best version and close the other. If these are different games, choose the one you would like reviewed first and close the other. If no action is taken, this older PR will close automatically in ${DUPLICATE_CLOSE_AFTER_DAYS} days.`; } export function buildOlderNotice({ number, latestNumber, since }) { @@ -33,15 +33,16 @@ export function buildOlderNotice({ number, latestNumber, since }) { ${olderMarker(latestNumber, since)} ### Multiple open PRs -You have multiple open submission PRs (newer: **#${latestNumber}**). +Thanks for submitting! We noticed you have multiple open submission PRs (newer: **#${latestNumber}**). -- You may only have one open submission PR at a time. -- If both PRs are for the same game: you do not need a new PR to update your game. Pushing commits or editing files in your existing PR updates it automatically. Usually, your newer PR (**#${latestNumber}**) contains your latest changes, but please check which PR has your best version, keep that one, and close the other. -- If these are different games: you still must choose only one game to submit at a time. Pick which game you want reviewed first, keep that PR open, and close the other. You can submit your next game after this one is merged or closed. -- If no action is taken and both remain open, this older PR will close automatically in ${DUPLICATE_CLOSE_AFTER_DAYS} days. If you prefer to keep this older PR instead, close #${latestNumber}.`; +To keep reviews fair and fast for everyone, authors can only have one submission PR open at a time: + +- **Same game?** You do not need to open a new PR to update your game! Pushing commits or editing files in your existing PR updates it automatically. Usually your newer PR (**#${latestNumber}**) has your latest changes, but please check which PR has your best version, keep that one, and close the other. +- **Different games?** That is awesome, but our review team can only review one game per author at a time. Please choose which game you would like reviewed first, keep that PR open, and close the other. You can submit your next game once this one is merged! +- **Next steps:** If no action is taken, this older PR will close automatically in ${DUPLICATE_CLOSE_AFTER_DAYS} days so your newest PR can move forward. If you prefer to keep this older PR instead, please close #${latestNumber}. If you need any help, feel free to ask in the #sprig channel on Slack!`; } export function buildLatestWarning({ older }) { const list = older.map((n) => `#${n}`).join(", "); - return `You have older open PRs (${list}) labeled "${DUPLICATE_LABEL}". Each author may only submit one game at a time. If these are for the same game, check which PR has your best version, keep that one, and close the others. If these are different games, keep only the game you want reviewed first and close the others. Older inactive duplicates will close automatically after ${DUPLICATE_CLOSE_AFTER_DAYS} days.`; + return `You have older open PRs (${list}) labeled "${DUPLICATE_LABEL}". To keep review times fast for everyone, please keep only one game submission open at a time. If these are for the same game, check which PR has your best version, keep that one, and close the others. If these are different games, please keep only the game you want reviewed first and close the others (you can submit your next game once this one merges). Older inactive duplicates will close automatically after ${DUPLICATE_CLOSE_AFTER_DAYS} days.`; } diff --git a/.github/scripts/duplicate-detection.test.mjs b/.github/scripts/duplicate-detection.test.mjs index 0e0ff09151..6013b1f9aa 100644 --- a/.github/scripts/duplicate-detection.test.mjs +++ b/.github/scripts/duplicate-detection.test.mjs @@ -46,17 +46,17 @@ describe("older notice", () => { it("frames keeping the newest PR as a suggestion rather than an absolute requirement", () => { const notice = buildOlderNotice({ number: 4245, latestNumber: 4248, since: "2026-09-30T10:00:00.000Z" }); - expect(notice).toContain("Usually, your newer PR (**#4248**) contains your latest changes"); + expect(notice).toContain("Usually your newer PR (**#4248**) has your latest changes"); expect(notice).toContain("check which PR has your best version, keep that one, and close the other"); expect(notice).toContain("close #4248"); const checkDetail = buildOlderCheckDetail({ number: 4245, latestNumber: 4248 }); expect(checkDetail).toContain("keep the PR with your best version and close the other"); - expect(checkDetail).toContain("Each author may only have one open submission"); + expect(checkDetail).toContain("each author can only have one open submission at a time"); const warning = buildLatestWarning({ older: [4244, 4245] }); expect(warning).toContain('labeled "Multiple Submissions"'); - expect(warning).toContain("Each author may only submit one game at a time"); + expect(warning).toContain("please keep only one game submission open at a time"); expect(warning).toContain("check which PR has your best version, keep that one, and close the others"); }); From 32d2a3c8d0a7f5ad322e001500f899957aa339f0 Mon Sep 17 00:00:00 2001 From: Maaren <80040706+SSoggyTacoMan@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:14:51 +0200 Subject: [PATCH 06/13] fix(ci): make review automation and stale queue messages friendlier --- .github/scripts/auto-triage.mjs | 4 ++-- .github/scripts/stale-review-queue.mjs | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/scripts/auto-triage.mjs b/.github/scripts/auto-triage.mjs index 6173fd61f4..78d6441307 100644 --- a/.github/scripts/auto-triage.mjs +++ b/.github/scripts/auto-triage.mjs @@ -783,7 +783,7 @@ function buildComment(result) { return `${REVIEW_BOT_MARKER} ### ${status} -${result.ok ? "This submission is ready for human playtest." : "This submission needs author fixes before normal review."} +${result.ok ? "All automated checks passed! Your game is ready for a reviewer to playtest." : "Thanks for submitting! There are just a few quick fixes needed before we can playtest your game."} #### Submission - Game: ${metadata.title ? `\`${metadata.title}\`` : "unknown"} @@ -796,7 +796,7 @@ ${links.join("\n")} ${checksSection}${warningLines.join("\n")} -${result.ok ? "Reviewers: please use the play link to playtest, then approve or request changes." : `@${pullRequest.user.login}: push fixes to this PR ([edit file](${editUrl})). These checks rerun automatically.\n If you are in trouble, don't hesitate to ask for help in the #sprig channel on Slack or ping @LucasHT22 here.`} +${result.ok ? "Reviewers: please use the play link to playtest, then approve or request changes." : `@${pullRequest.user.login}: you can update your game right here ([edit file](${editUrl})) or push new commits. Once you do, these checks will rerun automatically! If you get stuck or have questions, ask in the #sprig channel on Slack or ping @LucasHT22 here.`} `; } diff --git a/.github/scripts/stale-review-queue.mjs b/.github/scripts/stale-review-queue.mjs index 1fa7136b1b..8ff100c46b 100644 --- a/.github/scripts/stale-review-queue.mjs +++ b/.github/scripts/stale-review-queue.mjs @@ -114,7 +114,7 @@ async function handleOlderDuplicate(pullRequest, labels) { await commentOnce({ issueNumber: pullRequest.number, marker: "", - body: `Closing because #${latestNumber} is your newer open PR. If you'd rather keep this PR instead, reopen it and close #${latestNumber}.`, + body: `Closing this older submission so your newer PR (#${latestNumber}) can move forward. If you would rather keep this game instead, simply reopen this PR and close #${latestNumber}.`, }); await githubRequest(token, "PATCH", `/repos/${owner}/${repo}/issues/${pullRequest.number}`, { state: "closed" }); closedDuplicateNumbers.add(pullRequest.number); @@ -153,7 +153,7 @@ async function handleNeedsAuthor(pullRequest, labels) { await commentOnce({ issueNumber: pullRequest.number, marker: ``, - body: "Closing because this submission has been waiting on author changes for 14 days without activity. Push fixes and ask a reviewer to reopen when ready.", + body: "Closing this submission for now since there has been no activity for 14 days. Whenever you want to return to it, push your fixes and ping a reviewer to reopen it. We would love to see your game in the gallery!", }); await githubRequest(token, "PATCH", `/repos/${owner}/${repo}/issues/${pullRequest.number}`, { state: "closed", @@ -166,7 +166,7 @@ async function handleNeedsAuthor(pullRequest, labels) { await commentOnce({ issueNumber: pullRequest.number, marker: ``, - body: "This submission has been waiting on author changes for 7 days. Please push fixes soon, or it may be closed after 14 days of no response.", + body: "Just checking in! This submission has been waiting for updates for 7 days. Whenever you have time, push your fixes so we can get your game reviewed. If there is no response after 14 days, the PR will close automatically, but you can always reopen it whenever you are ready!", }); } else if (hasLabel(labels, "Stale")) { await removeLabel({ owner, repo, token, issueNumber: pullRequest.number, label: "Stale" }); @@ -185,7 +185,7 @@ async function handleReadyForMaintainer(pullRequest) { await commentOnce({ issueNumber: pullRequest.number, marker: MAINTAINER_REMINDER_MARKER, - body: `This submission has been ready for maintainer review for 7 days. ${pings} — could one of you take a look?`, + body: `Friendly ping! This submission has been ready for maintainer review for 7 days. ${pings}, could one of you take a look?`, }); } @@ -204,7 +204,7 @@ async function handleClaimed(pullRequest) { await commentOnce({ issueNumber: pullRequest.number, marker: "", - body: "Unassigning because this review has had no activity for 3 days.", + body: "Releasing review claim back to the queue because there has been no activity for 3 days.", }); } From 47f13fc717708656fddf58c2754373b5da9699c8 Mon Sep 17 00:00:00 2001 From: Maaren <80040706+SSoggyTacoMan@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:22:26 +0200 Subject: [PATCH 07/13] fix(ci): direct authors to ask reviewers to reopen closed PRs --- .github/scripts/stale-review-queue.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/scripts/stale-review-queue.mjs b/.github/scripts/stale-review-queue.mjs index 8ff100c46b..8bec0b5261 100644 --- a/.github/scripts/stale-review-queue.mjs +++ b/.github/scripts/stale-review-queue.mjs @@ -114,7 +114,7 @@ async function handleOlderDuplicate(pullRequest, labels) { await commentOnce({ issueNumber: pullRequest.number, marker: "", - body: `Closing this older submission so your newer PR (#${latestNumber}) can move forward. If you would rather keep this game instead, simply reopen this PR and close #${latestNumber}.`, + body: `Closing this older submission so your newer PR (#${latestNumber}) can move forward. If you would rather keep this game instead, close #${latestNumber} and ask a reviewer to reopen this PR.`, }); await githubRequest(token, "PATCH", `/repos/${owner}/${repo}/issues/${pullRequest.number}`, { state: "closed" }); closedDuplicateNumbers.add(pullRequest.number); @@ -166,7 +166,7 @@ async function handleNeedsAuthor(pullRequest, labels) { await commentOnce({ issueNumber: pullRequest.number, marker: ``, - body: "Just checking in! This submission has been waiting for updates for 7 days. Whenever you have time, push your fixes so we can get your game reviewed. If there is no response after 14 days, the PR will close automatically, but you can always reopen it whenever you are ready!", + body: "Just checking in! This submission has been waiting for updates for 7 days. Whenever you have time, push your fixes so we can get your game reviewed. If there is no response after 14 days, the PR will close automatically, but you can always ask a reviewer to reopen it whenever you are ready!", }); } else if (hasLabel(labels, "Stale")) { await removeLabel({ owner, repo, token, issueNumber: pullRequest.number, label: "Stale" }); From ab97c7a8ed629c7622af9e57448bea06b7d3242c Mon Sep 17 00:00:00 2001 From: Maaren <80040706+SSoggyTacoMan@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:25:32 +0200 Subject: [PATCH 08/13] fix(ci): remove friendly ping prefix from maintainer reminder --- .github/scripts/stale-review-queue.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/stale-review-queue.mjs b/.github/scripts/stale-review-queue.mjs index 8bec0b5261..568210b365 100644 --- a/.github/scripts/stale-review-queue.mjs +++ b/.github/scripts/stale-review-queue.mjs @@ -185,7 +185,7 @@ async function handleReadyForMaintainer(pullRequest) { await commentOnce({ issueNumber: pullRequest.number, marker: MAINTAINER_REMINDER_MARKER, - body: `Friendly ping! This submission has been ready for maintainer review for 7 days. ${pings}, could one of you take a look?`, + body: `This submission has been ready for maintainer review for 7 days. ${pings}, could one of you take a look?`, }); } From 2c4d5bd620b04cb1de02732ac0e0b85eff0d3e02 Mon Sep 17 00:00:00 2001 From: Maaren <80040706+SSoggyTacoMan@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:13:47 +0200 Subject: [PATCH 09/13] fix(ci): improve metadata validation UX and show copy-paste template --- .github/scripts/auto-triage.mjs | 129 +++++++--------------- .github/scripts/metadata-parsing.test.mjs | 113 ++++++++++++++++++- .github/scripts/review-utils.mjs | 118 ++++++++++++++++++++ 3 files changed, 272 insertions(+), 88 deletions(-) diff --git a/.github/scripts/auto-triage.mjs b/.github/scripts/auto-triage.mjs index 78d6441307..5d2d9fe26e 100644 --- a/.github/scripts/auto-triage.mjs +++ b/.github/scripts/auto-triage.mjs @@ -3,6 +3,7 @@ import path from "node:path"; import { REVIEW_BOT_MARKER, addLabels, + buildMetadataSnippet, ensureReviewLabels, getIssueLabels, getMetadataValue, @@ -15,6 +16,7 @@ import { removeLabel, setStateLabel, upsertBotComment, + validateMetadataHeader, } from "./review-utils.mjs"; import { buildSubmissionManifest } from "./submission-manifest.mjs"; import { @@ -466,51 +468,7 @@ function stripTemplateNoise(value) { } async function validateMetadata(content, filename, workspace) { - const checks = []; - const values = { - title: getMetadataValue(content, "title"), - author: getMetadataValue(content, "author"), - description: getMetadataValue(content, "description"), - tags: getMetadataValue(content, "tags"), - addedOn: getMetadataValue(content, "addedOn"), - }; - - const add = (name, ok, detail) => checks.push({ name, ok, detail }); - - for (const [key, value] of Object.entries(values)) { - add( - `Metadata @${key}`, - Boolean(value), - value ? `@${key} is filled.` : `Metadata in \`${filename}\` is missing or has empty \`@${key}:\`.` - ); - } - - const parsedTags = parseTags(values.tags); - const tagsValid = Boolean(parsedTags.tags && parsedTags.tags.length > 0); - add( - "Metadata tags parse", - tagsValid, - tagsValid - ? "Tags are formatted correctly." - : `Format \`@tags:\` as a list of tags in quotes and brackets, for example \`@tags: ['maze', 'puzzle']\`.\nReason: ${parsedTags.issue ?? "Tags list cannot be empty."}` - ); - - checkMetadataDate(values.addedOn, add); - - const titleLooksLikeTemplate = /^getting(_|\s)started$/i.test(values.title?.trim()) || /^template$/i.test(values.title?.trim()) || /^my game$/i.test(values.title?.trim()); - const authorLooksLikeTemplate = /leo,\s*edits/i.test(values.author) || /^my name$/i.test(values.author?.trim()); - const descriptionLooksLikeTemplate = /short description about the game/i.test(values.description?.trim()); - const hasExampleTags = parsedTags.tags?.some(t => ["tag1", "tag2", "example", "another-example"].includes(t.toLowerCase())); - - const hasTemplateValues = titleLooksLikeTemplate || authorLooksLikeTemplate || descriptionLooksLikeTemplate || hasExampleTags; - - add( - "Metadata template values", - !hasTemplateValues, - !hasTemplateValues - ? "No template metadata values found." - : "Replace example/template values in the metadata header (like 'MY GAME', 'MY NAME', 'Short description...', or placeholder tags)." - ); + const { checks, values, hasHeader } = validateMetadataHeader(content, filename); const TUTORIAL_FILES = ["getting_started.js", "maze_game_starter.js", "sprig_dodge.js"]; const isTutorial = TUTORIAL_FILES.some((tutFile) => { @@ -520,25 +478,29 @@ async function validateMetadata(content, filename, workspace) { return normalizeCode(content) === normalizeCode(tutContent); } catch { return false; } }); - add( - "Not a tutorial game", - !isTutorial, - isTutorial + checks.push({ + name: "Not a tutorial game", + ok: !isTutorial, + detail: isTutorial ? "This looks like an unmodified tutorial or starter game. Submit your own original game instead." - : "Game does not appear to be an unmodified tutorial." - ); + : "Game does not appear to be an unmodified tutorial.", + }); + + if (!hasHeader) { + return { checks, values }; + } const safeTitle = values.title ? values.title.replace(/`/g, "'") : ""; const titleConflict = values.title ? await findTitleConflict(values.title, filename, workspace) : null; - add( - "Unique game title", - !titleConflict, - titleConflict + checks.push({ + name: "Unique game title", + ok: !titleConflict, + detail: titleConflict ? `Game title \`${safeTitle}\` already appears in \`${titleConflict}\`; choose a unique title.` - : "Game title appears unique." - ); + : "Game title appears unique.", + }); - return { checks, values: { ...values, tags: parsedTags.tags ?? values.tags } }; + return { checks, values }; } async function findTitleConflict(title, filename, workspace) { @@ -710,6 +672,7 @@ function buildComment(result) { "Code checklist": "pr", "Image checklist": "pr", + "Metadata header": "metadata", "Metadata @title": "metadata", "Metadata @author": "metadata", "Metadata @description": "metadata", @@ -732,6 +695,10 @@ function buildComment(result) { categories[catKey].checks.push(check); } + const headRepo = pullRequest.head.repo?.full_name ?? `${owner}/${repo}`; + const headRef = pullRequest.head.ref; + const editUrl = `https://github.com/${headRepo}/edit/${headRef}/${result.gameFile ?? ""}`; + const failedChecks = result.checks.filter((c) => !c.ok); let checksSection = ""; @@ -741,6 +708,23 @@ function buildComment(result) { checksSection += `> - ❌ **${check.name}:** ${check.detail}\n`; } checksSection += "\n"; + + const hasMetadataFailure = failedChecks.some((c) => categoryMap[c.name] === "metadata"); + if (hasMetadataFailure && result.gameFile) { + const defaultTitle = pullRequest.title ? pullRequest.title.replace(/^\[.*?\]\s*/, "").trim() : ""; + const filenameTitle = (result.gameFile.split("/").pop() ?? "").replace(/\.js$/, "").replace(/[-_]+/g, " "); + const sampleTitle = metadata.title || defaultTitle || filenameTitle || "My Game"; + const sampleAuthor = metadata.author || pullRequest.user?.login || "Your Name"; + const snippet = buildMetadataSnippet({ + title: sampleTitle, + author: sampleAuthor, + description: metadata.description || undefined, + tags: Array.isArray(metadata.tags) && metadata.tags.length ? metadata.tags : undefined, + addedOn: metadata.addedOn || undefined, + }); + + checksSection += `> [!TIP]\n> **How to fix metadata:**\n> Click **[Edit Game File](${editUrl})** and paste this block at the very top (line 1) of \`${result.gameFile}\`:\n> \`\`\`javascript\n> ${snippet.split("\n").join("\n> ")}\n> \`\`\`\n\n`; + } } checksSection += "#### Checks Status\n"; @@ -761,10 +745,6 @@ function buildComment(result) { } } - const headRepo = pullRequest.head.repo?.full_name ?? `${owner}/${repo}`; - const headRef = pullRequest.head.ref; - const editUrl = `https://github.com/${headRepo}/edit/${headRef}/${result.gameFile ?? ""}`; - const links = []; if (result.playUrl) links.push(`- [Play in Sprig Editor](${result.playUrl})`); if (result.gameFile) links.push(`- [Edit Game File](${editUrl})`); @@ -804,31 +784,6 @@ function formatPercent(value) { return `${Math.round(value * 100)}%`; } -function checkMetadataDate(addedOn, add) { - const validFormat = /^\d{4}-\d{2}-\d{2}$/.test(addedOn); - const parsedDate = validFormat ? new Date(`${addedOn}T00:00:00Z`) : null; - const isRealDate = Boolean( - validFormat && - parsedDate && - !Number.isNaN(parsedDate.getTime()) && - parsedDate.toISOString().slice(0, 10) === addedOn - ); - const now = new Date(); - const tooOld = isRealDate ? Math.abs(now.getTime() - parsedDate.getTime()) > 183 * 86_400_000 : true; - const ok = isRealDate && !tooOld; - let detail = "Date looks current."; - if (!ok) { - if (isRealDate && tooOld) { - detail = "Set `@addedOn:` to a recent date in `YYYY-MM-DD` format (must be within the last 6 months)."; - } else if (/\n/.test(addedOn) || /\b\d{4}-\d{2}-\d{2}\b/.test(addedOn)) { - detail = "Set `@addedOn:` to a recent date in `YYYY-MM-DD` format. Close the metadata header with `*/` immediately after `@addedOn` before any instructions or other comments."; - } else { - detail = "Set `@addedOn:` to a valid recent date in `YYYY-MM-DD` format."; - } - } - add("Metadata date", ok, detail); -} - function validateSubmissionFiles(pullFiles, addCheck) { const manifest = buildSubmissionManifest(pullFiles); const { gameFiles: jsFiles, gameFilesLoose, imageFiles, disallowedFiles, uppercaseGames, changedNonAddedFiles } = manifest; diff --git a/.github/scripts/metadata-parsing.test.mjs b/.github/scripts/metadata-parsing.test.mjs index a756be3b78..b0ba428708 100644 --- a/.github/scripts/metadata-parsing.test.mjs +++ b/.github/scripts/metadata-parsing.test.mjs @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { getMetadataValue, parseTags } from "./review-utils.mjs"; +import { + buildMetadataSnippet, + getMetadataValue, + parseTags, + validateMetadataHeader, +} from "./review-utils.mjs"; describe("getMetadataValue", () => { it("parses fields from standard Sprig comment blocks", () => { @@ -119,3 +124,109 @@ describe("parseTags", () => { }); }); +describe("validateMetadataHeader", () => { + it("consolidates missing header into a single clear check when no metadata exists", () => { + const rawCode = `// Block Pusher +const player = "p"; +setLegend([player, bitmap\`...\`]);`; + const res = validateMetadataHeader(rawCode, "Vasko-JS-game.js"); + expect(res.hasHeader).toBe(false); + expect(res.checks).toHaveLength(1); + expect(res.checks[0].name).toBe("Metadata header"); + expect(res.checks[0].ok).toBe(false); + expect(res.checks[0].detail).toContain("Missing comment header at the top of `Vasko-JS-game.js`"); + }); + + it("provides actionable instructions with examples when individual fields are missing", () => { + const partial = `/* +@title: Space Maze +@author: Alice +*/ +const p = "p";`; + const res = validateMetadataHeader(partial, "space_maze.js"); + expect(res.hasHeader).toBe(true); + + const titleCheck = res.checks.find((c) => c.name === "Metadata @title"); + expect(titleCheck.ok).toBe(true); + expect(titleCheck.detail).toBe("@title is filled."); + + const descCheck = res.checks.find((c) => c.name === "Metadata @description"); + expect(descCheck.ok).toBe(false); + expect(descCheck.detail).toContain("Add a short description of what players do"); + expect(descCheck.detail).toContain("@description:"); + + const tagsCheck = res.checks.find((c) => c.name === "Metadata @tags"); + expect(tagsCheck.ok).toBe(false); + expect(tagsCheck.detail).toContain("Add tags for your game"); + + const dateCheck = res.checks.find((c) => c.name === "Metadata @addedOn"); + expect(dateCheck.ok).toBe(false); + expect(dateCheck.detail).toContain("Add today's date in YYYY-MM-DD format"); + + // Suppresses redundant sub-checks when parent fields are missing + expect(res.checks.some((c) => c.name === "Metadata tags parse")).toBe(false); + expect(res.checks.some((c) => c.name === "Metadata date")).toBe(false); + }); + + it("runs tags parse and date checks only when those fields are present", () => { + const content = `/* +@title: My Game +@author: Bob +@description: Cool game +@tags: [broken, tags] +@addedOn: 1999-01-01 +*/`; + const res = validateMetadataHeader(content, "game.js"); + expect(res.hasHeader).toBe(true); + + const tagsParse = res.checks.find((c) => c.name === "Metadata tags parse"); + expect(tagsParse).toBeDefined(); + expect(tagsParse.ok).toBe(false); + + const dateCheck = res.checks.find((c) => c.name === "Metadata date"); + expect(dateCheck).toBeDefined(); + expect(dateCheck.ok).toBe(false); + }); + + it("passes all checks when metadata is complete and valid", () => { + const today = new Date().toISOString().slice(0, 10); + const content = `/* +@title: Crystal Quest +@author: Adventurer +@description: Collect all crystals and find the hidden exit. +@tags: ['puzzle', 'adventure'] +@addedOn: ${today} +*/`; + const res = validateMetadataHeader(content, "crystal_quest.js"); + expect(res.hasHeader).toBe(true); + expect(res.checks.every((c) => c.ok)).toBe(true); + }); +}); + +describe("buildMetadataSnippet", () => { + it("generates a complete metadata comment block with defaults", () => { + const snippet = buildMetadataSnippet(); + expect(snippet).toContain("@title: My Game"); + expect(snippet).toContain("@author: Your Name"); + expect(snippet).toContain("@tags: ['puzzle']"); + expect(snippet.startsWith("/*\n")).toBe(true); + expect(snippet.endsWith("\n*/")).toBe(true); + }); + + it("pre-fills provided author, title, tags, and date", () => { + const snippet = buildMetadataSnippet({ + title: "Vasko JS game", + author: "vask3", + description: "Push blocks to targets", + tags: ["sokoban", "puzzle"], + addedOn: "2026-10-14", + }); + expect(snippet).toContain("@title: Vasko JS game"); + expect(snippet).toContain("@author: vask3"); + expect(snippet).toContain("@description: Push blocks to targets"); + expect(snippet).toContain("@tags: ['sokoban', 'puzzle']"); + expect(snippet).toContain("@addedOn: 2026-10-14"); + }); +}); + + diff --git a/.github/scripts/review-utils.mjs b/.github/scripts/review-utils.mjs index 82da7a970c..3b3d035211 100644 --- a/.github/scripts/review-utils.mjs +++ b/.github/scripts/review-utils.mjs @@ -356,4 +356,122 @@ export function parseTags(raw) { return { tags }; } +export function checkMetadataDate(addedOn, add) { + const validFormat = /^\d{4}-\d{2}-\d{2}$/.test(addedOn); + const parsedDate = validFormat ? new Date(`${addedOn}T00:00:00Z`) : null; + const isRealDate = Boolean( + validFormat && + parsedDate && + !Number.isNaN(parsedDate.getTime()) && + parsedDate.toISOString().slice(0, 10) === addedOn + ); + const now = new Date(); + const tooOld = isRealDate ? Math.abs(now.getTime() - parsedDate.getTime()) > 183 * 86_400_000 : true; + const ok = isRealDate && !tooOld; + let detail = "Date looks current."; + if (!ok) { + if (isRealDate && tooOld) { + detail = "Set `@addedOn:` to a recent date in `YYYY-MM-DD` format (must be within the last 6 months)."; + } else if (/\n/.test(addedOn) || /\b\d{4}-\d{2}-\d{2}\b/.test(addedOn)) { + detail = "Set `@addedOn:` to a recent date in `YYYY-MM-DD` format. Close the metadata header with `*/` immediately after `@addedOn` before any instructions or other comments."; + } else { + detail = "Set `@addedOn:` to a valid recent date in `YYYY-MM-DD` format."; + } + } + add("Metadata date", ok, detail); +} + +export function buildMetadataSnippet({ title, author, description, tags, addedOn } = {}) { + const gameTitle = title || "My Game"; + const gameAuthor = author || "Your Name"; + const gameDesc = description || "A short description of what players do in your game"; + let gameTags = "['puzzle']"; + if (Array.isArray(tags) && tags.length > 0) { + gameTags = `[${tags.map((t) => `'${t.replace(/'/g, "\\'")}'`).join(", ")}]`; + } else if (typeof tags === "string" && tags.trim()) { + gameTags = tags.trim(); + } + const gameDate = addedOn || new Date().toISOString().slice(0, 10); + + return `/* +@title: ${gameTitle} +@author: ${gameAuthor} +@description: ${gameDesc} +@tags: ${gameTags} +@addedOn: ${gameDate} +*/`; +} + +export function validateMetadataHeader(content, filename) { + const checks = []; + const values = { + title: getMetadataValue(content, "title"), + author: getMetadataValue(content, "author"), + description: getMetadataValue(content, "description"), + tags: getMetadataValue(content, "tags"), + addedOn: getMetadataValue(content, "addedOn"), + }; + + const hasAnyMetadata = Boolean(values.title || values.author || values.description || values.tags || values.addedOn); + if (!hasAnyMetadata) { + checks.push({ + name: "Metadata header", + ok: false, + detail: `Missing comment header at the top of \`${filename}\`. Sprig games require a \`/* ... */\` comment block with game details at line 1.`, + }); + return { checks, values: { ...values, tags: [] }, hasHeader: false }; + } + + const today = new Date().toISOString().slice(0, 10); + const missingDetails = { + title: `Add a title for your game (e.g. \`@title: My Game\`) to the comment header at the top of \`${filename}\`.`, + author: `Add your name or handle (e.g. \`@author: Your Name\`) to the comment header at the top of \`${filename}\`.`, + description: `Add a short description of what players do (e.g. \`@description: A fun puzzle game\`) to the comment header at the top of \`${filename}\`.`, + tags: `Add tags for your game (e.g. \`@tags: ['puzzle']\`) to the comment header at the top of \`${filename}\`.`, + addedOn: `Add today's date in YYYY-MM-DD format (e.g. \`@addedOn: ${today}\`) to the comment header at the top of \`${filename}\`.`, + }; + + for (const [key, value] of Object.entries(values)) { + checks.push({ + name: `Metadata @${key}`, + ok: Boolean(value), + detail: value ? `@${key} is filled.` : missingDetails[key], + }); + } + + let parsedTags = { tags: [] }; + if (values.tags) { + parsedTags = parseTags(values.tags); + const tagsValid = Boolean(parsedTags.tags && parsedTags.tags.length > 0); + checks.push({ + name: "Metadata tags parse", + ok: tagsValid, + detail: tagsValid + ? "Tags are formatted correctly." + : `Format \`@tags:\` as a list of tags in quotes and brackets, for example \`@tags: ['maze', 'puzzle']\`.\nReason: ${parsedTags.issue ?? "Tags list cannot be empty."}`, + }); + } + + if (values.addedOn) { + checkMetadataDate(values.addedOn, (name, ok, detail) => checks.push({ name, ok, detail })); + } + + const titleLooksLikeTemplate = /^getting(_|\s)started$/i.test(values.title?.trim()) || /^template$/i.test(values.title?.trim()) || /^my game$/i.test(values.title?.trim()); + const authorLooksLikeTemplate = /leo,\s*edits/i.test(values.author) || /^my name$/i.test(values.author?.trim()); + const descriptionLooksLikeTemplate = /short description about the game/i.test(values.description?.trim()); + const hasExampleTags = parsedTags.tags?.some((t) => ["tag1", "tag2", "example", "another-example"].includes(t.toLowerCase())); + + const hasTemplateValues = titleLooksLikeTemplate || authorLooksLikeTemplate || descriptionLooksLikeTemplate || hasExampleTags; + + checks.push({ + name: "Metadata template values", + ok: !hasTemplateValues, + detail: !hasTemplateValues + ? "No template metadata values found." + : "Replace example/template values in the metadata header (like 'MY GAME', 'MY NAME', 'Short description...', or placeholder tags).", + }); + + return { checks, values: { ...values, tags: parsedTags.tags ?? values.tags }, hasHeader: true }; +} + From 234bd17d857ec06181059b9748aef79c04dc4c16 Mon Sep 17 00:00:00 2001 From: Maaren <80040706+SSoggyTacoMan@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:33:34 +0200 Subject: [PATCH 10/13] fix(ci): infer accurate metadata fields for copy-paste template --- .github/scripts/auto-triage.mjs | 101 ++--------- .github/scripts/metadata-parsing.test.mjs | 95 ++++++++++ .github/scripts/review-utils.mjs | 205 ++++++++++++++++++++++ 3 files changed, 310 insertions(+), 91 deletions(-) diff --git a/.github/scripts/auto-triage.mjs b/.github/scripts/auto-triage.mjs index 5d2d9fe26e..02cab3ada0 100644 --- a/.github/scripts/auto-triage.mjs +++ b/.github/scripts/auto-triage.mjs @@ -5,12 +5,15 @@ import { addLabels, buildMetadataSnippet, ensureReviewLabels, + extractAboutAndGameplay, + extractAuthor, getIssueLabels, getMetadataValue, getRepository, githubPaginated, githubRequest, hasLabel, + inferSubmissionMetadata, parseTags, readGitHubEvent, removeLabel, @@ -365,6 +368,7 @@ async function validateSubmission({ pullRequest, pullFiles, workspace, reviewBas warnings, metadata: metadata?.values ?? null, gameFile: gameFile?.filename ?? null, + content, rawUrl, playUrl, screenshotUrl, @@ -386,87 +390,6 @@ function validatePullRequestBody(body) { return { checks }; } -function getSectionBlocks(body, heading) { - const pattern = new RegExp(String.raw`(?:^|\n)#+\s*${heading}[^\n]*\n([\s\S]*?)(?=\n#+\s|$)`, "i"); - const match = body.match(pattern); - if (!match) return []; - const clean = stripTemplateNoise(match[1]); - return clean - .split(/\n\s*\n+/) - .map((p) => normalizeFieldValue(p)) - .filter(Boolean); -} - -function extractAuthor(body) { - const direct = extractBoldField(body, "Author"); - if (direct) return direct; - const blocks = getSectionBlocks(body, "Author(?:\\s+name)?"); - for (const block of blocks) { - if (/^author:?$/i.test(block)) continue; - const clean = block.replace(/^author:\s*/i, "").trim(); - if (clean) return clean; - } - return ""; -} - -function extractAboutAndGameplay(body) { - let about = extractBoldField(body, "What is your game about?"); - let gameplay = extractBoldField(body, "How do you play your game?"); - if (about && gameplay) return { about, gameplay }; - - const isPrompt = (text) => - /what is your game about/i.test(text) || - /how do you play your game/i.test(text); - - const blocks = getSectionBlocks(body, "About your game").filter((b) => !isPrompt(b)); - - if (!about && blocks.length > 0) about = blocks[0]; - if (!gameplay && blocks.length > 1) gameplay = blocks[1]; - - return { about, gameplay }; -} - -function extractBoldField(body, label) { - const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`); - - const boldPattern = new RegExp(String.raw`\*\*${escaped}:?\*\*\s*([\s\S]*?)(?=\n\s*(?:\*\*|#{1,6})|$)`, "i"); - const boldMatch = body.match(boldPattern); - if (boldMatch) { - const value = normalizeFieldValue(stripTemplateNoise(boldMatch[1])); - if (value) return value; - } - - const fullBoldPattern = new RegExp(String.raw`(?:\*\*|\*)${escaped}\s*:\s*(.+?)(?:\*\*|\*)(?:\r?\n|$)`, "i"); - const fullBoldMatch = body.match(fullBoldPattern); - if (fullBoldMatch) { - const value = normalizeFieldValue(stripTemplateNoise(fullBoldMatch[1])); - if (value) return value; - } - - const plainPattern = new RegExp(String.raw`(?:^|\n)\s*(?:[-*]|\d+\.)*\s*(?:\*\*|\*)?${escaped}\s*:\s*([^\n]+)`, "i"); - const plainMatch = body.match(plainPattern); - if (plainMatch) { - const value = normalizeFieldValue(stripTemplateNoise(plainMatch[1])); - if (value) return value; - } - - return ""; -} - -function normalizeFieldValue(value) { - return value - .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") - .replace(/^[*`_~]+|[*`_~]+$/g, "") - .trim(); -} - -function stripTemplateNoise(value) { - return value - .replace(//g, "") - .replace(/^>\s?.*$/gm, "") - .trim(); -} - async function validateMetadata(content, filename, workspace) { const { checks, values, hasHeader } = validateMetadataHeader(content, filename); @@ -711,17 +634,13 @@ function buildComment(result) { const hasMetadataFailure = failedChecks.some((c) => categoryMap[c.name] === "metadata"); if (hasMetadataFailure && result.gameFile) { - const defaultTitle = pullRequest.title ? pullRequest.title.replace(/^\[.*?\]\s*/, "").trim() : ""; - const filenameTitle = (result.gameFile.split("/").pop() ?? "").replace(/\.js$/, "").replace(/[-_]+/g, " "); - const sampleTitle = metadata.title || defaultTitle || filenameTitle || "My Game"; - const sampleAuthor = metadata.author || pullRequest.user?.login || "Your Name"; - const snippet = buildMetadataSnippet({ - title: sampleTitle, - author: sampleAuthor, - description: metadata.description || undefined, - tags: Array.isArray(metadata.tags) && metadata.tags.length ? metadata.tags : undefined, - addedOn: metadata.addedOn || undefined, + const inferred = inferSubmissionMetadata({ + content: result.content, + filename: result.gameFile, + pullRequest, + existingMetadata: metadata, }); + const snippet = buildMetadataSnippet(inferred); checksSection += `> [!TIP]\n> **How to fix metadata:**\n> Click **[Edit Game File](${editUrl})** and paste this block at the very top (line 1) of \`${result.gameFile}\`:\n> \`\`\`javascript\n> ${snippet.split("\n").join("\n> ")}\n> \`\`\`\n\n`; } diff --git a/.github/scripts/metadata-parsing.test.mjs b/.github/scripts/metadata-parsing.test.mjs index b0ba428708..c36562e0d9 100644 --- a/.github/scripts/metadata-parsing.test.mjs +++ b/.github/scripts/metadata-parsing.test.mjs @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { buildMetadataSnippet, getMetadataValue, + inferSubmissionMetadata, parseTags, validateMetadataHeader, } from "./review-utils.mjs"; @@ -229,4 +230,98 @@ describe("buildMetadataSnippet", () => { }); }); +describe("inferSubmissionMetadata", () => { + it("infers accurate metadata from PR details and source code comments", () => { + const content = `// Block Pusher (Sokoban) +const player = "p"; +const block = "b";`; + const pullRequest = { + title: "[Sprig App] Vasko JS game", + user: { login: "vask3" }, + body: `### Author name +Author: vask3 + +### About your game +**What is your game about?** +Hello, thats is my first JS game. + +**How do you play your game?** +Objective: Push the block (b) onto the target tile (g).`, + }; + + const inferred = inferSubmissionMetadata({ + content, + filename: "games/Vasko-JS-game.js", + pullRequest, + }); + + expect(inferred.title).toBe("Block Pusher"); + expect(inferred.author).toBe("vask3"); + expect(inferred.description).toContain("Hello, thats is my first JS game"); + expect(inferred.tags).toEqual(["puzzle"]); + expect(inferred.addedOn).toMatch(/^\d{4}-\d{2}-\d{2}$/); + + // Guarantee that pasting the generated snippet passes 100% of metadata checks + const snippet = buildMetadataSnippet(inferred); + const validation = validateMetadataHeader(`${snippet}\n${content}`, "Vasko-JS-game.js"); + expect(validation.hasHeader).toBe(true); + expect(validation.checks.every((c) => c.ok)).toBe(true); + }); + + it("falls back to cleaned PR title and author login when comments/body are minimal", () => { + const content = `const a = 1;`; + const pullRequest = { + title: "Add Dungeon Crawler Game", + user: { login: "gamer123" }, + body: "", + }; + + const inferred = inferSubmissionMetadata({ + content, + filename: "games/dungeon_crawler.js", + pullRequest, + }); + + expect(inferred.title).toBe("Dungeon Crawler Game"); + expect(inferred.author).toBe("gamer123"); + expect(inferred.description).toBe("A custom game called Dungeon Crawler Game built for Sprig."); + expect(inferred.tags).toBeDefined(); + expect(inferred.tags.length).toBeGreaterThan(0); + + const snippet = buildMetadataSnippet(inferred); + const validation = validateMetadataHeader(`${snippet}\n${content}`, "dungeon_crawler.js"); + expect(validation.hasHeader).toBe(true); + expect(validation.checks.every((c) => c.ok)).toBe(true); + }); + + it("preserves already valid existing metadata fields", () => { + const content = `/* +@title: Retro Race +@author: Speedster +*/`; + const existingMetadata = { + title: "Retro Race", + author: "Speedster", + }; + const pullRequest = { + title: "[Sprig App] race", + user: { login: "someone" }, + body: "**What is your game about?** Dodge cars at high speed.", + }; + + const inferred = inferSubmissionMetadata({ + content, + filename: "games/retro_race.js", + pullRequest, + existingMetadata, + }); + + expect(inferred.title).toBe("Retro Race"); + expect(inferred.author).toBe("Speedster"); + expect(inferred.description).toBe("Dodge cars at high speed."); + expect(inferred.tags).toContain("runner"); + }); +}); + + diff --git a/.github/scripts/review-utils.mjs b/.github/scripts/review-utils.mjs index 3b3d035211..8df4d3e29b 100644 --- a/.github/scripts/review-utils.mjs +++ b/.github/scripts/review-utils.mjs @@ -1,4 +1,5 @@ import { readFileSync } from "node:fs"; +import path from "node:path"; export const LABELS = { Submission: { @@ -356,6 +357,210 @@ export function parseTags(raw) { return { tags }; } +export function stripTemplateNoise(value) { + return (value ?? "") + .replace(//g, "") + .replace(/^>\s?.*$/gm, "") + .trim(); +} + +export function normalizeFieldValue(value) { + return (value ?? "") + .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") + .replace(/^[*`_~]+|[*`_~]+$/g, "") + .trim(); +} + +export function getSectionBlocks(body, heading) { + const pattern = new RegExp(String.raw`(?:^|\n)#+\s*${heading}[^\n]*\n([\s\S]*?)(?=\n#+\s|$)`, "i"); + const match = (body ?? "").match(pattern); + if (!match) return []; + const clean = stripTemplateNoise(match[1]); + return clean + .split(/\n\s*\n+/) + .map((p) => normalizeFieldValue(p)) + .filter(Boolean); +} + +export function extractBoldField(body, label) { + const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`); + + const boldPattern = new RegExp(String.raw`\*\*${escaped}:?\*\*\s*([\s\S]*?)(?=\n\s*(?:\*\*|#{1,6})|$)`, "i"); + const boldMatch = (body ?? "").match(boldPattern); + if (boldMatch) { + const value = normalizeFieldValue(stripTemplateNoise(boldMatch[1])); + if (value) return value; + } + + const fullBoldPattern = new RegExp(String.raw`(?:\*\*|\*)${escaped}\s*:\s*(.+?)(?:\*\*|\*)(?:\r?\n|$)`, "i"); + const fullBoldMatch = (body ?? "").match(fullBoldPattern); + if (fullBoldMatch) { + const value = normalizeFieldValue(stripTemplateNoise(fullBoldMatch[1])); + if (value) return value; + } + + const plainPattern = new RegExp(String.raw`(?:^|\n)\s*(?:[-*]|\d+\.)*\s*(?:\*\*|\*)?${escaped}\s*:\s*([^\n]+)`, "i"); + const plainMatch = (body ?? "").match(plainPattern); + if (plainMatch) { + const value = normalizeFieldValue(stripTemplateNoise(plainMatch[1])); + if (value) return value; + } + + return ""; +} + +export function extractAuthor(body) { + const direct = extractBoldField(body, "Author"); + if (direct) return direct; + const blocks = getSectionBlocks(body, "Author(?:\\s+name)?"); + for (const block of blocks) { + if (/^author:?$/i.test(block)) continue; + const clean = block.replace(/^author:\s*/i, "").trim(); + if (clean) return clean; + } + return ""; +} + +export function extractAboutAndGameplay(body) { + let about = extractBoldField(body, "What is your game about?"); + let gameplay = extractBoldField(body, "How do you play your game?"); + if (about && gameplay) return { about, gameplay }; + + const isPrompt = (text) => + /what is your game about/i.test(text) || + /how do you play your game/i.test(text); + + const blocks = getSectionBlocks(body, "About your game").filter((b) => !isPrompt(b)); + + if (!about && blocks.length > 0) about = blocks[0]; + if (!gameplay && blocks.length > 1) gameplay = blocks[1]; + + return { about, gameplay }; +} + +export const TAG_KEYWORDS = [ + { tag: "puzzle", pattern: /\b(?:puzzle|sokoban|box|boxes|block|push|slide|tiles|logic|match)\b/i }, + { tag: "maze", pattern: /\b(?:maze|labyrinth)\b/i }, + { tag: "runner", pattern: /\b(?:runner|running|dodge)\b/i }, + { tag: "platformer", pattern: /\b(?:platformer|platform|jump|jumping)\b/i }, + { tag: "arcade", pattern: /\b(?:arcade|classic)\b/i }, + { tag: "action", pattern: /\b(?:action|fight|battle)\b/i }, + { tag: "shooter", pattern: /\b(?:shooter|shoot|bullet|laser|space|invader)\b/i }, + { tag: "multiplayer", pattern: /\b(?:multiplayer|2\s*player|two\s*player|versus)\b/i }, + { tag: "strategy", pattern: /\b(?:strategy|tactics|chess|tower)\b/i }, + { tag: "card", pattern: /\b(?:card|blackjack|poker|deck)\b/i }, + { tag: "music", pattern: /\b(?:music|tune|rhythm|audio)\b/i }, + { tag: "retro", pattern: /\b(?:retro|pixel|8[- ]?bit)\b/i }, + { tag: "endless", pattern: /\b(?:endless|infinite|score)\b/i }, + { tag: "adventure", pattern: /\b(?:adventure|quest|rpg|explore)\b/i }, + { tag: "simulation", pattern: /\b(?:simulation|sim|tycoon|farm)\b/i }, +]; + +export function inferSubmissionMetadata({ content, filename, pullRequest, existingMetadata } = {}) { + // 1. Title + let title = ""; + if (existingMetadata?.title && !/^getting(_|\s)started$/i.test(existingMetadata.title.trim()) && !/^template$/i.test(existingMetadata.title.trim()) && !/^my game$/i.test(existingMetadata.title.trim())) { + title = existingMetadata.title.trim(); + } else { + if (content) { + const commentMatch = content.match(/^\s*(?:\/\/|\/\*)\s*([A-Za-z0-9][A-Za-z0-9\s-_']{2,40})(?:\s*\([^)]*\))?\s*(?:\*\/|$)/m); + if (commentMatch) { + const candidate = commentMatch[1].trim(); + if (!candidate.startsWith("@") && !/^eslint/i.test(candidate) && !/^prettier/i.test(candidate) && !/^sprig/i.test(candidate)) { + title = candidate; + } + } + } + if (!title && pullRequest?.title) { + const cleanedPr = pullRequest.title + .replace(/^\[(?:sprig app|sprig|game|submission)\]\s*/i, "") + .replace(/^(?:add|create|new game:?)\s+/i, "") + .trim(); + if (cleanedPr && cleanedPr.length >= 2 && !/^my game$/i.test(cleanedPr)) { + title = cleanedPr; + } + } + if (!title && filename) { + const basename = path.basename(filename, ".js").replace(/[-_]+/g, " ").trim(); + if (basename && !/^my game$/i.test(basename)) { + title = basename; + } + } + if (!title) title = "Sprig Game"; + } + + // 2. Author + let author = ""; + if (existingMetadata?.author && !/leo,\s*edits/i.test(existingMetadata.author) && !/^my name$/i.test(existingMetadata.author.trim())) { + author = existingMetadata.author.trim(); + } else { + const prAuthor = extractAuthor(pullRequest?.body ?? ""); + if (prAuthor && !/^my name$/i.test(prAuthor.trim())) { + author = prAuthor.trim(); + } else if (pullRequest?.user?.login && !/^my name$/i.test(pullRequest.user.login.trim())) { + author = pullRequest.user.login.trim(); + } else { + author = "Sprig Developer"; + } + } + + // 3. Description + let description = ""; + if (existingMetadata?.description && !/short description about the game/i.test(existingMetadata.description.trim())) { + description = existingMetadata.description.trim(); + } else { + const { about, gameplay } = extractAboutAndGameplay(pullRequest?.body ?? ""); + const cleanAbout = about ? about.replace(//g, "").replace(/\s+/g, " ").trim() : ""; + const cleanGameplay = gameplay ? gameplay.replace(//g, "").replace(/\s+/g, " ").trim() : ""; + + let descCandidate = cleanAbout; + if (cleanAbout.length < 25 && cleanGameplay) { + descCandidate = cleanAbout ? `${cleanAbout} ${cleanGameplay}` : cleanGameplay; + } else if (!cleanAbout && cleanGameplay) { + descCandidate = cleanGameplay; + } + + descCandidate = descCandidate.replace(/^objective:\s*/i, "").trim(); + const firstSentence = descCandidate.split(/(?<=[.!?])\s+/)[0]?.trim() ?? ""; + if (firstSentence.length >= 5 && !/short description/i.test(firstSentence)) { + description = firstSentence.length > 140 ? `${firstSentence.slice(0, 137)}...` : firstSentence; + } else { + description = `A custom game called ${title} built for Sprig.`; + } + } + + // 4. Tags + let tags = []; + if (Array.isArray(existingMetadata?.tags) && existingMetadata.tags.length > 0) { + const filtered = existingMetadata.tags.filter((t) => !["tag1", "tag2", "example", "another-example"].includes(t.toLowerCase())); + if (filtered.length > 0) tags = filtered; + } + if (tags.length === 0) { + const text = `${filename ?? ""} ${pullRequest?.title ?? ""} ${pullRequest?.body ?? ""} ${(content ?? "").slice(0, 3000)}`.toLowerCase(); + for (const { tag, pattern } of TAG_KEYWORDS) { + if (pattern.test(text)) { + tags.push(tag); + if (tags.length >= 2) break; + } + } + if (tags.length === 0) tags = ["puzzle"]; + } + + // 5. AddedOn + let addedOn = ""; + if (existingMetadata?.addedOn && /^\d{4}-\d{2}-\d{2}$/.test(existingMetadata.addedOn)) { + const parsed = new Date(`${existingMetadata.addedOn}T00:00:00Z`); + if (!Number.isNaN(parsed.getTime()) && Math.abs(Date.now() - parsed.getTime()) <= 183 * 86_400_000) { + addedOn = existingMetadata.addedOn; + } + } + if (!addedOn) { + addedOn = new Date().toISOString().slice(0, 10); + } + + return { title, author, description, tags, addedOn }; +} + export function checkMetadataDate(addedOn, add) { const validFormat = /^\d{4}-\d{2}-\d{2}$/.test(addedOn); const parsedDate = validFormat ? new Date(`${addedOn}T00:00:00Z`) : null; From 650b97adbbf7398be1d985daeaee17ed68008709 Mon Sep 17 00:00:00 2001 From: Maaren <80040706+SSoggyTacoMan@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:41:41 +0200 Subject: [PATCH 11/13] fix(ci): refine game title cleaning for trailing author names and hyphens --- .github/scripts/review-utils.mjs | 68 +++++++++++++++++++++----------- 1 file changed, 45 insertions(+), 23 deletions(-) diff --git a/.github/scripts/review-utils.mjs b/.github/scripts/review-utils.mjs index 8df4d3e29b..f792279f2e 100644 --- a/.github/scripts/review-utils.mjs +++ b/.github/scripts/review-utils.mjs @@ -439,23 +439,44 @@ export function extractAboutAndGameplay(body) { } export const TAG_KEYWORDS = [ - { tag: "puzzle", pattern: /\b(?:puzzle|sokoban|box|boxes|block|push|slide|tiles|logic|match)\b/i }, + { tag: "arcade", pattern: /\b(?:casino|roulette|slot|slots|payout|bet|betting)\b/i }, + { tag: "strategy", pattern: /\b(?:chess|checkmate|tic[- ]?tac(?:[- ]?toe)?)\b/i }, + { tag: "music", pattern: /\b(?:music|tune|rhythm|audio|beat|melody)\b/i }, + { tag: "runner", pattern: /\b(?:runner|running|dodge|dodging|dino)\b/i }, + { tag: "shooter", pattern: /\b(?:shooter|shoot|bullet|laser|spaceships?|asteroid|asteroids|invader)\b/i }, + { tag: "multiplayer", pattern: /\b(?:multiplayer|2[- ]?player|two[- ]?player|versus|1v1|pvp)\b/i }, { tag: "maze", pattern: /\b(?:maze|labyrinth)\b/i }, - { tag: "runner", pattern: /\b(?:runner|running|dodge)\b/i }, { tag: "platformer", pattern: /\b(?:platformer|platform|jump|jumping)\b/i }, - { tag: "arcade", pattern: /\b(?:arcade|classic)\b/i }, - { tag: "action", pattern: /\b(?:action|fight|battle)\b/i }, - { tag: "shooter", pattern: /\b(?:shooter|shoot|bullet|laser|space|invader)\b/i }, - { tag: "multiplayer", pattern: /\b(?:multiplayer|2\s*player|two\s*player|versus)\b/i }, - { tag: "strategy", pattern: /\b(?:strategy|tactics|chess|tower)\b/i }, + { tag: "strategy", pattern: /\b(?:strategy|tactics|tower|dispatcher)\b/i }, { tag: "card", pattern: /\b(?:card|blackjack|poker|deck)\b/i }, - { tag: "music", pattern: /\b(?:music|tune|rhythm|audio)\b/i }, + { tag: "adventure", pattern: /\b(?:adventure|quest|rpg|explore|dungeon)\b/i }, + { tag: "puzzle", pattern: /\b(?:puzzle|sokoban|boxes?|block|push|slide|tiles?|logic|minesweeper|mine\s*sweeper)\b/i }, + { tag: "action", pattern: /\b(?:action|fight|battle|bomb|bomberman|blast)\b/i }, + { tag: "endless", pattern: /\b(?:endless|infinite|high score)\b/i }, { tag: "retro", pattern: /\b(?:retro|pixel|8[- ]?bit)\b/i }, - { tag: "endless", pattern: /\b(?:endless|infinite|score)\b/i }, - { tag: "adventure", pattern: /\b(?:adventure|quest|rpg|explore)\b/i }, - { tag: "simulation", pattern: /\b(?:simulation|sim|tycoon|farm)\b/i }, + { tag: "arcade", pattern: /\b(?:arcade|classic)\b/i }, + { tag: "simulation", pattern: /\b(?:simulation|sim|tycoon|farm|gravity)\b/i }, ]; +export function cleanGameTitle(rawTitle) { + if (!rawTitle) return ""; + let title = rawTitle + .replace(/^\[(?:sprig app|sprig|game|submission)\]\s*/i, "") + .replace(/^(?:add|upload)?\s*(?:files?\s+via\s+upload|uploaded\s+files?)\s*/i, "") + .replace(/^(?:add|create|new game:?)\s+/i, "") + .replace(/\.js$/i, "") + .replace(/[-_]v\d+.*$/i, "") + .replace(/[-–—]\s*[A-Z][a-z]+(?:\s+[A-Z][a-z]+)+$/g, "") + .replace(/["'`]/g, "") + .trim(); + + if (/^[A-Za-z0-9]+(?:[-_][A-Za-z0-9]+)+$/.test(title)) { + title = title.replace(/[-_]+/g, " "); + } + + return title.trim(); +} + export function inferSubmissionMetadata({ content, filename, pullRequest, existingMetadata } = {}) { // 1. Title let title = ""; @@ -465,23 +486,20 @@ export function inferSubmissionMetadata({ content, filename, pullRequest, existi if (content) { const commentMatch = content.match(/^\s*(?:\/\/|\/\*)\s*([A-Za-z0-9][A-Za-z0-9\s-_']{2,40})(?:\s*\([^)]*\))?\s*(?:\*\/|$)/m); if (commentMatch) { - const candidate = commentMatch[1].trim(); + const candidate = cleanGameTitle(commentMatch[1]); if (!candidate.startsWith("@") && !/^eslint/i.test(candidate) && !/^prettier/i.test(candidate) && !/^sprig/i.test(candidate)) { title = candidate; } } } if (!title && pullRequest?.title) { - const cleanedPr = pullRequest.title - .replace(/^\[(?:sprig app|sprig|game|submission)\]\s*/i, "") - .replace(/^(?:add|create|new game:?)\s+/i, "") - .trim(); + const cleanedPr = cleanGameTitle(pullRequest.title); if (cleanedPr && cleanedPr.length >= 2 && !/^my game$/i.test(cleanedPr)) { title = cleanedPr; } } if (!title && filename) { - const basename = path.basename(filename, ".js").replace(/[-_]+/g, " ").trim(); + const basename = cleanGameTitle(path.basename(filename, ".js")); if (basename && !/^my game$/i.test(basename)) { title = basename; } @@ -503,6 +521,7 @@ export function inferSubmissionMetadata({ content, filename, pullRequest, existi author = "Sprig Developer"; } } + author = author.replace(/[:;\s]+$/, "").replace(/^["'`]|["'`]$/g, "").trim(); // 3. Description let description = ""; @@ -510,12 +529,13 @@ export function inferSubmissionMetadata({ content, filename, pullRequest, existi description = existingMetadata.description.trim(); } else { const { about, gameplay } = extractAboutAndGameplay(pullRequest?.body ?? ""); - const cleanAbout = about ? about.replace(//g, "").replace(/\s+/g, " ").trim() : ""; - const cleanGameplay = gameplay ? gameplay.replace(//g, "").replace(/\s+/g, " ").trim() : ""; + const cleanAbout = about ? stripTemplateNoise(about).replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim() : ""; + const cleanGameplay = gameplay ? stripTemplateNoise(gameplay).replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim() : ""; let descCandidate = cleanAbout; - if (cleanAbout.length < 25 && cleanGameplay) { - descCandidate = cleanAbout ? `${cleanAbout} ${cleanGameplay}` : cleanGameplay; + if (cleanAbout && cleanAbout.length < 25 && cleanGameplay) { + const sep = /[.!?]$/.test(cleanAbout) ? " " : ". "; + descCandidate = `${cleanAbout}${sep}${cleanGameplay}`; } else if (!cleanAbout && cleanGameplay) { descCandidate = cleanGameplay; } @@ -528,6 +548,7 @@ export function inferSubmissionMetadata({ content, filename, pullRequest, existi description = `A custom game called ${title} built for Sprig.`; } } + description = description.replace(/[\r\n]+/g, " ").replace(/^["'`]|["'`]$/g, "").trim(); // 4. Tags let tags = []; @@ -536,9 +557,10 @@ export function inferSubmissionMetadata({ content, filename, pullRequest, existi if (filtered.length > 0) tags = filtered; } if (tags.length === 0) { - const text = `${filename ?? ""} ${pullRequest?.title ?? ""} ${pullRequest?.body ?? ""} ${(content ?? "").slice(0, 3000)}`.toLowerCase(); + const cleanBody = stripTemplateNoise(pullRequest?.body ?? ""); + const text = `${filename ?? ""} ${pullRequest?.title ?? ""} ${cleanBody} ${(content ?? "").slice(0, 3000)}`.toLowerCase(); for (const { tag, pattern } of TAG_KEYWORDS) { - if (pattern.test(text)) { + if (pattern.test(text) && !tags.includes(tag)) { tags.push(tag); if (tags.length >= 2) break; } From 216ba09db45c5818a67f983f59577cfd45d86a2d Mon Sep 17 00:00:00 2001 From: Maaren <80040706+SSoggyTacoMan@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:48:37 +0200 Subject: [PATCH 12/13] fix(ci): preserve apostrophes in game titles and add unit tests --- .github/scripts/metadata-parsing.test.mjs | 34 +++++++++++++++++++++++ .github/scripts/review-utils.mjs | 10 +++++-- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/.github/scripts/metadata-parsing.test.mjs b/.github/scripts/metadata-parsing.test.mjs index c36562e0d9..b6c7cfd409 100644 --- a/.github/scripts/metadata-parsing.test.mjs +++ b/.github/scripts/metadata-parsing.test.mjs @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { buildMetadataSnippet, + cleanGameTitle, getMetadataValue, inferSubmissionMetadata, parseTags, @@ -323,5 +324,38 @@ Objective: Push the block (b) onto the target tile (g).`, }); }); +describe("cleanGameTitle", () => { + it("strips [Sprig App] and related variations cleanly", () => { + expect(cleanGameTitle("[Sprig App] warios")).toBe("warios"); + expect(cleanGameTitle("[SPRIG APP] warios")).toBe("warios"); + expect(cleanGameTitle("[Sprig app] - warios")).toBe("warios"); + expect(cleanGameTitle("[Sprig App]: warios")).toBe("warios"); + expect(cleanGameTitle("(Sprig App) warios")).toBe("warios"); + expect(cleanGameTitle("Sprig App: warios")).toBe("warios"); + expect(cleanGameTitle("Sprig App - warios")).toBe("warios"); + expect(cleanGameTitle("Sprig App warios")).toBe("warios"); + expect(cleanGameTitle("[Sprig Game] warios")).toBe("warios"); + expect(cleanGameTitle("[Sprig] warios")).toBe("warios"); + expect(cleanGameTitle("Sprig - warios")).toBe("warios"); + expect(cleanGameTitle("Sprig: warios")).toBe("warios"); + }); + + it("preserves legitimate titles that start with the Sprig character name", () => { + expect(cleanGameTitle("Sprig Click")).toBe("Sprig Click"); + expect(cleanGameTitle("Sprig Adventure")).toBe("Sprig Adventure"); + expect(cleanGameTitle("Sprig's Quest")).toBe("Sprig's Quest"); + }); + + it("cleans GitHub web upload prefixes, version suffixes, and file extensions", () => { + expect(cleanGameTitle("Add files via upload warios")).toBe("warios"); + expect(cleanGameTitle("Create the_cube.js")).toBe("the cube"); + expect(cleanGameTitle("Beat-Drop-v1")).toBe("Beat Drop"); + expect(cleanGameTitle("Retro space shooter- Michael Qu")).toBe("Retro space shooter"); + expect(cleanGameTitle("The-Ellis-Emerald")).toBe("The Ellis Emerald"); + expect(cleanGameTitle('Add "A Long Fall" game')).toBe("A Long Fall game"); + }); +}); + + diff --git a/.github/scripts/review-utils.mjs b/.github/scripts/review-utils.mjs index f792279f2e..4262bff6fb 100644 --- a/.github/scripts/review-utils.mjs +++ b/.github/scripts/review-utils.mjs @@ -461,13 +461,17 @@ export const TAG_KEYWORDS = [ export function cleanGameTitle(rawTitle) { if (!rawTitle) return ""; let title = rawTitle - .replace(/^\[(?:sprig app|sprig|game|submission)\]\s*/i, "") - .replace(/^(?:add|upload)?\s*(?:files?\s+via\s+upload|uploaded\s+files?)\s*/i, "") + .replace(/^(?:\[|\()\s*sprig\s*(?:app|game|submission)?\s*[\]\)]\s*[:\-–—]?\s*/i, "") + .replace(/^sprig\s+(?:app|game|submission)\s*[:\-–—]?\s*/i, "") + .replace(/^sprig\s*[:\-–—]\s*/i, "") + .replace(/^(?:add|upload)?\s*(?:files?\s+via\s+upload|uploaded\s+files?)\s*[:\-–—]?\s*/i, "") .replace(/^(?:add|create|new game:?)\s+/i, "") .replace(/\.js$/i, "") .replace(/[-_]v\d+.*$/i, "") .replace(/[-–—]\s*[A-Z][a-z]+(?:\s+[A-Z][a-z]+)+$/g, "") - .replace(/["'`]/g, "") + .replace(/["`]/g, "") + .replace(/^'+|'+$/g, "") + .replace(/^[:\-–—\s]+/, "") .trim(); if (/^[A-Za-z0-9]+(?:[-_][A-Za-z0-9]+)+$/.test(title)) { From ca731b6fc4c666f96bdb40408078b9da035048da Mon Sep 17 00:00:00 2001 From: Maaren <80040706+SSoggyTacoMan@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:53:20 +0200 Subject: [PATCH 13/13] fix(ci): add friendly review note to auto-generated metadata snippet --- .github/scripts/auto-triage.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/auto-triage.mjs b/.github/scripts/auto-triage.mjs index 02cab3ada0..f1a3cc6105 100644 --- a/.github/scripts/auto-triage.mjs +++ b/.github/scripts/auto-triage.mjs @@ -642,7 +642,7 @@ function buildComment(result) { }); const snippet = buildMetadataSnippet(inferred); - checksSection += `> [!TIP]\n> **How to fix metadata:**\n> Click **[Edit Game File](${editUrl})** and paste this block at the very top (line 1) of \`${result.gameFile}\`:\n> \`\`\`javascript\n> ${snippet.split("\n").join("\n> ")}\n> \`\`\`\n\n`; + checksSection += `> [!TIP]\n> **How to fix metadata:**\n> Click **[Edit Game File](${editUrl})** and paste this block at the very top (line 1) of \`${result.gameFile}\`.\n>\n> *Note: We generated this header automatically from your PR details to help you out. Please review it and update any details (like your title, description, or tags) to make sure they match your game before committing!*\n> \`\`\`javascript\n> ${snippet.split("\n").join("\n> ")}\n> \`\`\`\n\n`; } }