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
1 change: 1 addition & 0 deletions src/github/engine/status/templates/dashboard-issue.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Before we dive in, please make sure this isn't a duplicate by searching through
**Reporting guidance**
{{#paragraphs}}
{{.}}

{{/paragraphs}}

{{/blocks.reporting-guidance}}
Expand Down
74 changes: 74 additions & 0 deletions src/github/helpers/dev-file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { log } from "../../log.js";
import { fetchWithTimeout } from "../../util/fetch.js";

// Long: these files change only by PR, and every reader re-evaluates on later
// events, so a stale read corrects itself.
const HIT_TTL_MS = 6 * 60 * 60 * 1000;

// Short: a missing or unusable file may be fixed on dev any minute, and must
// not stay invisible for six hours.
const MISS_TTL_MS = 5 * 60 * 1000;

// Core's domain grammar. Callers pass user input (command arguments, link
// segments, directory names, labels), which otherwise ends up in the URL.
const DOMAIN = /^[a-z0-9_]+$/;

const resets: (() => void)[] = [];

/**
* A cached reader for one file in every integration directory on core's dev
* branch, e.g. `manifest.json`. `read` turns a successful response into the
* value (undefined when it is not usable). Cached across deliveries, one
* entry per domain; concurrent readers share one fetch. Only a 404 counts as
* a miss: any other failure keeps the last good value and is not cached.
* Call once per file, at module scope.
*/
export function devComponentFile<T>(
file: string,
read: (res: Response, domain: string) => Promise<T | undefined>,
): (domain: string) => Promise<T | undefined> {
const byDomain = new Map<string, { value?: T; fetchedAt: number }>();
const inflight = new Map<string, Promise<T | undefined>>();
resets.push(() => {
byDomain.clear();
inflight.clear();
});

async function fetchFromDev(domain: string): Promise<T | undefined> {
let value: T | undefined;
try {
const res = await fetchWithTimeout(
`https://raw.githubusercontent.com/home-assistant/core/dev/homeassistant/components/${domain}/${file}`,
);
if (res.ok) value = await read(res, domain);
else if (res.status !== 404) throw new Error(`HTTP ${res.status}`);
} catch (err) {
log.warn("devComponentFile: fetch failed", { domain, file, error: String(err) });
// Uncached: a transient failure must not pin a miss.
return byDomain.get(domain)?.value;
}
byDomain.set(domain, { value, fetchedAt: Date.now() });
return value;
}

return (domain) => {
if (!DOMAIN.test(domain)) return Promise.resolve(undefined);
const cached = byDomain.get(domain);
if (cached) {
const ttl = cached.value === undefined ? MISS_TTL_MS : HIT_TTL_MS;
if (Date.now() - cached.fetchedAt < ttl) return Promise.resolve(cached.value);
}

let pending = inflight.get(domain);
if (!pending) {
pending = fetchFromDev(domain).finally(() => inflight.delete(domain));
inflight.set(domain, pending);
}
return pending;
};
}

/** Test hook: drop every cache so a case sees the files it serves. */
export function resetDevComponentFiles(): void {
for (const reset of resets) reset();
}
58 changes: 4 additions & 54 deletions src/github/helpers/integration-manifest.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { z } from "zod";
import { log } from "../../log.js";
import { fetchWithTimeout } from "../../util/fetch.js";
import { devComponentFile } from "./dev-file.js";

export enum QualityScale {
NO_SCORE = "No score",
Expand Down Expand Up @@ -43,60 +43,10 @@ export function parseIntegrationManifest(
return parsed.data;
}

// Long: manifests change only by PR, and every reader re-evaluates on later
// events, so a stale read corrects itself.
const MANIFEST_TTL_MS = 6 * 60 * 60 * 1000;

// Short: a 404 means "not on dev *yet*", and an integration merged minutes ago
// must not stay invisible for six hours.
const MISS_TTL_MS = 5 * 60 * 1000;

// Core's domain grammar. Callers pass user input (command arguments, link
// segments, directory names), which otherwise ends up in the fetched URL.
const DOMAIN = /^[a-z0-9_]+$/;

/** Cross-event cache, one entry per domain (~1500 at most). */
const byDomain = new Map<string, { manifest?: IntegrationManifest; fetchedAt: number }>();
const inflight = new Map<string, Promise<IntegrationManifest | undefined>>();

async function fetchFromDev(domain: string): Promise<IntegrationManifest | undefined> {
let manifest: IntegrationManifest | undefined;
try {
const res = await fetchWithTimeout(
`https://raw.githubusercontent.com/home-assistant/core/dev/homeassistant/components/${domain}/manifest.json`,
);
if (res.ok) manifest = parseIntegrationManifest(await res.json(), `dev:${domain}`);
} catch (err) {
log.warn("fetchIntegrationManifest: fetch failed", { domain, error: String(err) });
// Uncached: a transient failure must not pin a miss.
return byDomain.get(domain)?.manifest;
}
byDomain.set(domain, { manifest, fetchedAt: Date.now() });
return manifest;
}

/**
* The manifest as shipped on core's dev branch, or undefined if there is none
* there. Cached across deliveries; concurrent readers share one fetch.
*/
export function fetchIntegrationManifest(domain: string): Promise<IntegrationManifest | undefined> {
if (!DOMAIN.test(domain)) return Promise.resolve(undefined);
const cached = byDomain.get(domain);
if (cached) {
const ttl = cached.manifest ? MANIFEST_TTL_MS : MISS_TTL_MS;
if (Date.now() - cached.fetchedAt < ttl) return Promise.resolve(cached.manifest);
}

let pending = inflight.get(domain);
if (!pending) {
pending = fetchFromDev(domain).finally(() => inflight.delete(domain));
inflight.set(domain, pending);
}
return pending;
}

/** Test hook: drop the cache so a case sees the manifests it serves. */
export function resetIntegrationManifests(): void {
byDomain.clear();
inflight.clear();
}
export const fetchIntegrationManifest = devComponentFile("manifest.json", async (res, domain) =>
parseIntegrationManifest(await res.json(), `dev:${domain}`),
);
6 changes: 4 additions & 2 deletions src/github/manifests/home-assistant-core/issue-context.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
/**
* Per-label reporting guidance shown on the issue dashboard, ported from the
* legacy bot's `data/github/issue_context.yaml`. The generic duplicate-check
* message lives in the dashboard's issue intro, not here.
* legacy bot's `data/github/issue_context.yaml`. An integration's
* `issue_context.md` in core takes precedence over its entry here. The
* generic duplicate-check message lives in the dashboard's issue intro, not
* here.
*/
export const coreIssueContext: Record<string, string> = {
"custom integration":
Expand Down
57 changes: 45 additions & 12 deletions src/github/manifests/home-assistant-core/rules/issue-context.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,63 @@
import { log } from "../../../../log.js";
import { defuseHtmlComments, neutraliseMentions } from "../../../../util/markdown.js";
import { EventType } from "../../../engine/event.js";
import type { RuleContext } from "../../../engine/model/rule-context.js";
import { on } from "../../../engine/rule.js";
import type { Rule, RuleOutput } from "../../../engine/types.js";
import { devComponentFile } from "../../../helpers/dev-file.js";
import { INTEGRATION_LABEL_PREFIX } from "../../../helpers/integration-domains.js";
import { itemIntegrationDomains } from "../helpers/integration-domains.js";

type HandledEvent = EventType.ISSUES_OPENED | EventType.ISSUES_LABELED | EventType.ISSUES_ON_DEMAND;

// Longer guidance belongs in the integration's docs.
const MAX_LENGTH = 2000;

/**
* An integration's guidance from core, as text the dashboard can carry: no
* mentions that notify, no HTML comment that hides the rest or poses as the
* dashboard's state. Undefined when empty or too long.
*/
const fetchIssueContext = devComponentFile("issue_context.md", async (res, domain) => {
const text = (await res.text()).trim();
if (text.length > MAX_LENGTH) {
log.warn("issueContext: file too long, ignored", { domain, length: text.length });
return;
}
return text ? defuseHtmlComments(neutraliseMentions(text)) : undefined;
});

/**
* Per-label reporting guidance on the issue dashboard (keys are full label
* names, e.g. `integration: zha`, `custom integration`). Generic advice
* lives in the dashboard's issue intro; this rule only carries extras and
* fills the `reporting-guidance` template block.
* Reporting guidance on the issue dashboard. Integrations keep theirs in core
* as `homeassistant/components/<domain>/issue_context.md`, read from dev;
* `labels` holds guidance by full label name (e.g. `custom integration`),
* and for integrations that have no file in core yet. Generic advice lives
* in the dashboard's issue intro; this rule only carries extras and fills the
* `reporting-guidance` template block.
*/
export function issueContext(labels: Record<string, string>): Rule {
async function evaluate(ctx: RuleContext<HandledEvent>): Promise<RuleOutput | undefined> {
if ("label" in ctx.event && !(ctx.event.label in labels)) return;
if (
"label" in ctx.event &&
!(ctx.event.label in labels) &&
!ctx.event.label.startsWith(INTEGRATION_LABEL_PREFIX)
)
return;
// Plain rule (blocks, not check()): guard closed issues itself.
if ((await ctx.target.state()) !== "open") return;

// Labels on the issue plus the integration labels it's about, so guidance
// shows up on opened without waiting for set-integration.
const derived = (await itemIntegrationDomains(ctx)).map(
(d) => `${INTEGRATION_LABEL_PREFIX}${d}`,
// Labels on the issue plus the integrations it's about, so guidance shows
// up on opened without waiting for set-integration.
const domains = await itemIntegrationDomains(ctx);
const texts = await Promise.all(domains.map((domain) => fetchIssueContext(domain)));
const fromCore = new Map(
domains.map((domain, i) => [`${INTEGRATION_LABEL_PREFIX}${domain}`, texts[i]]),
);
const effective = new Set([...(await ctx.target.labels()), ...derived]);
const paragraphs = [...effective].flatMap((label) => (labels[label] ? [labels[label]] : []));
const effective = new Set([...(await ctx.target.labels()), ...fromCore.keys()]);
const paragraphs = [...effective].flatMap((label) => {
const text = fromCore.get(label) ?? labels[label];
return text ? [text] : [];
});

return {
blocks: [
Expand All @@ -39,7 +71,8 @@ export function issueContext(labels: Record<string, string>): Rule {

return {
name: "issue-context",
description: "Shows per-label reporting guidance on the issue dashboard",
description:
"Shows reporting guidance for the issue's labels and integrations on the dashboard",
events: on(
[EventType.ISSUES_OPENED, EventType.ISSUES_LABELED, EventType.ISSUES_ON_DEMAND],
evaluate,
Expand Down
5 changes: 5 additions & 0 deletions src/util/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,8 @@ export function codeSpan(text: string): string {
export function neutraliseMentions(text: string): string {
return text.replace(/@(?=\w)/g, "@\u200B");
}

/** `text` with HTML comment openers defused, so it cannot hide or forge the bot's markers. */
export function defuseHtmlComments(text: string): string {
return text.replaceAll("<!--", "&lt;!--");
}
6 changes: 3 additions & 3 deletions tests/github/engine/status/render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,9 +253,9 @@ describe("status renderer", () => {
});
expect(result).not.toContain("## Checks");
expect(result).toContain("**Reporting guidance**");
// Paragraphs render on consecutive lines (single newlines — GitHub
// comments treat those as line breaks).
expect(result).toContain("First advice.\nSecond advice.");
// A blank line between paragraphs, so one ending in a list or table
// cannot swallow the next.
expect(result).toContain("First advice.\n\nSecond advice.");
});

it("hidden blocks leave no trace in body or state", () => {
Expand Down
14 changes: 14 additions & 0 deletions tests/github/helpers/integration-manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,4 +101,18 @@ describe("fetchIntegrationManifest", () => {
expect(await fetchIntegrationManifest("hue")).toMatchObject({ domain: "hue" });
expect(fetch).toHaveBeenCalledTimes(1);
});

it("keeps the last good manifest when dev answers with an error", async () => {
expectLoggedErrors(); // the transient-failure warn is the path under test
vi.useFakeTimers();
mockFetch({ hue: HUE });
await fetchIntegrationManifest("hue");
vi.advanceTimersByTime(6 * 60 * 60 * 1000 + 1000);

const fetch = vi.fn(async () => ({ ok: false, status: 503 }) as Response);
vi.stubGlobal("fetch", fetch);
expect(await fetchIntegrationManifest("hue")).toMatchObject({ domain: "hue" });
await fetchIntegrationManifest("hue");
expect(fetch).toHaveBeenCalledTimes(2);
});
});
Loading