#!/usr/bin/env node // Checks that a pull request follows the contribution standards: it must use the // PR template, tick exactly one "Type of change" option, and describe the change. // Labels and comments the PR when it does not, and fails the check so it blocks // merging. Invoked from the `check` job in .github/workflows/pull-request-standards.yaml // via actions/github-script: // // const { default: checkStandards } = // await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-pull-request-standards.mjs`); // await checkStandards({ github, context, core }); export default async function checkPullRequestStandards({ github, context, core, }) { const pr = context.payload.pull_request; // Exempt bots (Copilot agent, dependabot), drafts, and maintainers. if (pr.user.type === "Bot") { core.info(`Skipping bot author: ${pr.user.login}`); return; } if (pr.draft) { core.info("Skipping draft pull request"); return; } try { await github.rest.orgs.checkMembershipForUser({ org: "home-assistant", username: pr.user.login, }); core.info(`Skipping organization member: ${pr.user.login}`); return; } catch (_error) { core.info( `${pr.user.login} is not an organization member, checking standards` ); } const label = "Needs Template"; const marker = ""; const { owner, repo } = context.repo; const issue_number = pr.number; let body = pr.body || ""; let previous; do { previous = body; body = body.replace(//g, ""); } while (body !== previous); const normalized = body.toLowerCase(); // Ignore 404s from mutations that race manual edits or cancelled runs. const ignoreMissing = async (fn) => { try { await fn(); } catch (error) { if (error.status === 404) { core.info("Target already removed, nothing to do"); return; } throw error; } }; // Hide/restore our comment via GraphQL (REST cannot minimize). const setMinimized = async (subjectId, minimized) => { const mutation = minimized ? `mutation($id: ID!) { minimizeComment(input: { subjectId: $id, classifier: RESOLVED }) { clientMutationId } }` : `mutation($id: ID!) { unminimizeComment(input: { subjectId: $id }) { clientMutationId } }`; try { await github.graphql(mutation, { id: subjectId }); } catch (error) { core.info( `Could not ${minimized ? "minimize" : "restore"} comment: ${error.message}` ); } }; // Content of a "## " section, or null when the heading is absent. const section = (name) => { const match = body.match( new RegExp(`##\\s${name}([\\s\\S]*?)(?=\\n##\\s|$)`, "i") ); return match ? match[1] : null; }; const problems = []; const requiredHeadings = [ "## proposed change", "## type of change", "## checklist", ]; if (requiredHeadings.some((h) => !normalized.includes(h))) { problems.push( "Use the pull request template without removing its sections." ); } const typeOfChange = section("type of change"); if (typeOfChange !== null) { const ticked = (typeOfChange.match(/-\s*\[[xX]\]/g) || []).length; if (ticked !== 1) { problems.push('Select exactly one option under "Type of change".'); } } const proposedChange = section("proposed change"); if (proposedChange !== null && proposedChange.trim().length === 0) { problems.push('Describe your changes under "Proposed change".'); } const isValid = problems.length === 0; const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number, per_page: 100, }); const existing = comments.find((c) => c.body.includes(marker)); const hasLabel = pr.labels.some((l) => l.name === label); if (isValid) { core.info("Pull request standards met"); if (hasLabel) { await ignoreMissing(() => github.rest.issues.removeLabel({ owner, repo, issue_number, name: label, }) ); } if (existing) { await setMinimized(existing.node_id, true); } return; } core.info(`Pull request standards not met:\n- ${problems.join("\n- ")}`); if (!hasLabel) { await github.rest.issues.addLabels({ owner, repo, issue_number, labels: [label], }); } const message = `${marker}\n` + `Hey @${pr.user.login}!\n\n` + `Thank you for your contribution! To help reviewers, please update ` + `this pull request to follow our pull request standards:\n\n` + problems.map((p) => `- ${p}`).join("\n") + `\n\n` + `Please complete the ` + `[PR template](https://github.com/home-assistant/frontend/blob/dev/.github/PULL_REQUEST_TEMPLATE.md?plain=1) ` + `and see the [developer docs](https://developers.home-assistant.io/docs/review-process) ` + `for more on creating a great pull request (see point 6).`; if (existing) { await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body: message, }); await setMinimized(existing.node_id, false); } else { await github.rest.issues.createComment({ owner, repo, issue_number, body: message, }); } // Fail this check so it can block the PR from being merged core.setFailed(`Pull request standards not met:\n- ${problems.join("\n- ")}`); }