Skip to content
Open
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
2 changes: 2 additions & 0 deletions src/github/manifests/home-assistant-core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { issueLinks } from "./rules/issue-links.js";
import { newIntegrationValidation } from "./rules/new-integration-validation.js";
import { platinumApproval } from "./rules/platinum-approval.js";
import { qualityScale } from "./rules/quality-scale.js";
import { singleNewIntegration } from "./rules/single-new-integration.js";
import { singleNewPlatform } from "./rules/single-new-platform.js";

const componentCodeowners = (domain: string) => `homeassistant/components/${domain}/*`;
Expand All @@ -60,6 +61,7 @@ export const homeAssistantCore: RepoManifest = {
qualityScale,
newIntegrationValidation,
singleNewPlatform,
singleNewIntegration,
forbiddenFiles({ base: "dev", files: coreForbiddenFiles }),
integrationTransport,
mentionCodeOwners({ pathPattern: componentCodeowners, domains: itemIntegrationDomains }),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { EventType } from "../../../engine/event.js";
import type { RuleContext } from "../../../engine/model/rule-context.js";
import { type CheckOutcome, check } from "../../../engine/rule.js";
import { parseFiles } from "../helpers/parse-path.js";
import { isReleasePR } from "../helpers/release-pr.js";

const REVIEW_PROCESS_URL =
"https://developers.home-assistant.io/docs/review-process/#home-assistant-core";

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

async function evaluate(ctx: RuleContext<HandledEvent>): Promise<CheckOutcome | undefined> {
if (await isReleasePR(ctx.target)) {
return { status: "skip", message: "Not checked on release PRs." };
}

const parsed = await parseFiles(ctx, await ctx.target.files());

// Every integration, virtual ones included, has a manifest.json, while a
// virtual integration has no __init__.py: count added manifests.
const added = [
...new Set(
parsed
.filter(
(p) =>
p.type === "component" &&
p.status === "added" &&
p.path === `homeassistant/components/${p.component}/manifest.json`,
)
.map((p) => `\`${p.component}\``),
),
].sort();

if (added.length === 0) {
return { status: "skip", message: "No new integrations added." };
}
if (added.length === 1) {
return { status: "pass", message: `Adds a single new integration: ${added[0]}.` };
}
return {
status: "warn",
message:
`This PR adds ${added.length} new integrations (${added.join(", ")}). ` +
"Add one integration per PR, virtual integrations included, and split the " +
`others into separate PRs. See the [review process](${REVIEW_PROCESS_URL}).`,
};
}

export const singleNewIntegration = check({
id: "single-new-integration",
title: "Single new integration",
description: "Warns on PRs that add more than one new integration, virtual ones included",
events: [
EventType.PULL_REQUEST_OPENED,
EventType.PULL_REQUEST_REOPENED,
EventType.PULL_REQUEST_SYNCHRONIZE,
EventType.ON_DEMAND,
],
evaluate,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { describe, expect, it } from "vitest";
import { EventType } from "../../../../../src/github/engine/event.js";
import { singleNewIntegration } from "../../../../../src/github/manifests/home-assistant-core/rules/single-new-integration.js";
import { createMockContext, mockPRFiles, runRule } from "../../../helpers/mock-context.js";

function makeFile(filename: string, status = "added") {
return {
filename,
status,
additions: 10,
deletions: 0,
changes: 10,
sha: "abc",
blob_url: "",
raw_url: "",
contents_url: "",
};
}

function prContext() {
return createMockContext({
eventType: EventType.ON_DEMAND,
payload: { pull_request: { head: { sha: "abc123" }, labels: [] } },
});
}

describe("single-new-integration", () => {
it("skips when no integration is added", async () => {
const context = prContext();
mockPRFiles(context, [
makeFile("homeassistant/components/hue/manifest.json", "modified"),
makeFile("homeassistant/components/hue/sensor.py"),
]);

const result = await runRule(singleNewIntegration, context);
expect(result?.section).toMatchObject({ id: "single-new-integration", status: "skip" });
});

it("passes when a single integration is added", async () => {
const context = prContext();
mockPRFiles(context, [
makeFile("homeassistant/components/awesome/__init__.py"),
makeFile("homeassistant/components/awesome/manifest.json"),
makeFile("tests/components/awesome/__init__.py"),
]);

const result = await runRule(singleNewIntegration, context);
expect(result?.section).toMatchObject({ id: "single-new-integration", status: "pass" });
expect(result?.section?.message).toContain("`awesome`");
});

it("warns when multiple virtual integrations are added", async () => {
const context = prContext();
mockPRFiles(context, [
makeFile("homeassistant/components/brand_a/manifest.json"),
makeFile("homeassistant/components/brand_b/manifest.json"),
makeFile("homeassistant/generated/integrations.json", "modified"),
]);

const result = await runRule(singleNewIntegration, context);
expect(result?.section?.status).toBe("warn");
expect(result?.section?.message).toContain("2 new integrations");
expect(result?.section?.message).toContain("`brand_a`");
expect(result?.section?.message).toContain("`brand_b`");
});

it("warns when a regular and a virtual integration are added together", async () => {
const context = prContext();
mockPRFiles(context, [
makeFile("homeassistant/components/awesome/__init__.py"),
makeFile("homeassistant/components/awesome/manifest.json"),
makeFile("homeassistant/components/awesome_brand/manifest.json"),
]);

const result = await runRule(singleNewIntegration, context);
expect(result?.section?.status).toBe("warn");
});

it("ignores manifest fixtures in tests", async () => {
const context = prContext();
mockPRFiles(context, [
makeFile("homeassistant/components/awesome/manifest.json"),
makeFile("tests/components/awesome/fixtures/manifest.json"),
makeFile("tests/testing_config/custom_components/test/manifest.json"),
]);

const result = await runRule(singleNewIntegration, context);
expect(result?.section?.status).toBe("pass");
});

it("skips release PRs", async () => {
const context = createMockContext({
eventType: EventType.ON_DEMAND,
payload: {
pull_request: { head: { sha: "abc123", ref: "rc" }, base: { ref: "master" }, labels: [] },
},
});
mockPRFiles(context, [
makeFile("homeassistant/components/brand_a/manifest.json"),
makeFile("homeassistant/components/brand_b/manifest.json"),
]);

const result = await runRule(singleNewIntegration, context);
expect(result?.section).toMatchObject({ id: "single-new-integration", status: "skip" });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ statuses:
title: Single new platform
status: skip
message: New-integration PRs are checked by new integration validation.
- id: single-new-integration
title: Single new integration
status: pass
message: "Adds a single new integration: `awesome`."
- id: forbidden-files
title: Forbidden files
status: pass
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ statuses:
title: Single new platform
status: skip
message: No new platforms added.
- id: single-new-integration
title: Single new integration
status: skip
message: No new integrations added.
- id: forbidden-files
title: Forbidden files
status: pass
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ statuses:
title: Single new platform
status: skip
message: No new platforms added.
- id: single-new-integration
title: Single new integration
status: skip
message: No new integrations added.
- id: forbidden-files
title: Forbidden files
status: pass
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ statuses:
title: Single new platform
status: skip
message: New-integration PRs are checked by new integration validation.
- id: single-new-integration
title: Single new integration
status: pass
message: "Adds a single new integration: `awesome`."
- id: forbidden-files
title: Forbidden files
status: pass
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ statuses:
title: Single new platform
status: skip
message: New-integration PRs are checked by new integration validation.
- id: single-new-integration
title: Single new integration
status: pass
message: "Adds a single new integration: `awesome`."
- id: forbidden-files
title: Forbidden files
status: pass
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ statuses:
title: Single new platform
status: skip
message: Not checked on release PRs.
- id: single-new-integration
title: Single new integration
status: skip
message: Not checked on release PRs.
- id: forbidden-files
title: Forbidden files
status: skip
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ statuses:
title: Single new platform
status: skip
message: No new platforms added.
- id: single-new-integration
title: Single new integration
status: skip
message: No new integrations added.
- id: forbidden-files
title: Forbidden files
status: pass
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ statuses:
title: Single new platform
status: skip
message: No new platforms added.
- id: single-new-integration
title: Single new integration
status: skip
message: No new integrations added.
- id: forbidden-files
title: Forbidden files
status: pass
Expand Down
Loading