#!/usr/bin/env node // Fail a PR touching packages/studio or packages/player unless its body has Before and After sections with media. // usage: node scripts/check-pr-captures.mjs --base origin/main --head ; the body arrives in the env (see main). import { execFileSync } from "node:child_process"; import { realpathSync } from "node:fs"; import { pathToFileURL } from "node:url"; export const WATCHED_PREFIXES = ["packages/studio/", "packages/player/"]; export const NO_VISIBLE_CHANGE_MAX_LINES = 20; export const VISUAL_EXTENSIONS = [".tsx", ".css", ".html"]; const MEDIA_PATH = /\.(?:png|jpe?g|gif|webp|svg|mp4|mov|webm)$/i; const ATTACHMENT_PATH = /^\/user-attachments\/assets\/[\w-]+/; const IMAGE_EMBED = /!\[[^\]]*\]\(\s*https?:\/\/|<(?:img|video|source)\b[^>]*\bsrc=["']https?:\/\//i; const ANY_URL = /https?:\/\/[^\s)"'<>\]]+/gi; function parseUrl(raw) { try { return new URL(raw); } catch { return null; } } const isAttachmentUrl = (url) => url.hostname === "github.com" && ATTACHMENT_PATH.test(url.pathname); function isMediaUrl(raw) { const url = parseUrl(raw); return url !== null && (isAttachmentUrl(url) || MEDIA_PATH.test(url.pathname)); } const TEST_FILE = /\.(?:test|spec)\.[jt]sx?$/; const CAPTURE_TITLES = { before: /^before(?:\s*[:(].*|\s+[-–—]\s.*)?$/i, after: /^after(?:\s*[:(].*|\s+[-–—]\s.*)?$/i, noVisibleChange: /^no visible change\b/i, }; const isCapture = (title) => Object.values(CAPTURE_TITLES).some((re) => re.test(title)); const SETEXT = [ [/^=+\s*$/, "#"], [/^-{2,}\s*$/, "##"], ]; /** GitHub hides an unclosed comment to the end of the body; repeat so removal cannot join fragments into a new one. */ function stripComments(body) { let text = body; let previous; do { previous = text; text = text.replace(/|$)/g, ""); } while (text !== previous); return text; } const FENCE = /^ {0,3}(`{3,}|~{3,})(.*)$/; const closesFence = (open, line) => { const [, marker = "", rest = ""] = FENCE.exec(line) ?? []; return marker[0] === open[0] && marker.length >= open.length && rest.trim() === ""; }; const openingMarker = (line) => FENCE.exec(line)?.[1] ?? null; /** The open fence marker after this line (CommonMark: same character, at least as long, to close), or null. */ const nextFence = (open, line) => { if (open) return closesFence(open, line) ? null : open; return openingMarker(line); }; function blankFences(lines) { let open = null; return lines.map((line) => { const before = open; open = nextFence(open, line); return before || open ? "" : line; }); } const setextPrefix = (underline) => SETEXT.find(([re]) => re.test(underline))?.[1]; function promoteSetext(lines) { const padded = [...lines, ""]; return lines.map((line, i) => { const prefix = isCapture(line.trim()) ? setextPrefix(padded[i + 1]) : undefined; return prefix ? `${prefix} ${line.trim()}` : line; }); } /** Drop HTML comments and fenced code, and promote setext capture headings, so none can fake a section. */ const normalize = (body) => promoteSetext(blankFences(stripComments(body).split(/\r?\n/))); /** Sections run to the next heading of the same or a higher level, or to the next capture heading. */ export function parseSections(body) { const lines = normalize(body); const headings = []; lines.forEach((line, index) => { const match = /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line); if (match) headings.push({ level: match[1].length, title: match[2].trim(), index }); }); return headings.map((heading, i) => { const end = headings .slice(i + 1) .find((next) => next.level <= heading.level || isCapture(next.title)); return { title: heading.title, text: lines.slice(heading.index + 1, end ? end.index : lines.length).join("\n"), }; }); } export function hasMedia(text) { return IMAGE_EMBED.test(text) || (text.match(ANY_URL) ?? []).some(isMediaUrl); } function findSection(sections, name) { return sections.find((section) => CAPTURE_TITLES[name].test(section.title)); } function parseRecord(record) { const [added, deleted, ...rest] = record.split("\t"); const binary = added === "-" || deleted === "-"; const lines = binary ? NO_VISIBLE_CHANGE_MAX_LINES : Number(added) + Number(deleted); return { path: rest.join("\t"), lines }; } const isWatched = (path) => path !== "" && WATCHED_PREFIXES.some((prefix) => path.startsWith(prefix)); /** Parse `git diff --numstat -z --no-renames`, keeping watched paths. A binary file counts as a full budget. */ export const parseNumstat = (numstat) => numstat .split("\0") .map(parseRecord) .filter((file) => isWatched(file.path)); const isVisualFile = (path) => VISUAL_EXTENSIONS.some((ext) => path.endsWith(ext)) && !TEST_FILE.test(path); /** Why a "No visible change" declaration does not hold for this diff; empty means it holds. */ export function noVisibleChangeFailures(files) { const failures = []; const lines = files.reduce((sum, file) => sum + file.lines, 0); if (lines >= NO_VISIBLE_CHANGE_MAX_LINES) { failures.push( `the diff changes ${lines} lines under packages/studio and packages/player; the limit is under ${NO_VISIBLE_CHANGE_MAX_LINES}`, ); } const visual = files.filter((file) => isVisualFile(file.path)); if (visual.length > 0) { failures.push( `the diff touches ${VISUAL_EXTENSIONS.join(", ")} files: ${visual.map((file) => file.path).join(", ")}`, ); } return failures; } function sectionProblem(section, name) { if (!section) return `the body has no "${name}" heading`; return hasMedia(section.text) ? null : `the "${name}" section has no image or video`; } const captureProblems = (sections) => [ sectionProblem(findSection(sections, "before"), "Before"), sectionProblem(findSection(sections, "after"), "After"), ].filter(Boolean); function noVisibleChangeVerdict(sections, files) { if (!findSection(sections, "noVisibleChange")) return { holds: false, problems: [] }; const failures = noVisibleChangeFailures(files); const problems = failures.map((failure) => `"No visible change" does not apply: ${failure}`); return { holds: failures.length === 0, problems }; } const PASS = { ok: true, problems: [] }; export function evaluate({ body, files }) { if (files.length === 0) return PASS; const sections = parseSections(body); const captures = captureProblems(sections); if (captures.length === 0) return PASS; const verdict = noVisibleChangeVerdict(sections, files); if (verdict.holds) return PASS; return { ok: false, problems: [...verdict.problems, ...captures] }; } export function attachCommand(prNumber) { return `gh pr edit ${prNumber} --attach ./before.png --attach ./after.png`; } function flag(args, name, fallback) { const at = args.indexOf(name); return at === -1 ? fallback : args[at + 1]; } function readNumstat(base, head) { try { return execFileSync("git", ["diff", "--numstat", "-z", "--no-renames", `${base}...${head}`], { encoding: "utf8", }); } catch (error) { console.error(`cannot diff ${base}...${head}: ${error.message.trim()}`); return process.exit(2); } } function printFailure(problems, prNumber) { console.error( "This PR changes packages/studio or packages/player, so its body must show the behaviour.", ); for (const problem of problems) console.error(` - ${problem}`); console.error("\nAdd '## Before' and '## After' sections, each with an image or video, then:"); console.error(` ${attachCommand(prNumber)}`); console.error("Only the PR description counts; captures posted as comments are not read."); console.error( "Edit the body text first: gh pr edit --body-file replaces the body and drops attachments.", ); console.error( `A change with no visible effect (under ${NO_VISIBLE_CHANGE_MAX_LINES} lines, no .tsx/.css/.html) may instead add a '## No visible change' section.`, ); } function main() { const args = process.argv.slice(2); const numstat = readNumstat(flag(args, "--base", "origin/main"), flag(args, "--head", "HEAD")); const body = process.env.PR_BODY ?? ""; const { ok, problems } = evaluate({ body, files: parseNumstat(numstat) }); if (ok) { console.log("packages/studio and packages/player: captures present, or nothing to show."); return; } printFailure(problems, process.env.PR_NUMBER ?? ""); process.exit(1); } if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) main();