From ea7278bbe9371affee090a8435c830f8551b959c Mon Sep 17 00:00:00 2001 From: Ariel Ebersberger Date: Wed, 11 Nov 2026 20:36:38 +0100 Subject: [PATCH] Count a parent PR named anywhere for io's merge target --- .../rules/docs-target-branch.ts | 33 +++++++++++++++++-- .../rules/docs-target-branch.test.ts | 15 ++++++++- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/github/manifests/home-assistant-io/rules/docs-target-branch.ts b/src/github/manifests/home-assistant-io/rules/docs-target-branch.ts index f8316db..77bff3c 100644 --- a/src/github/manifests/home-assistant-io/rules/docs-target-branch.ts +++ b/src/github/manifests/home-assistant-io/rules/docs-target-branch.ts @@ -1,3 +1,4 @@ +import { extractAllLinks } from "../../../../util/pr-body.js"; import { EventType } from "../../../engine/event.js"; import type { RuleContext } from "../../../engine/model/rule-context.js"; import { type CheckOutcome, check } from "../../../engine/rule.js"; @@ -12,15 +13,41 @@ type HandledEvent = | EventType.PULL_REQUEST_EDITED | EventType.ON_DEMAND; +// Each named PR costs a read; a description names its parent near the top. +const MAX_NAMED = 3; + +/** + * Whether the PR documents a code PR: one listed on the template line, linked + * back or not, or one named anywhere else in the description. An issue + * doesn't count. + */ +async function documentsCodePR(ctx: RuleContext): Promise { + if ((await linkedPRs(ctx, homeAssistantPrLinks)).some((l) => l.relation === "parent")) + return true; + + const self = { owner: ctx.repo.owner, repo: ctx.repo.name }; + const named = extractAllLinks(await ctx.target.body()) + .filter((ref) => homeAssistantPrLinks.relationOf(self, ref) === "parent") + .slice(0, MAX_NAMED); + for (const ref of named) { + try { + await ctx.pullRequest(ref).baseRepo(); + return true; + } catch (err) { + if ((err as { status?: number }).status !== 404) throw err; + } + } + return false; +} + async function evaluate(ctx: RuleContext): Promise { const baseRef = await ctx.target.baseRef(); if (EXEMPT_BRANCHES.has(baseRef)) { return { status: "skip", message: `PRs targeting \`${baseRef}\` are exempt.` }; } - // Documenting a code PR, listed back or not, is documenting an upcoming release. - const linked = await linkedPRs(ctx, homeAssistantPrLinks); - const expected = linked.some((l) => l.relation === "parent") ? "next" : "current"; + // Documenting a code PR is documenting an upcoming release. + const expected = (await documentsCodePR(ctx)) ? "next" : "current"; if (baseRef === expected) { return { status: "pass", message: `This PR targets \`${expected}\`.` }; } diff --git a/tests/github/manifests/home-assistant-io/rules/docs-target-branch.test.ts b/tests/github/manifests/home-assistant-io/rules/docs-target-branch.test.ts index baf2f94..5a2b423 100644 --- a/tests/github/manifests/home-assistant-io/rules/docs-target-branch.test.ts +++ b/tests/github/manifests/home-assistant-io/rules/docs-target-branch.test.ts @@ -72,12 +72,25 @@ describe("docs-target-branch", () => { `${LINE} home-assistant/brands#1 home-assistant/home-assistant.io#2`, ["brands#1", "home-assistant.io#2"], ], - ["a code PR outside the line", "Parent: home-assistant/core#12345", ["core#12345"]], + ["a code issue named in the description", "Fixes the issue in home-assistant/core#7", []], + ["a PR of a repo io doesn't follow", "Parent: home-assistant/android#3", ["android#3"]], ])("has no parent in %s", async (_, body, prs) => { const result = await runRule(docsTargetBranch, docsContext("current", body, prs)); expect(result?.section?.status).toBe("pass"); }); + it.each([ + ["in prose", "This documents home-assistant/core#12345."], + [ + "on a line without a space after the colon", + "- Link to parent pull request in the codebase:https://github.com/home-assistant/core/pull/12345", + ], + ["on an indented line", " - Parent: https://github.com/home-assistant/core/pull/12345"], + ])("expects next for a code PR named %s", async (_, body) => { + const result = await runRule(docsTargetBranch, docsContext("next", body, ["core#12345"])); + expect(result?.section?.status).toBe("pass"); + }); + it("exempts the `new` branch", async () => { const result = await runRule(docsTargetBranch, docsContext("new")); expect(result?.section?.status).toBe("skip");