From c907912dd1f112c792db1491c08ef624bd24eb38 Mon Sep 17 00:00:00 2001
From: Aidan Timson <contact@timmo.xyz>
Date: Tue, 11 Jun 2024 17:08:58 +0100
Subject: [PATCH] Restructure and setup dedicated coordinator for Azure DevOps
 (#119199)

---
 .../components/azure_devops/__init__.py       |  94 +++-----------
 .../components/azure_devops/coordinator.py    | 116 ++++++++++++++++++
 homeassistant/components/azure_devops/data.py |  15 +++
 .../components/azure_devops/entity.py         |  28 +++++
 .../components/azure_devops/sensor.py         |  22 ++--
 tests/components/azure_devops/conftest.py     |   9 +-
 tests/components/azure_devops/test_init.py    |  17 ++-
 7 files changed, 207 insertions(+), 94 deletions(-)
 create mode 100644 homeassistant/components/azure_devops/coordinator.py
 create mode 100644 homeassistant/components/azure_devops/data.py
 create mode 100644 homeassistant/components/azure_devops/entity.py

diff --git a/homeassistant/components/azure_devops/__init__.py b/homeassistant/components/azure_devops/__init__.py
index 27f7f790637..a6e531879b7 100644
--- a/homeassistant/components/azure_devops/__init__.py
+++ b/homeassistant/components/azure_devops/__init__.py
@@ -2,83 +2,45 @@
 
 from __future__ import annotations
 
-from datetime import timedelta
 import logging
-from typing import Final
-
-from aioazuredevops.builds import DevOpsBuild
-from aioazuredevops.client import DevOpsClient
-import aiohttp
 
 from homeassistant.config_entries import ConfigEntry
 from homeassistant.const import Platform
 from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import ConfigEntryAuthFailed
-from homeassistant.helpers.aiohttp_client import async_get_clientsession
-from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.update_coordinator import (
-    CoordinatorEntity,
-    DataUpdateCoordinator,
-    UpdateFailed,
-)
 
-from .const import CONF_ORG, CONF_PAT, CONF_PROJECT, DOMAIN
+from .const import CONF_PAT, CONF_PROJECT, DOMAIN
+from .coordinator import AzureDevOpsDataUpdateCoordinator
 
 _LOGGER = logging.getLogger(__name__)
 
 PLATFORMS = [Platform.SENSOR]
 
-BUILDS_QUERY: Final = "?queryOrder=queueTimeDescending&maxBuildsPerDefinition=1"
-
 
 async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
     """Set up Azure DevOps from a config entry."""
-    aiohttp_session = async_get_clientsession(hass)
-    client = DevOpsClient(session=aiohttp_session)
 
-    if entry.data.get(CONF_PAT) is not None:
-        await client.authorize(entry.data[CONF_PAT], entry.data[CONF_ORG])
-        if not client.authorized:
-            raise ConfigEntryAuthFailed(
-                "Could not authorize with Azure DevOps. You will need to update your"
-                " token"
-            )
-
-    project = await client.get_project(
-        entry.data[CONF_ORG],
-        entry.data[CONF_PROJECT],
+    # Create the data update coordinator
+    coordinator = AzureDevOpsDataUpdateCoordinator(
+        hass,
+        _LOGGER,
+        entry=entry,
     )
 
-    async def async_update_data() -> list[DevOpsBuild]:
-        """Fetch data from Azure DevOps."""
-
-        try:
-            builds = await client.get_builds(
-                entry.data[CONF_ORG],
-                entry.data[CONF_PROJECT],
-                BUILDS_QUERY,
-            )
-        except aiohttp.ClientError as exception:
-            raise UpdateFailed from exception
+    # Store the coordinator in hass data
+    hass.data.setdefault(DOMAIN, {})
+    hass.data[DOMAIN][entry.entry_id] = coordinator
 
-        if builds is None:
-            raise UpdateFailed("No builds found")
+    # If a personal access token is set, authorize the client
+    if entry.data.get(CONF_PAT) is not None:
+        await coordinator.authorize(entry.data[CONF_PAT])
 
-        return builds
-
-    coordinator = DataUpdateCoordinator(
-        hass,
-        _LOGGER,
-        name=f"{DOMAIN}_coordinator",
-        update_method=async_update_data,
-        update_interval=timedelta(seconds=300),
-    )
+    # Set the project for the coordinator
+    coordinator.project = await coordinator.get_project(entry.data[CONF_PROJECT])
 
+    # Fetch initial data so we have data when entities subscribe
     await coordinator.async_config_entry_first_refresh()
 
-    hass.data.setdefault(DOMAIN, {})
-    hass.data[DOMAIN][entry.entry_id] = coordinator, project
-
+    # Set up platforms
     await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
 
     return True
@@ -89,25 +51,5 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
     unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
     if unload_ok:
         del hass.data[DOMAIN][entry.entry_id]
-    return unload_ok
 
-
-class AzureDevOpsEntity(CoordinatorEntity[DataUpdateCoordinator[list[DevOpsBuild]]]):
-    """Defines a base Azure DevOps entity."""
-
-    _attr_has_entity_name = True
-
-    def __init__(
-        self,
-        coordinator: DataUpdateCoordinator[list[DevOpsBuild]],
-        organization: str,
-        project_name: str,
-    ) -> None:
-        """Initialize the Azure DevOps entity."""
-        super().__init__(coordinator)
-        self._attr_device_info = DeviceInfo(
-            entry_type=DeviceEntryType.SERVICE,
-            identifiers={(DOMAIN, organization, project_name)},  # type: ignore[arg-type]
-            manufacturer=organization,
-            name=project_name,
-        )
+    return unload_ok
diff --git a/homeassistant/components/azure_devops/coordinator.py b/homeassistant/components/azure_devops/coordinator.py
new file mode 100644
index 00000000000..ba0528de282
--- /dev/null
+++ b/homeassistant/components/azure_devops/coordinator.py
@@ -0,0 +1,116 @@
+"""Define the Azure DevOps DataUpdateCoordinator."""
+
+from collections.abc import Callable
+from datetime import timedelta
+import logging
+from typing import Final
+
+from aioazuredevops.builds import DevOpsBuild
+from aioazuredevops.client import DevOpsClient
+from aioazuredevops.core import DevOpsProject
+import aiohttp
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryAuthFailed
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+from .const import CONF_ORG, DOMAIN
+from .data import AzureDevOpsData
+
+BUILDS_QUERY: Final = "?queryOrder=queueTimeDescending&maxBuildsPerDefinition=1"
+
+
+def ado_exception_none_handler(func: Callable) -> Callable:
+    """Handle exceptions or None to always return a value or raise."""
+
+    async def handler(*args, **kwargs):
+        try:
+            response = await func(*args, **kwargs)
+        except aiohttp.ClientError as exception:
+            raise UpdateFailed from exception
+
+        if response is None:
+            raise UpdateFailed("No data returned from Azure DevOps")
+
+        return response
+
+    return handler
+
+
+class AzureDevOpsDataUpdateCoordinator(DataUpdateCoordinator[AzureDevOpsData]):
+    """Class to manage and fetch Azure DevOps data."""
+
+    client: DevOpsClient
+    organization: str
+    project: DevOpsProject
+
+    def __init__(
+        self,
+        hass: HomeAssistant,
+        logger: logging.Logger,
+        *,
+        entry: ConfigEntry,
+    ) -> None:
+        """Initialize global Azure DevOps data updater."""
+        self.title = entry.title
+
+        super().__init__(
+            hass=hass,
+            logger=logger,
+            name=DOMAIN,
+            update_interval=timedelta(seconds=300),
+        )
+
+        self.client = DevOpsClient(session=async_get_clientsession(hass))
+        self.organization = entry.data[CONF_ORG]
+
+    @ado_exception_none_handler
+    async def authorize(
+        self,
+        personal_access_token: str,
+    ) -> bool:
+        """Authorize with Azure DevOps."""
+        await self.client.authorize(
+            personal_access_token,
+            self.organization,
+        )
+        if not self.client.authorized:
+            raise ConfigEntryAuthFailed(
+                "Could not authorize with Azure DevOps. You will need to update your"
+                " token"
+            )
+
+        return True
+
+    @ado_exception_none_handler
+    async def get_project(
+        self,
+        project: str,
+    ) -> DevOpsProject | None:
+        """Get the project."""
+        return await self.client.get_project(
+            self.organization,
+            project,
+        )
+
+    @ado_exception_none_handler
+    async def _get_builds(self, project_name: str) -> list[DevOpsBuild] | None:
+        """Get the builds."""
+        return await self.client.get_builds(
+            self.organization,
+            project_name,
+            BUILDS_QUERY,
+        )
+
+    async def _async_update_data(self) -> AzureDevOpsData:
+        """Fetch data from Azure DevOps."""
+        # Get the builds from the project
+        builds = await self._get_builds(self.project.name)
+
+        return AzureDevOpsData(
+            organization=self.organization,
+            project=self.project,
+            builds=builds,
+        )
diff --git a/homeassistant/components/azure_devops/data.py b/homeassistant/components/azure_devops/data.py
new file mode 100644
index 00000000000..6cbd6eb3bc1
--- /dev/null
+++ b/homeassistant/components/azure_devops/data.py
@@ -0,0 +1,15 @@
+"""Data classes for Azure DevOps integration."""
+
+from dataclasses import dataclass
+
+from aioazuredevops.builds import DevOpsBuild
+from aioazuredevops.core import DevOpsProject
+
+
+@dataclass(frozen=True, kw_only=True)
+class AzureDevOpsData:
+    """Class describing Azure DevOps data."""
+
+    organization: str
+    project: DevOpsProject
+    builds: list[DevOpsBuild]
diff --git a/homeassistant/components/azure_devops/entity.py b/homeassistant/components/azure_devops/entity.py
new file mode 100644
index 00000000000..0a4a94d4b32
--- /dev/null
+++ b/homeassistant/components/azure_devops/entity.py
@@ -0,0 +1,28 @@
+"""Base entity for Azure DevOps."""
+
+from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DOMAIN
+from .coordinator import AzureDevOpsDataUpdateCoordinator
+
+
+class AzureDevOpsEntity(CoordinatorEntity[AzureDevOpsDataUpdateCoordinator]):
+    """Defines a base Azure DevOps entity."""
+
+    _attr_has_entity_name = True
+
+    def __init__(
+        self,
+        coordinator: AzureDevOpsDataUpdateCoordinator,
+    ) -> None:
+        """Initialize the Azure DevOps entity."""
+        super().__init__(coordinator)
+        self._attr_device_info = DeviceInfo(
+            entry_type=DeviceEntryType.SERVICE,
+            identifiers={
+                (DOMAIN, coordinator.data.organization, coordinator.data.project.name)  # type: ignore[arg-type]
+            },
+            manufacturer=coordinator.data.organization,
+            name=coordinator.data.project.name,
+        )
diff --git a/homeassistant/components/azure_devops/sensor.py b/homeassistant/components/azure_devops/sensor.py
index b1d975f0a70..7b2a1a15adf 100644
--- a/homeassistant/components/azure_devops/sensor.py
+++ b/homeassistant/components/azure_devops/sensor.py
@@ -19,11 +19,11 @@ from homeassistant.config_entries import ConfigEntry
 from homeassistant.core import HomeAssistant
 from homeassistant.helpers.entity_platform import AddEntitiesCallback
 from homeassistant.helpers.typing import StateType
-from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
 from homeassistant.util import dt as dt_util
 
-from . import AzureDevOpsEntity
-from .const import CONF_ORG, DOMAIN
+from .const import DOMAIN
+from .coordinator import AzureDevOpsDataUpdateCoordinator
+from .entity import AzureDevOpsEntity
 
 _LOGGER = logging.getLogger(__name__)
 
@@ -132,15 +132,13 @@ async def async_setup_entry(
     async_add_entities: AddEntitiesCallback,
 ) -> None:
     """Set up Azure DevOps sensor based on a config entry."""
-    coordinator, project = hass.data[DOMAIN][entry.entry_id]
-    initial_builds: list[DevOpsBuild] = coordinator.data
+    coordinator = hass.data[DOMAIN][entry.entry_id]
+    initial_builds: list[DevOpsBuild] = coordinator.data.builds
 
     async_add_entities(
         AzureDevOpsBuildSensor(
             coordinator,
             description,
-            entry.data[CONF_ORG],
-            project.name,
             key,
         )
         for description in BASE_BUILD_SENSOR_DESCRIPTIONS
@@ -156,17 +154,15 @@ class AzureDevOpsBuildSensor(AzureDevOpsEntity, SensorEntity):
 
     def __init__(
         self,
-        coordinator: DataUpdateCoordinator[list[DevOpsBuild]],
+        coordinator: AzureDevOpsDataUpdateCoordinator,
         description: AzureDevOpsBuildSensorEntityDescription,
-        organization: str,
-        project_name: str,
         item_key: int,
     ) -> None:
         """Initialize."""
-        super().__init__(coordinator, organization, project_name)
+        super().__init__(coordinator)
         self.entity_description = description
         self.item_key = item_key
-        self._attr_unique_id = f"{organization}_{self.build.project.project_id}_{self.build.definition.build_id}_{description.key}"
+        self._attr_unique_id = f"{self.coordinator.data.organization}_{self.build.project.project_id}_{self.build.definition.build_id}_{description.key}"
         self._attr_translation_placeholders = {
             "definition_name": self.build.definition.name
         }
@@ -174,7 +170,7 @@ class AzureDevOpsBuildSensor(AzureDevOpsEntity, SensorEntity):
     @property
     def build(self) -> DevOpsBuild:
         """Return the build."""
-        return self.coordinator.data[self.item_key]
+        return self.coordinator.data.builds[self.item_key]
 
     @property
     def native_value(self) -> datetime | StateType:
diff --git a/tests/components/azure_devops/conftest.py b/tests/components/azure_devops/conftest.py
index 29569da2c90..97e113bbb39 100644
--- a/tests/components/azure_devops/conftest.py
+++ b/tests/components/azure_devops/conftest.py
@@ -1,9 +1,9 @@
 """Test fixtures for Azure DevOps."""
 
+from collections.abc import AsyncGenerator, Generator
 from unittest.mock import AsyncMock, MagicMock, patch
 
 import pytest
-from typing_extensions import AsyncGenerator, Generator
 
 from homeassistant.components.azure_devops.const import DOMAIN
 
@@ -18,7 +18,8 @@ async def mock_devops_client() -> AsyncGenerator[MagicMock]:
 
     with (
         patch(
-            "homeassistant.components.azure_devops.DevOpsClient", autospec=True
+            "homeassistant.components.azure_devops.coordinator.DevOpsClient",
+            autospec=True,
         ) as mock_client,
         patch(
             "homeassistant.components.azure_devops.config_flow.DevOpsClient",
@@ -54,5 +55,5 @@ def mock_setup_entry() -> Generator[AsyncMock]:
     with patch(
         "homeassistant.components.azure_devops.async_setup_entry",
         return_value=True,
-    ) as mock_setup_entry:
-        yield mock_setup_entry
+    ) as mock_entry:
+        yield mock_entry
diff --git a/tests/components/azure_devops/test_init.py b/tests/components/azure_devops/test_init.py
index 240edee82d7..a7655042f25 100644
--- a/tests/components/azure_devops/test_init.py
+++ b/tests/components/azure_devops/test_init.py
@@ -48,7 +48,22 @@ async def test_auth_failed(
     assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR
 
 
-async def test_update_failed(
+async def test_update_failed_project(
+    hass: HomeAssistant,
+    mock_config_entry: MockConfigEntry,
+    mock_devops_client: MagicMock,
+) -> None:
+    """Test a failed update entry."""
+    mock_devops_client.get_project.side_effect = aiohttp.ClientError
+
+    await setup_integration(hass, mock_config_entry)
+
+    assert mock_devops_client.get_project.call_count == 1
+
+    assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR
+
+
+async def test_update_failed_builds(
     hass: HomeAssistant,
     mock_config_entry: MockConfigEntry,
     mock_devops_client: MagicMock,
-- 
GitLab