Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/github/manifests/home-assistant-core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { mentionCodeOwners } from "../../rules/code-owner-mention.js";
import { forbiddenFiles } from "../../rules/forbidden-files.js";
import { mergeConflict } from "../../rules/merge-conflict.js";
import { mergeTarget } from "../../rules/merge-target.js";
import { prTemplate } from "../../rules/pr-template.js";
import { setIntegration } from "../../rules/set-integration.js";
import {
HomeAssistantRepository,
Expand All @@ -23,6 +24,7 @@ import {
import type { RepoManifest } from "../types.js";
import { coreForbiddenFiles } from "./forbidden-files.js";
import { itemIntegrationDomains } from "./helpers/integration-domains.js";
import { isReleasePR } from "./helpers/release-pr.js";
import { coreIssueContext } from "./issue-context.js";
import { changeType } from "./rules/change-type.js";
import { dependencyBump } from "./rules/dependency-bump.js";
Expand Down Expand Up @@ -81,6 +83,11 @@ export const homeAssistantCore: RepoManifest = {
platinumApproval,

// Classification & process
prTemplate({
sections: ["Proposed change", "Type of change", "Checklist"],
requireContent: ["Proposed change"],
skip: async (pr) => ((await isReleasePR(pr)) ? "Not checked on release PRs." : undefined),
}),
changeType,
fileShape,
dependencyBump,
Expand Down
121 changes: 121 additions & 0 deletions src/github/rules/pr-template.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { EventType } from "../engine/event.js";
import type { PullRequest } from "../engine/model/pull-request.js";
import type { RuleContext } from "../engine/model/rule-context.js";
import { type CheckOutcome, check } from "../engine/rule.js";
import type { Rule } from "../engine/types.js";

type HandledEvent =
| EventType.PULL_REQUEST_OPENED
| EventType.PULL_REQUEST_REOPENED
| EventType.PULL_REQUEST_EDITED
| EventType.PULL_REQUEST_SYNCHRONIZE
| EventType.ON_DEMAND;

const COMMENT_PATTERN = /<!--[\s\S]*?-->/g;
const HEADING_PATTERN = /^(#{1,6})\s+(.+?)\s*#*\s*$/;
const FENCE_PATTERN = /^\s*(```|~~~)/;

/** Case, surrounding whitespace and a trailing colon don't make a heading different. */
function normalize(heading: string): string {
return heading.trim().replace(/:$/, "").trim().toLowerCase();
}

type Line = { text: string; heading?: { level: number; name: string } };

/**
* Splits a PR body into its headed sections, keyed by normalized heading.
* Template comments are stripped first: they hold the template's own
* instructions, so a section with only its comment left is still empty.
* A section runs until the next heading of the same or a higher level, so
* text under a subheading (`### Details` inside `## Proposed change`) still
* counts as the parent's content; the subheading lines themselves don't.
* Lines inside fenced code blocks are never headings.
*/
export function parseSections(body: string | null): Map<string, string> {
const sections = new Map<string, string>();
if (!body) return sections;

let inFence = false;
const lines: Line[] = body
.replace(COMMENT_PATTERN, "")
.split(/\r?\n/)
.map((text) => {
if (FENCE_PATTERN.test(text)) inFence = !inFence;
const match = inFence ? null : HEADING_PATTERN.exec(text);
return match
? { text, heading: { level: match[1].length, name: normalize(match[2]) } }
: { text };
});

lines.forEach(({ heading }, start) => {
if (!heading || sections.has(heading.name)) return;
const content: string[] = [];
for (const line of lines.slice(start + 1)) {
if (line.heading && line.heading.level <= heading.level) break;
if (!line.heading) content.push(line.text);
}
sections.set(heading.name, content.join("\n").trim());
});
return sections;
}

export function prTemplate(config: {
/** Headings the PR description must keep, as written in the repo's template. */
sections: string[];
/** Headings (a subset of `sections`) that must also hold the contributor's own text. */
requireContent?: string[];
/** Repo policy for PRs that don't use the template; return the skip reason. */
skip?: (pr: PullRequest) => Promise<string | undefined>;
}): Rule {
async function evaluate(ctx: RuleContext<HandledEvent>): Promise<CheckOutcome> {
// allowBots stops bot-sent events; this also covers a human editing a bot's PR.
if ((await ctx.target.authorLogin()).endsWith("[bot]")) {
return { status: "skip", message: "Not checked on PRs opened by bots." };
}
const reason = await config.skip?.(ctx.target);
if (reason) return { status: "skip", message: reason };

const sections = parseSections(await ctx.target.body());
const issues: string[] = [];
for (const heading of config.sections) {
const content = sections.get(normalize(heading));
if (content === undefined) {
issues.push(`The **${heading}** section is missing.`);
} else if (config.requireContent?.includes(heading) && content === "") {
issues.push(`The **${heading}** section is empty.`);
}
}

if (issues.length === 0) {
return { status: "pass", message: "The PR description follows the template." };
}
return {
status: "fail",
message: [
"Please fill in the PR description using the template:",
...issues.map((i) => `- ${i}`),
].join("\n"),
};
}

return check({
id: "pr-template",
title: "PR description",
description:
`Requires the PR description to keep the template's ${config.sections
.map((s) => `**${s}**`)
.join(", ")} sections` +
(config.requireContent?.length
? `, with ${config.requireContent.map((s) => `**${s}**`).join(", ")} filled in.`
: "."),
allowBots: false,
events: [
EventType.PULL_REQUEST_OPENED,
EventType.PULL_REQUEST_REOPENED,
EventType.PULL_REQUEST_EDITED,
EventType.PULL_REQUEST_SYNCHRONIZE,
EventType.ON_DEMAND,
],
evaluate,
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ statuses:
title: Code owner approval
status: pending
message: Platinum `hue` — needs approval from a code owner before merging.
- id: pr-template
title: PR description
status: pass
message: The PR description follows the template.
- id: type-of-change
title: Type of change
status: pass
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ statuses:
title: Code owner approval
status: skip
message: Touches no platinum integration — no code-owner approval required.
- id: pr-template
title: PR description
status: pass
message: The PR description follows the template.
- id: type-of-change
title: Type of change
status: pass
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ statuses:
title: Code owner approval
status: pending
message: Platinum `hue` — needs approval from a code owner before merging.
- id: pr-template
title: PR description
status: pass
message: The PR description follows the template.
- id: type-of-change
title: Type of change
status: pass
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ statuses:
title: Code owner approval
status: skip
message: Touches no platinum integration — no code-owner approval required.
- id: pr-template
title: PR description
status: pass
message: The PR description follows the template.
- id: type-of-change
title: Type of change
status: pass
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ statuses:
title: Code owner approval
status: skip
message: Touches no platinum integration — no code-owner approval required.
- id: pr-template
title: PR description
status: pass
message: The PR description follows the template.
- id: type-of-change
title: Type of change
status: pass
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ statuses:
title: Code owner approval
status: skip
message: Touches no platinum integration — no code-owner approval required.
- id: pr-template
title: PR description
status: pass
message: The PR description follows the template.
- id: type-of-change
title: Type of change
status: pass
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ statuses:
title: Code owner approval
status: skip
message: Touches no platinum integration — no code-owner approval required.
- id: pr-template
title: PR description
status: skip
message: Not checked on release PRs.
- id: type-of-change
title: Type of change
status: skip
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ statuses:
title: Code owner approval
status: pending
message: Platinum `hue` — needs approval from a code owner before merging.
- id: pr-template
title: PR description
status: pass
message: The PR description follows the template.
effects:
- type: addLabels
labels:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ statuses:
title: Code owner approval
status: pending
message: Platinum `hue` — needs approval from a code owner before merging.
- id: pr-template
title: PR description
status: pass
message: The PR description follows the template.
- id: type-of-change
title: Type of change
status: pass
Expand Down
137 changes: 137 additions & 0 deletions tests/github/rules/pr-template.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { describe, expect, it } from "vitest";
import { EventType } from "../../../src/github/engine/event.js";
import { parseSections, prTemplate } from "../../../src/github/rules/pr-template.js";
import { createMockContext, runRule } from "../helpers/mock-context.js";
import { loadPRTemplate, renderPRTemplate } from "../manifests/pr-template.js";

const CORE_TEMPLATE = loadPRTemplate("home-assistant-core");

const rule = prTemplate({
sections: ["Proposed change", "Type of change", "Checklist"],
requireContent: ["Proposed change"],
skip: async (pr) =>
(await pr.baseRef()) === "master" ? "Not checked on release PRs." : undefined,
});

function filledCoreBody(): string {
return renderPRTemplate(CORE_TEMPLATE, {
check: ["Bugfix (non-breaking change which fixes an issue)"],
sections: { "Proposed change": "Fix the thing that was broken." },
});
}

function run(pull_request: Record<string, unknown>) {
const context = createMockContext({
eventType: EventType.PULL_REQUEST_OPENED,
payload: { pull_request },
});
return runRule(rule, context);
}

describe("pr-template", () => {
it("passes a filled-in core template", async () => {
const result = await run({ body: filledCoreBody() });
expect(result?.section).toMatchObject({ id: "pr-template", status: "pass" });
});

it("fails and names a deleted section", async () => {
const body = filledCoreBody().replace("## Checklist", "");
const result = await run({ body });
expect(result?.section?.status).toBe("fail");
expect(result?.section?.message).toContain("The **Checklist** section is missing.");
expect(result?.section?.message).not.toContain("Type of change");
});

it("fails when Proposed change only holds the template comment", async () => {
const result = await run({ body: CORE_TEMPLATE });
expect(result?.section?.status).toBe("fail");
expect(result?.section?.message).toContain("The **Proposed change** section is empty.");
});

it("passes when Proposed change is written under a subheading", async () => {
const body = filledCoreBody().replace(
"Fix the thing that was broken.",
"### Details\nFix the thing that was broken.",
);
const result = await run({ body });
expect(result?.section?.status).toBe("pass");
});

it("lists every problem at once", async () => {
const result = await run({ body: "Just a one-line description." });
expect(result?.section?.status).toBe("fail");
for (const heading of ["Proposed change", "Type of change", "Checklist"]) {
expect(result?.section?.message).toContain(`The **${heading}** section is missing.`);
}
});

it("fails on an empty body", async () => {
const result = await run({ body: null });
expect(result?.section?.status).toBe("fail");
});

it("does not require content in sections outside requireContent", async () => {
const body = renderPRTemplate(CORE_TEMPLATE, {
sections: { "Proposed change": "Something useful." },
});
const result = await run({ body });
expect(result?.section?.status).toBe("pass");
});

it("skips release PRs through the repo's skip hook", async () => {
const result = await run({ body: "Release notes", base: { ref: "master" } });
expect(result?.section).toMatchObject({
status: "skip",
message: "Not checked on release PRs.",
});
});

it("skips PRs authored by bots", async () => {
const result = await run({ body: "Bumps foo from 1 to 2", user: { login: "dependabot[bot]" } });
expect(result?.section?.status).toBe("skip");
});

it("does not run on bot-sent events", () => {
expect(rule.allowBots).toBe(false);
});
});

describe("parseSections", () => {
it("matches headings regardless of case, level and trailing colon", () => {
const sections = parseSections("# PROPOSED CHANGE:\nhello\n# checklist\n- [x] done");
expect(sections.get("proposed change")).toBe("hello");
expect(sections.get("checklist")).toBe("- [x] done");
expect(parseSections("### Checklist").has("checklist")).toBe(true);
});

it("counts text under a subheading as the parent section's content", () => {
const sections = parseSections(
"## Proposed change\n### Details\nthe change\n## Type of change\n- [x] Bugfix",
);
expect(sections.get("proposed change")).toBe("the change");
expect(sections.get("details")).toBe("the change");
expect(sections.get("type of change")).toBe("- [x] Bugfix");
});

it("does not count a bare subheading as content", () => {
const sections = parseSections("## Proposed change\n### Details\n\n## Checklist");
expect(sections.get("proposed change")).toBe("");
});

it("ignores headings inside template comments", () => {
const sections = parseSections("<!--\n## Hidden\n-->\n## Shown\ntext");
expect(sections.has("hidden")).toBe(false);
expect(sections.get("shown")).toBe("text");
});

it("ignores headings inside fenced code blocks", () => {
const sections = parseSections("## Proposed change\n```yaml\n# Example config\n```");
expect(sections.has("example config")).toBe(false);
expect(sections.get("proposed change")).toContain("# Example config");
});

it("keeps the first section when a heading repeats", () => {
const sections = parseSections("## Checklist\nfirst\n## Checklist\nsecond");
expect(sections.get("checklist")).toBe("first");
});
});