name: PR Template Check on: pull_request: types: [opened, edited, synchronize, reopened] permissions: pull-requests: read jobs: validate_pr_body: runs-on: ubuntu-latest steps: - name: Validate required PR sections uses: actions/github-script@v7 with: script: | const pr = context.payload.pull_request; const body = (pr.body || "").trim(); function sectionContent(title) { const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const re = new RegExp(`^##\\s+${escaped}\\s*$`, "m"); const match = body.match(re); if (!match) return null; const start = match.index + match[0].length; const rest = body.slice(start); const next = rest.search(/^##\s+/m); return (next === -1 ? rest : rest.slice(0, next)).trim(); } function cleanedContent(title) { const content = sectionContent(title); if (content === null) return null; return content .replace(//g, "") .replace(/`/g, "") .replace(/\s+/g, " ") .trim(); } function checkedLines(content) { return (content || "").match(/^\s*-\s+\[[xX]\]\s+.+$/gm) || []; } function uncheckedLines(content) { return (content || "").match(/^\s*-\s+\[\s\]\s+.+$/gm) || []; } function hasIssueReference(content) { return /(^|\s)(#\d+|https:\/\/github\.com\/\S+\/issues\/\d+)/i.test(content || ""); } const required = [ "Summary", "PR type", "Why", "Issue or approval", "UI / behavior impact", "Policy check", "Scope boundaries", "Testing", "Screenshots / Video", "Breaking changes", "Linked issues", ]; const missing = []; const empty = []; const failedRules = []; for (const name of required) { const content = sectionContent(name); if (content === null) { missing.push(name); continue; } const cleaned = cleanedContent(name); const normalized = cleaned.toLowerCase(); const allowsNone = name === "Breaking changes" || name === "Screenshots / Video"; if ( cleaned.length < 4 || (!allowsNone && ["none", "n/a", "na", "not applicable"].includes(normalized)) || normalized.includes("what changed in this pr") || normalized.includes("why this change is needed") || normalized.includes("what you tested") || normalized.includes("example: fixes #123") ) { empty.push(name); } } const prTypeContent = sectionContent("PR type") || ""; const prTypeChecked = checkedLines(prTypeContent); if (sectionContent("PR type") !== null && prTypeChecked.length !== 1) { failedRules.push("Check exactly one PR type."); } const impactContent = sectionContent("UI / behavior impact") || ""; const impactChecked = checkedLines(impactContent); if (sectionContent("UI / behavior impact") !== null && impactChecked.length === 0) { failedRules.push("Check at least one UI / behavior impact box."); } const policyContent = sectionContent("Policy check") || ""; const policyChecked = checkedLines(policyContent); const policyUnchecked = uncheckedLines(policyContent); if (sectionContent("Policy check") !== null && policyChecked.length === 0) { failedRules.push("Policy check must include checked boxes."); } if (policyUnchecked.length) { failedRules.push("Every Policy check box must be checked."); } const checkedTypeText = prTypeChecked.join(" "); const issueRequired = /bug fix|ui glitch|behavior bug|approved larger|approved directional/i.test(checkedTypeText); const issueText = [ cleanedContent("Issue or approval") || "", cleanedContent("Linked issues") || "", ].join(" "); if (issueRequired && !hasIssueReference(issueText)) { failedRules.push("Bug fixes, UI glitch fixes, behavior fixes, and approved changes must link an issue or approved request."); } const uiChanged = checkedLines(impactContent).some((line) => /UI changed only to fix a documented glitch\/bug|UI change has explicit maintainer approval/i.test(line) ); const screenshotText = (cleanedContent("Screenshots / Video") || "").toLowerCase(); if (uiChanged && ["none", "n/a", "na", "not a ui change", "not applicable"].includes(screenshotText)) { failedRules.push("UI changes must include before/after screenshots or video."); } if (missing.length || empty.length || failedRules.length) { const lines = [ "PR description is missing required detail.", "", ]; if (missing.length) lines.push(`Missing sections: ${missing.join(", ")}`); if (empty.length) lines.push(`Incomplete sections: ${empty.join(", ")}`); if (failedRules.length) lines.push(`Failed policy rules: ${failedRules.join(" ")}`); lines.push(""); lines.push("Please complete the PR template and make sure the PR fits CONTRIBUTING.md before review."); core.setFailed(lines.join("\n")); } else { core.info("PR template check passed."); }