mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-03 20:45:52 +01:00
c6f79c2093
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
191 lines
6.8 KiB
YAML
191 lines
6.8 KiB
YAML
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 = "<!-- pr-standards-check -->";
|
|
const { owner, repo } = context.repo;
|
|
const issue_number = pr.number;
|
|
|
|
const body = (pr.body || "").replace(/<!--[\s\S]*?-->/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 "## <name>" 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- ")}`
|
|
);
|