diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index e0aa44c..04aae7a 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -2,6 +2,15 @@ +## Release notes + + + ## Type of change - [ ] Bug fix diff --git a/.github/workflows/e2e-component.yml b/.github/workflows/e2e-component.yml index 8053120..5ad1576 100644 --- a/.github/workflows/e2e-component.yml +++ b/.github/workflows/e2e-component.yml @@ -53,15 +53,35 @@ jobs: echo "Failed to parse ESPHome Device Builder add-on version" >&2 exit 1 fi + hacs_addon_config="$( + gh api \ + -H "Accept: application/vnd.github.raw" \ + repos/hacs/addons/contents/get/config.yaml + )" + hacs_addon_hash="$( + printf '%s' "$hacs_addon_config" | sha256sum | cut -d' ' -f1 | head -c16 + )" + hacs_version="$(gh api repos/hacs/integration/releases/latest --jq '.tag_name')" + if [ -z "$hacs_version" ]; then + echo "Failed to resolve the latest HACS release" >&2 + exit 1 + fi hash=$(git ls-tree -r HEAD \ tests/haos_image_build \ tests/initial_test_state \ custom_components/esphome_mcp \ - | { cat; echo "esphome-addon:$esphome_addon_hash"; } \ + | { + cat + echo "esphome-addon:$esphome_addon_hash" + echo "hacs-addon:$hacs_addon_hash" + echo "hacs-release:$hacs_version" + } \ | sha256sum | cut -d' ' -f1 | head -c16) echo "cache-key=esphome-mcp-haos-image-$hash" >> "$GITHUB_OUTPUT" echo "esphome-addon-hash=$esphome_addon_hash" >> "$GITHUB_OUTPUT" echo "esphome-version=$esphome_version" >> "$GITHUB_OUTPUT" + echo "hacs-addon-hash=$hacs_addon_hash" >> "$GITHUB_OUTPUT" + echo "hacs-version=$hacs_version" >> "$GITHUB_OUTPUT" - name: Restore image from cache id: restore-cache @@ -72,7 +92,7 @@ jobs: - name: Report image acquisition path run: | - echo "::notice title=HAOS image cache::cache-hit=${{ steps.restore-cache.outputs.cache-hit }} key=${{ steps.key.outputs.cache-key }} esphome-addon-hash=${{ steps.key.outputs.esphome-addon-hash }} esphome-version=${{ steps.key.outputs.esphome-version }}" + echo "::notice title=HAOS image cache::cache-hit=${{ steps.restore-cache.outputs.cache-hit }} key=${{ steps.key.outputs.cache-key }} esphome-addon-hash=${{ steps.key.outputs.esphome-addon-hash }} esphome-version=${{ steps.key.outputs.esphome-version }} hacs-addon-hash=${{ steps.key.outputs.hacs-addon-hash }} hacs-version=${{ steps.key.outputs.hacs-version }}" - name: Install QEMU, OVMF, and libguestfs run: | diff --git a/.github/workflows/release-notes.yml b/.github/workflows/release-notes.yml new file mode 100644 index 0000000..550f1ae --- /dev/null +++ b/.github/workflows/release-notes.yml @@ -0,0 +1,26 @@ +name: Release Notes + +on: + pull_request: + branches: [master] + types: [opened, edited, reopened, synchronize] + +permissions: + contents: read + +jobs: + release-notes: + name: Release Notes + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - uses: actions/setup-python@v6 + with: + python-version: "3.13" + - name: Validate release notes for version bumps + run: >- + python scripts/release_notes.py validate-pr + --base-ref "origin/${{ github.base_ref }}" + --event-path "$GITHUB_EVENT_PATH" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 67dbcd8..24a5869 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -99,6 +99,7 @@ jobs: if: ${{ github.ref_name == 'master' && github.event.inputs.dry_run != 'true' }} permissions: contents: write + pull-requests: read steps: - name: Inspect existing release and tag id: release-state @@ -136,6 +137,33 @@ jobs: exit 1 fi + - name: Check out release source + if: steps.release-state.outputs.should_publish == 'true' + uses: actions/checkout@v7 + + - name: Set up Python + if: steps.release-state.outputs.should_publish == 'true' + uses: actions/setup-python@v6 + with: + python-version: "3.13" + + - name: Build release notes from merged pull request + if: steps.release-state.outputs.should_publish == 'true' + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + set -euo pipefail + gh api \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2026-03-10" \ + "repos/${GH_REPO}/commits/${GITHUB_SHA}/pulls" \ + > /tmp/release-pulls.json + python scripts/release_notes.py render \ + --pulls-json /tmp/release-pulls.json \ + --sha "${GITHUB_SHA}" \ + --output /tmp/release-notes.md + - name: Create GitHub release if: steps.release-state.outputs.should_publish == 'true' env: @@ -149,4 +177,4 @@ jobs: gh release create "${tag}" \ --target "${GITHUB_SHA}" \ --title "${tag}" \ - --notes "Release ${tag} for HACS installation." + --notes-file /tmp/release-notes.md diff --git a/README.md b/README.md index a603b0b..96a4873 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ Builder tools need Supervisor. This repository is release-backed for HACS installs. The release workflow publishes the component manifest version as a GitHub Release tag such as -`v0.1.3`, which is the version HACS displays. Do not install a +`v0.1.4`, which is the version HACS displays. Do not install a seven-character commit version such as `99cdab0`. If HACS has cached an old commit-only entry, refresh the custom repository before installing. diff --git a/custom_components/esphome_mcp/const.py b/custom_components/esphome_mcp/const.py index c7bfce6..4d854d4 100644 --- a/custom_components/esphome_mcp/const.py +++ b/custom_components/esphome_mcp/const.py @@ -1,7 +1,7 @@ """Constants for the ESPHome MCP custom component.""" DOMAIN = "esphome_mcp" -VERSION = "0.1.3" +VERSION = "0.1.4" DEFAULT_SERVER_PORT = 9590 DEFAULT_BIND_HOST = "0.0.0.0" diff --git a/custom_components/esphome_mcp/manifest.json b/custom_components/esphome_mcp/manifest.json index 626917f..026217d 100644 --- a/custom_components/esphome_mcp/manifest.json +++ b/custom_components/esphome_mcp/manifest.json @@ -17,5 +17,5 @@ "documentation": "https://github.com/kingpanther13/esphome-mcp", "iot_class": "local_push", "issue_tracker": "https://github.com/kingpanther13/esphome-mcp/issues", - "version": "0.1.3" + "version": "0.1.4" } diff --git a/pyproject.toml b/pyproject.toml index f8e79f5..b17d2ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "esphome-mcp-custom-component" -version = "0.1.3" +version = "0.1.4" description = "ESPHome MCP server as a Home Assistant custom component" readme = "README.md" requires-python = ">=3.13" diff --git a/scripts/release_notes.py b/scripts/release_notes.py new file mode 100644 index 0000000..585204e --- /dev/null +++ b/scripts/release_notes.py @@ -0,0 +1,203 @@ +"""Validate and render GitHub release notes from a merged pull request.""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +MANIFEST_PATH = "custom_components/esphome_mcp/manifest.json" + +_HTML_COMMENT_RE = re.compile(r"", re.DOTALL) +_RELEASE_HEADING_RE = re.compile(r"^##[ \t]+release notes[ \t]*$", re.IGNORECASE) +_TOP_LEVEL_HEADING_RE = re.compile(r"^#{1,2}(?:[ \t]+|$)") +_FENCE_RE = re.compile(r"^[ \t]*(`{3,}|~{3,})") +_EMPTY_RELEASE_NOTES = { + "n a", + "na", + "no release notes", + "none", + "not applicable", +} + + +class ReleaseNotesError(ValueError): + """Raised when release notes cannot be safely produced.""" + + +def _fence_marker(line: str) -> str | None: + match = _FENCE_RE.match(line) + return match.group(1) if match else None + + +def extract_release_notes(body: str) -> str: + """Extract the single level-two Release notes section from a PR body.""" + cleaned = _HTML_COMMENT_RE.sub("", body) + lines = cleaned.splitlines() + section_starts: list[int] = [] + active_fence: str | None = None + + for index, line in enumerate(lines): + marker = _fence_marker(line) + if marker is not None: + if active_fence is None: + active_fence = marker + elif marker[0] == active_fence[0] and len(marker) >= len(active_fence): + active_fence = None + continue + if active_fence is None and _RELEASE_HEADING_RE.fullmatch(line.strip()): + section_starts.append(index) + + if not section_starts: + raise ReleaseNotesError("pull request body is missing a '## Release notes' section") + if len(section_starts) > 1: + raise ReleaseNotesError("pull request body contains multiple '## Release notes' sections") + + start = section_starts[0] + 1 + end = len(lines) + active_fence = None + for index in range(start, len(lines)): + marker = _fence_marker(lines[index]) + if marker is not None: + if active_fence is None: + active_fence = marker + elif marker[0] == active_fence[0] and len(marker) >= len(active_fence): + active_fence = None + continue + if active_fence is None and _TOP_LEVEL_HEADING_RE.match(lines[index].strip()): + end = index + break + + notes = "\n".join(lines[start:end]).strip() + if not notes: + raise ReleaseNotesError("the '## Release notes' section is empty") + + normalized = re.sub(r"[^a-z0-9]+", " ", notes.casefold()).strip() + if normalized in _EMPTY_RELEASE_NOTES: + raise ReleaseNotesError("the '## Release notes' section must describe what changed") + return notes + + +def select_merged_pull(pulls: Any, merge_sha: str) -> dict[str, Any]: + """Select the merged PR that introduced the release target commit.""" + if not isinstance(pulls, list): + raise ReleaseNotesError("commit-to-pull response must be a JSON list") + + merged = [pull for pull in pulls if isinstance(pull, dict) and pull.get("merged_at")] + exact_matches = [pull for pull in merged if pull.get("merge_commit_sha") == merge_sha] + if len(exact_matches) == 1: + return exact_matches[0] + if len(exact_matches) > 1: + raise ReleaseNotesError(f"multiple merged pull requests claim release commit {merge_sha}") + + # GitHub can rewrite commit SHAs for rebase merges. The endpoint itself is + # commit-specific, so one merged result remains unambiguous in that case. + if len(merged) == 1: + return merged[0] + if not merged: + raise ReleaseNotesError( + f"no merged pull request is associated with release commit {merge_sha}" + ) + raise ReleaseNotesError( + f"multiple merged pull requests are associated with release commit {merge_sha}" + ) + + +def render_release_notes(pulls: Any, merge_sha: str) -> str: + """Render a GitHub release body from the exact merged PR.""" + pull = select_merged_pull(pulls, merge_sha) + number = pull.get("number") + title = pull.get("title") + url = pull.get("html_url") + body = pull.get("body") + + if not isinstance(number, int): + raise ReleaseNotesError("merged pull request is missing its number") + if not isinstance(title, str) or not title.strip(): + raise ReleaseNotesError("merged pull request is missing its title") + if not isinstance(url, str) or not url.startswith("https://github.com/"): + raise ReleaseNotesError("merged pull request is missing its GitHub URL") + if not isinstance(body, str): + raise ReleaseNotesError("merged pull request body is empty") + + notes = extract_release_notes(body) + return ( + f"## What's changed\n\n{notes}\n\n---\n\n[Pull request #{number}]({url}): {title.strip()}\n" + ) + + +def _git(args: list[str]) -> str: + return subprocess.check_output(["git", *args], cwd=ROOT, text=True).strip() + + +def _manifest_version_from_worktree() -> str: + manifest = json.loads((ROOT / MANIFEST_PATH).read_text()) + return str(manifest["version"]) + + +def _manifest_version_from_ref(ref: str) -> str: + manifest = json.loads(_git(["show", f"{ref}:{MANIFEST_PATH}"])) + return str(manifest["version"]) + + +def release_version_changed(base_ref: str) -> bool: + """Return whether this PR changes the version that will be released.""" + return _manifest_version_from_worktree() != _manifest_version_from_ref(base_ref) + + +def validate_pull_request_event(event_path: Path, base_ref: str) -> bool: + """Validate release notes when a PR changes the release version.""" + if not release_version_changed(base_ref): + return False + + event = json.loads(event_path.read_text()) + pull_request = event.get("pull_request") + if not isinstance(pull_request, dict): + raise ReleaseNotesError("GitHub event does not contain a pull_request object") + body = pull_request.get("body") + extract_release_notes(body if isinstance(body, str) else "") + return True + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest="command", required=True) + + validate = subparsers.add_parser("validate-pr") + validate.add_argument("--base-ref", required=True) + validate.add_argument("--event-path", type=Path, required=True) + + render = subparsers.add_parser("render") + render.add_argument("--pulls-json", type=Path, required=True) + render.add_argument("--sha", required=True) + render.add_argument("--output", type=Path, required=True) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = _build_parser().parse_args(argv) + try: + if args.command == "validate-pr": + required = validate_pull_request_event(args.event_path, args.base_ref) + if required: + print("Release notes are valid for the new component version.") + else: + print("Release notes are not required because the component version is unchanged.") + return 0 + + pulls = json.loads(args.pulls_json.read_text()) + args.output.write_text(render_release_notes(pulls, args.sha)) + print(f"Release notes written to {args.output}.") + return 0 + except (ReleaseNotesError, json.JSONDecodeError, OSError, subprocess.CalledProcessError) as err: + print(f"ERROR: {err}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/haos_image_build/README.md b/tests/haos_image_build/README.md index eb9a57a..d669518 100644 --- a/tests/haos_image_build/README.md +++ b/tests/haos_image_build/README.md @@ -7,6 +7,7 @@ lane, trimmed to this component: - boot a pinned HAOS qcow2, - onboard Home Assistant, - install and start the official ESPHome Device Builder add-on, +- bootstrap the complete HACS release through the supported Get HACS add-on, - shut down HAOS, - inject `custom_components/esphome_mcp` and an enabled config entry into `/supervisor/homeassistant`. diff --git a/tests/haos_image_build/build_image.py b/tests/haos_image_build/build_image.py index 4d680b9..bb81bfa 100644 --- a/tests/haos_image_build/build_image.py +++ b/tests/haos_image_build/build_image.py @@ -69,6 +69,11 @@ class Addon: name="ESPHome Device Builder", ) +GET_HACS_ADDON = Addon( + repo="https://github.com/hacs/addons", + name="Get HACS", +) + def _run(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]: LOG.debug("$ %s", " ".join(cmd)) @@ -518,6 +523,33 @@ def install_esphome_device_builder(ws: HAWebSocket) -> str: return slug +def install_hacs(ws: HAWebSocket, base_url: str) -> None: + """Bootstrap HACS through its supported Get HACS add-on path.""" + _wait_supervisor_ready(ws) + addon = GET_HACS_ADDON + assert addon.repo is not None + _add_repository(ws, addon.repo) + _reload_store(ws) + slug = _discover_slug(ws, addon) + LOG.info("Installing %s (slug=%s)", addon.name, slug) + info = _addon_info_or_none(ws, slug) + if not _addon_is_installed(info): + _install_addon_with_retry(ws, slug, timeout=900.0) + ws.supervisor_api(f"/addons/{slug}/start", method="post", timeout=180.0) + + # A Core restart closes this WebSocket before Supervisor can reply. That + # disconnect is expected and matches ha-mcp's proven HAOS bake path. + from websockets.exceptions import ConnectionClosed + + LOG.info("Restarting HA Core so the installed HACS component loads") + try: + ws.supervisor_api("/core/restart", method="post", timeout=300.0) + except ConnectionClosed: + LOG.info("WebSocket closed during Core restart (expected)") + _wait_http_ok(f"{base_url}/manifest.json", timeout=300.0) + ws.reconnect() + + def _check_core_auth(base_url: str, token: str) -> None: cfg = _http("GET", f"{base_url}/api/config", token=token, timeout=10.0) LOG.info("AUTH OK: /api/config version=%s state=%s", cfg.get("version"), cfg.get("state")) @@ -686,6 +718,11 @@ def bake_component_into_config(qcow2: Path) -> None: cc_dir = config_dir / "custom_components" cc_dir.mkdir(exist_ok=True) + # Get HACS installed the complete release into the qcow2. The copied + # seed is source-only and lacks HACS's generated frontend package. + seed_hacs = cc_dir / "hacs" + if seed_hacs.exists(): + shutil.rmtree(seed_hacs) dest = cc_dir / ESPHOME_MCP_DOMAIN if dest.exists(): shutil.rmtree(dest) @@ -768,6 +805,7 @@ def build(work_dir: Path, output: Path) -> None: _check_core_auth(base_url, token) with HAWebSocket(base_url, token) as ws: install_esphome_device_builder(ws) + install_hacs(ws, base_url) stop_qemu(qemu, ws) except Exception: LOG.exception("Image build failed; leaving qcow2 in %s for inspection", qcow2) diff --git a/tests/src/e2e/haos_only/test_embedded_server_haos.py b/tests/src/e2e/haos_only/test_embedded_server_haos.py index 742c437..5aa4aa4 100644 --- a/tests/src/e2e/haos_only/test_embedded_server_haos.py +++ b/tests/src/e2e/haos_only/test_embedded_server_haos.py @@ -105,6 +105,11 @@ LIVE_LOG_MARKER = "esp-mcp-e2e-live heartbeat" LIVE_DEVICE_TIMEOUT_S = 420 + +class MCPServerUnavailableError(RuntimeError): + """The HA webhook could not reach the embedded MCP server.""" + + pytestmark = [ pytest.mark.e2e, pytest.mark.slow, @@ -229,6 +234,8 @@ def _tool_call( timeout=timeout, ) parsed = _parse_mcp(resp) + if resp.status_code in {502, 503} and resp.text.startswith("ESPHome MCP server"): + raise MCPServerUnavailableError(f"HTTP {resp.status_code}: {resp.text}") assert parsed is not None, f"unparseable tools/call response: {resp.text[:500]}" assert "result" in parsed, parsed return parsed @@ -537,34 +544,44 @@ async def _exercise_live_host_device_in_haos( deadline = time.monotonic() + LIVE_DEVICE_TIMEOUT_S last_devices: dict[str, Any] | None = None last_entities: dict[str, Any] | None = None + last_transport_error: str | None = None while time.monotonic() < deadline: - last_devices = _tool_payload( - await asyncio.to_thread( - _tool_call, - base_url, - session_id, - "esp_list_devices", - { - "query": LIVE_FRIENDLY_NAME, - "config_entry_state": "loaded", - "limit": 10, - }, + try: + last_devices = _tool_payload( + await asyncio.to_thread( + _tool_call, + base_url, + session_id, + "esp_list_devices", + { + "query": LIVE_FRIENDLY_NAME, + "config_entry_state": "loaded", + "limit": 10, + }, + ) ) - ) - last_entities = _tool_payload( - await asyncio.to_thread( - _tool_call, - base_url, - session_id, - "esp_list_entities", - { - "query": LIVE_SENSOR_NAME, - "domain": "sensor", - "state": "42", - "limit": 10, - }, + last_entities = _tool_payload( + await asyncio.to_thread( + _tool_call, + base_url, + session_id, + "esp_list_entities", + { + "query": LIVE_SENSOR_NAME, + "domain": "sensor", + "state": "42", + "limit": 10, + }, + ) ) - ) + except MCPServerUnavailableError as err: + last_transport_error = str(err) + LOG.warning( + "MCP server unavailable while polling the live ESPHome registry: %s", + err, + ) + await asyncio.sleep(5) + continue if ( last_devices.get("success") is True and last_entities.get("success") is True @@ -576,7 +593,8 @@ async def _exercise_live_host_device_in_haos( else: raise AssertionError( "Live ESPHome host device did not appear in HA registries: " - f"devices={last_devices} entities={last_entities}" + f"devices={last_devices} entities={last_entities} " + f"last_transport_error={last_transport_error}" ) logs = _tool_payload( @@ -632,6 +650,59 @@ def test_overview_tool_runs_inside_haos( assert payload["mcp_domain"] == "esphome_mcp" assert "device_count" in payload + def test_local_brand_icon_uses_home_assistant_authenticated_proxy( + self, + embedded_server: tuple[str, str | None, str], + ) -> None: + base_url, _session_id, _configuration = embedded_server + token = login_for_token(base_url) + brands_auth = websocket_command( + base_url, + token, + {"type": "brands/access_token"}, + ) + + assert isinstance(brands_auth, dict), brands_auth + brands_token = brands_auth.get("token") + assert isinstance(brands_token, str) and brands_token, brands_auth + + response = requests.get( + f"{base_url}/api/brands/integration/esphome_mcp/icon.png", + params={"token": brands_token}, + timeout=60, + ) + assert response.status_code == 200, response.text[:1000] + assert response.headers.get("Content-Type", "").split(";", 1)[0] == "image/png" + + expected_icon = ( + Path(__file__).resolve().parents[4] + / "custom_components" + / "esphome_mcp" + / "brand" + / "icon.png" + ).read_bytes() + assert response.content == expected_icon + + def test_hacs_integration_is_loaded( + self, + embedded_server: tuple[str, str | None, str], + ) -> None: + base_url, _session_id, _configuration = embedded_server + token = login_for_token(base_url) + entries = websocket_command(base_url, token, {"type": "config_entries/get"}) + + assert isinstance(entries, list), entries + hacs_entry = next( + ( + entry + for entry in entries + if isinstance(entry, dict) and entry.get("domain") == "hacs" + ), + None, + ) + assert isinstance(hacs_entry, dict), entries + assert hacs_entry.get("state") == "loaded", hacs_entry + def test_options_flow_shows_resolved_webhook_connect_url( self, embedded_server: tuple[str, str | None, str], diff --git a/tests/src/unit/test_haos_e2e_harness.py b/tests/src/unit/test_haos_e2e_harness.py index 64a0581..1cd48b6 100644 --- a/tests/src/unit/test_haos_e2e_harness.py +++ b/tests/src/unit/test_haos_e2e_harness.py @@ -96,8 +96,8 @@ def test_build_image_injects_disabled_esphome_mcp_entry(tmp_path: Path) -> None: assert "pip_spec" not in entry["options"] -def test_build_image_installs_official_esphome_device_builder_before_bake() -> None: - """The HAOS image builder installs ESPHome Device Builder before component bake.""" +def test_build_image_installs_esphome_and_hacs_before_bake() -> None: + """The HAOS image has official Device Builder and complete HACS installs.""" build_image = _load_module("esphome_mcp_test_build_image", BUILD_IMAGE_PATH) source = BUILD_IMAGE_PATH.read_text() workflow = E2E_COMPONENT_WORKFLOW.read_text() @@ -108,12 +108,83 @@ def test_build_image_installs_official_esphome_device_builder_before_bake() -> N ) assert build_image.ESPHOME_DEVICE_BUILDER_ADDON.name == "ESPHome Device Builder" assert build_image.ESPHOME_DEVICE_BUILDER_ADDON.start is True + assert build_image.GET_HACS_ADDON.repo == "https://github.com/hacs/addons" + assert build_image.GET_HACS_ADDON.name == "Get HACS" assert source.index("install_esphome_device_builder(ws)") < source.index( "bake_component_into_config(qcow2)" ) + assert source.index("install_hacs(ws, base_url)") < source.index( + "bake_component_into_config(qcow2)" + ) + assert 'seed_hacs = cc_dir / "hacs"' in source assert "repos/esphome/home-assistant-addon/contents/esphome/config.yaml" in workflow + assert "repos/hacs/addons/contents/get/config.yaml" in workflow + assert "repos/hacs/integration/releases/latest" in workflow assert "GH_TOKEN: ${{ github.token }}" in workflow assert "esphome-addon-hash" in workflow + assert "hacs-addon-hash" in workflow + assert "hacs-version" in workflow + + +def test_install_hacs_uses_supported_addon_and_restarts_core(monkeypatch) -> None: + """The image bake installs HACS, restarts Core, and reconnects Supervisor.""" + build_image = _load_module("esphome_mcp_test_build_image", BUILD_IMAGE_PATH) + events: list[tuple[object, ...]] = [] + + class FakeWebSocket: + def supervisor_api( + self, + path: str, + *, + method: str, + timeout: float, + ) -> dict[str, object]: + events.append(("api", path, method, timeout)) + return {} + + def reconnect(self) -> None: + events.append(("reconnect",)) + + monkeypatch.setattr( + build_image, + "_wait_supervisor_ready", + lambda _ws: events.append(("supervisor-ready",)), + ) + monkeypatch.setattr( + build_image, + "_add_repository", + lambda _ws, repo: events.append(("add-repository", repo)), + ) + monkeypatch.setattr( + build_image, + "_reload_store", + lambda _ws: events.append(("reload-store",)), + ) + monkeypatch.setattr(build_image, "_discover_slug", lambda _ws, _addon: "get_hacs") + monkeypatch.setattr(build_image, "_addon_info_or_none", lambda _ws, _slug: None) + monkeypatch.setattr( + build_image, + "_install_addon_with_retry", + lambda _ws, slug, *, timeout: events.append(("install", slug, timeout)), + ) + monkeypatch.setattr( + build_image, + "_wait_http_ok", + lambda url, *, timeout: events.append(("wait-http", url, timeout)), + ) + + build_image.install_hacs(FakeWebSocket(), "http://127.0.0.1:18123") + + assert events == [ + ("supervisor-ready",), + ("add-repository", "https://github.com/hacs/addons"), + ("reload-store",), + ("install", "get_hacs", 900.0), + ("api", "/addons/get_hacs/start", "post", 180.0), + ("api", "/core/restart", "post", 300.0), + ("wait-http", "http://127.0.0.1:18123/manifest.json", 300.0), + ("reconnect",), + ] def test_build_image_bakes_from_seed_state_instead_of_live_config() -> None: @@ -278,9 +349,13 @@ def test_embedded_e2e_module_tracks_expected_webhook_and_tool_names() -> None: assert "devices/create" in string_constants assert "firmware/cancel" in string_constants assert "ESPHOME_MCP_SERVER_WEBHOOK_ID" in EMBEDDED_E2E_PATH.read_text() + assert "brands/access_token" in string_constants + assert "/api/brands/integration/esphome_mcp/icon.png" in string_constants + assert "config_entries/get" in string_constants assert "/api/config/config_entries/options/flow" in string_constants assert "description_placeholders" in string_constants assert "connect_url" in string_constants source = EMBEDDED_E2E_PATH.read_text() + assert "MCPServerUnavailableError" in source assert 'assert "" not in connect_url' in source assert 'assert "Home Assistant URL unavailable" not in connect_url' in source diff --git a/tests/src/unit/test_metadata.py b/tests/src/unit/test_metadata.py index aadedd7..f8b77b9 100644 --- a/tests/src/unit/test_metadata.py +++ b/tests/src/unit/test_metadata.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import struct from importlib import util from pathlib import Path @@ -33,9 +34,9 @@ def test_manifest_is_hacs_ready() -> None: assert "frontend" not in manifest["after_dependencies"] assert "webhook" in manifest["dependencies"] assert "fastmcp==3.4.3" not in manifest.get("requirements", []) - assert manifest["version"] == "0.1.3" - assert 'version = "0.1.3"' in pyproject - assert 'VERSION = "0.1.3"' in const + assert manifest["version"] == "0.1.4" + assert 'version = "0.1.4"' in pyproject + assert 'VERSION = "0.1.4"' in const def test_hacs_metadata_exists() -> None: @@ -51,6 +52,14 @@ def test_hacs_metadata_exists() -> None: assert not (COMPONENT / "hacs.json").exists() +def test_local_brand_icon_meets_home_assistant_requirements() -> None: + """The HACS-installed component ships the supported local 256px PNG icon.""" + icon = (COMPONENT / "brand" / "icon.png").read_bytes() + + assert icon.startswith(b"\x89PNG\r\n\x1a\n") + assert struct.unpack(">II", icon[16:24]) == (256, 256) + + def test_server_defaults_are_scaffolded() -> None: """The scaffold uses the requested port and tool prefix.""" const = (COMPONENT / "const.py").read_text() @@ -172,10 +181,10 @@ def test_readme_has_hacs_facing_usage_information() -> None: def test_release_metadata_validation_accepts_manifest_version() -> None: """Release publishing must use a real version tag, not a short commit.""" - assert validate_release_metadata("v0.1.3") == [] + assert validate_release_metadata("v0.1.4") == [] -@pytest.mark.parametrize("version", ["99cdab0", "v0.1.0rc", "v0.1.4"]) +@pytest.mark.parametrize("version", ["99cdab0", "v0.1.0rc", "v0.1.5"]) def test_release_metadata_validation_rejects_bad_versions(version: str) -> None: """The release guard rejects the short-commit path that broke HACS installs.""" errors = validate_release_metadata(version) @@ -201,6 +210,7 @@ def test_release_workflow_creates_a_github_release() -> None: assert "REQUESTED_VERSION: ${{ github.event.inputs.version }}" in workflow assert "needs: validate" in workflow assert "contents: write" in workflow + assert "pull-requests: read" in workflow assert ( "if: ${{ github.ref_name == 'master' && github.event.inputs.dry_run != 'true' }}" in workflow @@ -209,7 +219,7 @@ def test_release_workflow_creates_a_github_release() -> None: assert "VERSION: ${{ steps.release-version.outputs.version }}" in workflow assert "VERSION: ${{ needs.validate.outputs.version }}" in workflow assert 'python scripts/validate_release_metadata.py "$VERSION"' in workflow - assert workflow.count("uses: actions/checkout@v7") == 1 + assert workflow.count("uses: actions/checkout@v7") == 2 assert not any( "${{ github.event.inputs.version }}" in line for line in workflow.splitlines() @@ -222,6 +232,10 @@ def test_release_workflow_creates_a_github_release() -> None: assert "gh release create" in workflow assert '--target "${GITHUB_SHA}"' in workflow assert 'tag="v${version}"' in workflow + assert "commits/${GITHUB_SHA}/pulls" in workflow + assert "scripts/release_notes.py render" in workflow + assert "--notes-file /tmp/release-notes.md" in workflow + assert "Release ${tag} for HACS installation." not in workflow def test_pr_validation_requires_version_bumps_for_component_changes() -> None: @@ -236,6 +250,18 @@ def test_pr_validation_requires_version_bumps_for_component_changes() -> None: assert "manifest version did not increase" in script +def test_pr_template_and_validation_supply_release_notes() -> None: + """Versioned PRs provide the user-facing text consumed by the release job.""" + template = (ROOT / ".github" / "pull_request_template.md").read_text() + workflow = (ROOT / ".github" / "workflows" / "release-notes.yml").read_text() + + assert template.count("## Release notes") == 1 + assert "published verbatim" in template + assert "scripts/release_notes.py validate-pr" in workflow + assert '--event-path "$GITHUB_EVENT_PATH"' in workflow + assert "types: [opened, edited, reopened, synchronize]" in workflow + + def test_runtime_dependency_sandbox_is_enforced_before_merge_and_release() -> None: """Both CI gates protect shared FastMCP state and upstream pin parity.""" pr_workflow = (ROOT / ".github" / "workflows" / "pr.yml").read_text() diff --git a/tests/src/unit/test_release_notes.py b/tests/src/unit/test_release_notes.py new file mode 100644 index 0000000..bad5fc5 --- /dev/null +++ b/tests/src/unit/test_release_notes.py @@ -0,0 +1,228 @@ +"""Tests for pull-request-driven GitHub release notes.""" + +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path +from types import ModuleType + +import pytest + +ROOT = Path(__file__).resolve().parents[3] +SCRIPT_PATH = ROOT / "scripts" / "release_notes.py" + + +def _load_release_notes() -> ModuleType: + spec = importlib.util.spec_from_file_location("release_notes", SCRIPT_PATH) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_extract_release_notes_preserves_user_markdown() -> None: + """The release section ends at the next top-level PR section.""" + release_notes = _load_release_notes() + body = """## Summary + +Internal implementation details. + +## Release notes + +- Fixed startup hangs when HA MCP and ESPHome MCP load together. +- Added a restart-specific repair when shared dependencies conflict. + +### Compatibility + +FastMCP is pinned to the HA MCP-compatible version. + +## Testing + +- Unit tests pass. +""" + + assert ( + release_notes.extract_release_notes(body) + == """- Fixed startup hangs when HA MCP and ESPHome MCP load together. +- Added a restart-specific repair when shared dependencies conflict. + +### Compatibility + +FastMCP is pinned to the HA MCP-compatible version.""" + ) + + +def test_extract_release_notes_ignores_comments_and_fenced_headings() -> None: + """Template comments and Markdown examples cannot terminate the section.""" + release_notes = _load_release_notes() + body = """## Release notes + + +- Added a Markdown example: + +```markdown +## This is example content +``` + +## Checklist +""" + + assert ( + release_notes.extract_release_notes(body) + == """- Added a Markdown example: + +```markdown +## This is example content +```""" + ) + + +def test_select_merged_pull_accepts_unambiguous_rebase_association() -> None: + """A commit-specific API result survives GitHub rewriting a rebase SHA.""" + release_notes = _load_release_notes() + pull = { + "number": 19, + "merged_at": "2026-07-11T00:00:00Z", + "merge_commit_sha": "github-rewritten-sha", + } + + assert release_notes.select_merged_pull([pull], "release-target-sha") is pull + + +@pytest.mark.parametrize( + "body, expected", + [ + ("## Summary\nNothing here.\n", "missing"), + ("## Release notes\n\n## Testing\n", "empty"), + ("## Release notes\nN/A\n", "must describe"), + ("## Release notes\nNone\n", "must describe"), + ( + "## Release notes\nFirst\n## Release notes\nSecond\n", + "multiple", + ), + ], +) +def test_extract_release_notes_rejects_unpublishable_sections( + body: str, + expected: str, +) -> None: + """Missing, ambiguous, and placeholder notes fail closed.""" + release_notes = _load_release_notes() + + with pytest.raises(release_notes.ReleaseNotesError, match=expected): + release_notes.extract_release_notes(body) + + +def test_render_release_notes_uses_exact_merge_pull() -> None: + """The release body is sourced from the PR that produced the target commit.""" + release_notes = _load_release_notes() + pulls = [ + { + "number": 17, + "title": "Add useful release notes", + "html_url": "https://github.com/kingpanther13/esphome-mcp/pull/17", + "body": "## Release notes\n\n- Releases now explain what changed.\n", + "merged_at": "2026-07-11T00:00:00Z", + "merge_commit_sha": "abc123", + }, + { + "number": 16, + "title": "Unrelated PR", + "html_url": "https://github.com/kingpanther13/esphome-mcp/pull/16", + "body": "## Release notes\n\n- Wrong notes.\n", + "merged_at": "2026-07-10T00:00:00Z", + "merge_commit_sha": "def456", + }, + ] + + assert ( + release_notes.render_release_notes(pulls, "abc123") + == """## What's changed + +- Releases now explain what changed. + +--- + +[Pull request #17](https://github.com/kingpanther13/esphome-mcp/pull/17): Add useful release notes +""" + ) + + +@pytest.mark.parametrize( + "pulls, expected", + [ + ([], "no merged pull request"), + ({"number": 1}, "JSON list"), + ( + [ + {"merged_at": "now", "merge_commit_sha": "abc123"}, + {"merged_at": "now", "merge_commit_sha": "abc123"}, + ], + "multiple merged pull requests", + ), + ], +) +def test_select_merged_pull_rejects_unsafe_api_results( + pulls: object, + expected: str, +) -> None: + """Publication stops when commit-to-PR provenance is missing or ambiguous.""" + release_notes = _load_release_notes() + + with pytest.raises(release_notes.ReleaseNotesError, match=expected): + release_notes.select_merged_pull(pulls, "abc123") + + +def test_validate_pull_request_event_requires_notes_only_for_a_release( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Maintenance PRs pass while a version bump with empty notes fails.""" + release_notes = _load_release_notes() + event_path = tmp_path / "event.json" + event_path.write_text(json.dumps({"pull_request": {"body": "## Release notes\n"}})) + + monkeypatch.setattr(release_notes, "release_version_changed", lambda _base_ref: False) + assert release_notes.validate_pull_request_event(event_path, "origin/master") is False + + monkeypatch.setattr(release_notes, "release_version_changed", lambda _base_ref: True) + with pytest.raises(release_notes.ReleaseNotesError, match="empty"): + release_notes.validate_pull_request_event(event_path, "origin/master") + + +def test_render_command_writes_notes_file(tmp_path: Path) -> None: + """The CLI emits the notes file consumed by gh release create.""" + release_notes = _load_release_notes() + pulls_path = tmp_path / "pulls.json" + output_path = tmp_path / "release.md" + pulls_path.write_text( + json.dumps( + [ + { + "number": 18, + "title": "Ship notes", + "html_url": "https://github.com/kingpanther13/esphome-mcp/pull/18", + "body": "## Release notes\n\n- Shipped.\n", + "merged_at": "now", + "merge_commit_sha": "release-sha", + } + ] + ) + ) + + result = release_notes.main( + [ + "render", + "--pulls-json", + str(pulls_path), + "--sha", + "release-sha", + "--output", + str(output_path), + ] + ) + + assert result == 0 + assert "- Shipped." in output_path.read_text()