From 338ab8f08f496e5234b0d56f302f2415eb03030a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 23:52:12 +0000 Subject: [PATCH] feat(core): warn when a PR adds more than one new integration Count added homeassistant/components//manifest.json files, so virtual integrations (manifest only, no __init__.py) are included. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SLHDn4hLv4oEzNkgkeqecr --- .../manifests/home-assistant-core/index.ts | 2 + .../rules/single-new-integration.ts | 64 +++++++++++ .../rules/single-new-integration.test.ts | 106 ++++++++++++++++++ .../pull_request.opened.all-change-types.yaml | 4 + ...uest.opened.bugfix-single-integration.yaml | 4 + .../pull_request.opened.dependency-bump.yaml | 4 + ...equest.opened.new-integration-no-docs.yaml | 4 + ...est.opened.new-integration-transports.yaml | 4 + .../pull_request.opened.release.yaml | 4 + .../pull_request.reopened.yaml | 4 + ...ll_request.synchronize.merge-conflict.yaml | 4 + 11 files changed, 204 insertions(+) create mode 100644 src/github/manifests/home-assistant-core/rules/single-new-integration.ts create mode 100644 tests/github/manifests/home-assistant-core/rules/single-new-integration.test.ts diff --git a/src/github/manifests/home-assistant-core/index.ts b/src/github/manifests/home-assistant-core/index.ts index 0b64089..f6c776c 100644 --- a/src/github/manifests/home-assistant-core/index.ts +++ b/src/github/manifests/home-assistant-core/index.ts @@ -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}/*`; @@ -60,6 +61,7 @@ export const homeAssistantCore: RepoManifest = { qualityScale, newIntegrationValidation, singleNewPlatform, + singleNewIntegration, forbiddenFiles({ base: "dev", files: coreForbiddenFiles }), integrationTransport, mentionCodeOwners({ pathPattern: componentCodeowners, domains: itemIntegrationDomains }), diff --git a/src/github/manifests/home-assistant-core/rules/single-new-integration.ts b/src/github/manifests/home-assistant-core/rules/single-new-integration.ts new file mode 100644 index 0000000..80c795b --- /dev/null +++ b/src/github/manifests/home-assistant-core/rules/single-new-integration.ts @@ -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): Promise { + 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, +}); diff --git a/tests/github/manifests/home-assistant-core/rules/single-new-integration.test.ts b/tests/github/manifests/home-assistant-core/rules/single-new-integration.test.ts new file mode 100644 index 0000000..c9eda93 --- /dev/null +++ b/tests/github/manifests/home-assistant-core/rules/single-new-integration.test.ts @@ -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" }); + }); +}); diff --git a/tests/github/manifests/snapshots/home-assistant-core/pull_request.opened.all-change-types.yaml b/tests/github/manifests/snapshots/home-assistant-core/pull_request.opened.all-change-types.yaml index 25310cc..a460b79 100644 --- a/tests/github/manifests/snapshots/home-assistant-core/pull_request.opened.all-change-types.yaml +++ b/tests/github/manifests/snapshots/home-assistant-core/pull_request.opened.all-change-types.yaml @@ -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 diff --git a/tests/github/manifests/snapshots/home-assistant-core/pull_request.opened.bugfix-single-integration.yaml b/tests/github/manifests/snapshots/home-assistant-core/pull_request.opened.bugfix-single-integration.yaml index 5014ace..09978f5 100644 --- a/tests/github/manifests/snapshots/home-assistant-core/pull_request.opened.bugfix-single-integration.yaml +++ b/tests/github/manifests/snapshots/home-assistant-core/pull_request.opened.bugfix-single-integration.yaml @@ -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 diff --git a/tests/github/manifests/snapshots/home-assistant-core/pull_request.opened.dependency-bump.yaml b/tests/github/manifests/snapshots/home-assistant-core/pull_request.opened.dependency-bump.yaml index beeb685..77636f6 100644 --- a/tests/github/manifests/snapshots/home-assistant-core/pull_request.opened.dependency-bump.yaml +++ b/tests/github/manifests/snapshots/home-assistant-core/pull_request.opened.dependency-bump.yaml @@ -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 diff --git a/tests/github/manifests/snapshots/home-assistant-core/pull_request.opened.new-integration-no-docs.yaml b/tests/github/manifests/snapshots/home-assistant-core/pull_request.opened.new-integration-no-docs.yaml index 5695cf4..eb3d9aa 100644 --- a/tests/github/manifests/snapshots/home-assistant-core/pull_request.opened.new-integration-no-docs.yaml +++ b/tests/github/manifests/snapshots/home-assistant-core/pull_request.opened.new-integration-no-docs.yaml @@ -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 diff --git a/tests/github/manifests/snapshots/home-assistant-core/pull_request.opened.new-integration-transports.yaml b/tests/github/manifests/snapshots/home-assistant-core/pull_request.opened.new-integration-transports.yaml index 706da27..ba53c3f 100644 --- a/tests/github/manifests/snapshots/home-assistant-core/pull_request.opened.new-integration-transports.yaml +++ b/tests/github/manifests/snapshots/home-assistant-core/pull_request.opened.new-integration-transports.yaml @@ -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 diff --git a/tests/github/manifests/snapshots/home-assistant-core/pull_request.opened.release.yaml b/tests/github/manifests/snapshots/home-assistant-core/pull_request.opened.release.yaml index 5f26a1a..3b41443 100644 --- a/tests/github/manifests/snapshots/home-assistant-core/pull_request.opened.release.yaml +++ b/tests/github/manifests/snapshots/home-assistant-core/pull_request.opened.release.yaml @@ -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 diff --git a/tests/github/manifests/snapshots/home-assistant-core/pull_request.reopened.yaml b/tests/github/manifests/snapshots/home-assistant-core/pull_request.reopened.yaml index 11fc39d..0c3d021 100644 --- a/tests/github/manifests/snapshots/home-assistant-core/pull_request.reopened.yaml +++ b/tests/github/manifests/snapshots/home-assistant-core/pull_request.reopened.yaml @@ -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 diff --git a/tests/github/manifests/snapshots/home-assistant-core/pull_request.synchronize.merge-conflict.yaml b/tests/github/manifests/snapshots/home-assistant-core/pull_request.synchronize.merge-conflict.yaml index c0baaa3..5834373 100644 --- a/tests/github/manifests/snapshots/home-assistant-core/pull_request.synchronize.merge-conflict.yaml +++ b/tests/github/manifests/snapshots/home-assistant-core/pull_request.synchronize.merge-conflict.yaml @@ -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