name: Pull request standards on: pull_request_target: # zizmor: ignore[dangerous-triggers] -- safe: reads PR metadata from event payload only, no PR code checkout types: - opened - edited - reopened - ready_for_review branches: - dev permissions: {} concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number }} cancel-in-progress: true jobs: check: name: Check pull request follows contribution standards runs-on: ubuntu-latest permissions: pull-requests: write # To label and comment on pull requests steps: - name: Check pull request standards uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | 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; const body = (pr.body || "").replace(//g, ""); 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- ")}` );