diff --git a/.github/scripts/auto-triage.mjs b/.github/scripts/auto-triage.mjs index 66a39968fe..f1a3cc6105 100644 --- a/.github/scripts/auto-triage.mjs +++ b/.github/scripts/auto-triage.mjs @@ -3,22 +3,28 @@ import path from "node:path"; import { REVIEW_BOT_MARKER, addLabels, + buildMetadataSnippet, ensureReviewLabels, + extractAboutAndGameplay, + extractAuthor, getIssueLabels, getMetadataValue, getRepository, githubPaginated, githubRequest, hasLabel, + inferSubmissionMetadata, parseTags, readGitHubEvent, removeLabel, setStateLabel, upsertBotComment, + validateMetadataHeader, } from "./review-utils.mjs"; import { buildSubmissionManifest } from "./submission-manifest.mjs"; import { DUPLICATE_LABEL, + LEGACY_DUPLICATE_LABEL, DUPLICATE_NOTICE_MARKER, buildLatestWarning, buildOlderCheckDetail, @@ -236,10 +242,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 +256,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) { @@ -359,6 +368,7 @@ async function validateSubmission({ pullRequest, pullFiles, workspace, reviewBas warnings, metadata: metadata?.values ?? null, gameFile: gameFile?.filename ?? null, + content, rawUrl, playUrl, screenshotUrl, @@ -380,133 +390,8 @@ 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 = []; - 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) => { @@ -516,25 +401,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) { @@ -706,6 +595,7 @@ function buildComment(result) { "Code checklist": "pr", "Image checklist": "pr", + "Metadata header": "metadata", "Metadata @title": "metadata", "Metadata @author": "metadata", "Metadata @description": "metadata", @@ -728,6 +618,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 = ""; @@ -737,6 +631,19 @@ 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 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>\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`; + } } checksSection += "#### Checks Status\n"; @@ -757,10 +664,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})`); @@ -779,7 +682,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"} @@ -792,7 +695,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.`} `; } @@ -800,31 +703,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/duplicate-detection.mjs b/.github/scripts/duplicate-detection.mjs index 5dcbf69eb7..af13ed2c97 100644 --- a/.github/scripts/duplicate-detection.mjs +++ b/.github/scripts/duplicate-detection.mjs @@ -1,4 +1,6 @@ -export const DUPLICATE_LABEL = "Potential Duplicate"; +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; @@ -23,7 +25,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}). 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 }) { @@ -31,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}**). -- 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).`; +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}". 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}". 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 1cef25cff6..6013b1f9aa 100644 --- a/.github/scripts/duplicate-detection.test.mjs +++ b/.github/scripts/duplicate-detection.test.mjs @@ -46,17 +46,18 @@ 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**) 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("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 can only have one open submission at a time"); 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('labeled "Multiple Submissions"'); + 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"); }); it("ignores notices without a marker", () => { diff --git a/.github/scripts/metadata-parsing.test.mjs b/.github/scripts/metadata-parsing.test.mjs index a756be3b78..b6c7cfd409 100644 --- a/.github/scripts/metadata-parsing.test.mjs +++ b/.github/scripts/metadata-parsing.test.mjs @@ -1,5 +1,12 @@ import { describe, expect, it } from "vitest"; -import { getMetadataValue, parseTags } from "./review-utils.mjs"; +import { + buildMetadataSnippet, + cleanGameTitle, + getMetadataValue, + inferSubmissionMetadata, + parseTags, + validateMetadataHeader, +} from "./review-utils.mjs"; describe("getMetadataValue", () => { it("parses fields from standard Sprig comment blocks", () => { @@ -119,3 +126,236 @@ 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"); + }); +}); + +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"); + }); +}); + +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 c7189411c0..4262bff6fb 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: { @@ -46,10 +47,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", @@ -356,4 +357,352 @@ 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: "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: "platformer", pattern: /\b(?:platformer|platform|jump|jumping)\b/i }, + { tag: "strategy", pattern: /\b(?:strategy|tactics|tower|dispatcher)\b/i }, + { tag: "card", pattern: /\b(?:card|blackjack|poker|deck)\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: "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(/^(?:\[|\()\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(/^[:\-–—\s]+/, "") + .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 = ""; + 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 = 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 = cleanGameTitle(pullRequest.title); + if (cleanedPr && cleanedPr.length >= 2 && !/^my game$/i.test(cleanedPr)) { + title = cleanedPr; + } + } + if (!title && filename) { + const basename = cleanGameTitle(path.basename(filename, ".js")); + 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"; + } + } + author = author.replace(/[:;\s]+$/, "").replace(/^["'`]|["'`]$/g, "").trim(); + + // 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 ? stripTemplateNoise(about).replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim() : ""; + const cleanGameplay = gameplay ? stripTemplateNoise(gameplay).replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim() : ""; + + let descCandidate = cleanAbout; + if (cleanAbout && cleanAbout.length < 25 && cleanGameplay) { + const sep = /[.!?]$/.test(cleanAbout) ? " " : ". "; + descCandidate = `${cleanAbout}${sep}${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.`; + } + } + description = description.replace(/[\r\n]+/g, " ").replace(/^["'`]|["'`]$/g, "").trim(); + + // 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 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) && !tags.includes(tag)) { + 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; + 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 }; +} + diff --git a/.github/scripts/stale-review-queue.mjs b/.github/scripts/stale-review-queue.mjs index 58a7dbf768..568210b365 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; } @@ -104,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, 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); @@ -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; } @@ -143,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", @@ -156,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 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" }); @@ -175,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: `This submission has been ready for maintainer review for 7 days. ${pings}, could one of you take a look?`, }); } @@ -194,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.", }); }