diff --git a/homeassistant/components/azure_devops/coordinator.py b/homeassistant/components/azure_devops/coordinator.py index 2460a9bbfce..21fb76560c3 100644 --- a/homeassistant/components/azure_devops/coordinator.py +++ b/homeassistant/components/azure_devops/coordinator.py @@ -6,8 +6,14 @@ import logging from typing import Final from aioazuredevops.client import DevOpsClient +from aioazuredevops.helper import ( + WorkItemTypeAndState, + work_item_types_states_filter, + work_items_by_type_and_state, +) from aioazuredevops.models.build import Build from aioazuredevops.models.core import Project +from aioazuredevops.models.work_item_type import Category import aiohttp from homeassistant.config_entries import ConfigEntry @@ -20,6 +26,7 @@ from .const import CONF_ORG, DOMAIN from .data import AzureDevOpsData BUILDS_QUERY: Final = "?queryOrder=queueTimeDescending&maxBuildsPerDefinition=1" +IGNORED_CATEGORIES: Final[list[Category]] = [Category.COMPLETED, Category.REMOVED] def ado_exception_none_handler(func: Callable) -> Callable: @@ -105,13 +112,60 @@ class AzureDevOpsDataUpdateCoordinator(DataUpdateCoordinator[AzureDevOpsData]): BUILDS_QUERY, ) + @ado_exception_none_handler + async def _get_work_items( + self, project_name: str + ) -> list[WorkItemTypeAndState] | None: + """Get the work items.""" + + if ( + work_item_types := await self.client.get_work_item_types( + self.organization, + project_name, + ) + ) is None: + # If no work item types are returned, return an empty list + return [] + + if ( + work_item_ids := await self.client.get_work_item_ids( + self.organization, + project_name, + # Filter out completed and removed work items so we only get active work items + states=work_item_types_states_filter( + work_item_types, + ignored_categories=IGNORED_CATEGORIES, + ), + ) + ) is None: + # If no work item ids are returned, return an empty list + return [] + + if ( + work_items := await self.client.get_work_items( + self.organization, + project_name, + work_item_ids, + ) + ) is None: + # If no work items are returned, return an empty list + return [] + + return work_items_by_type_and_state( + work_item_types, + work_items, + ignored_categories=IGNORED_CATEGORIES, + ) + 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) + work_items = await self._get_work_items(self.project.name) return AzureDevOpsData( organization=self.organization, project=self.project, builds=builds, + work_items=work_items, ) diff --git a/homeassistant/components/azure_devops/data.py b/homeassistant/components/azure_devops/data.py index c2da38ccc09..ff34bc90c24 100644 --- a/homeassistant/components/azure_devops/data.py +++ b/homeassistant/components/azure_devops/data.py @@ -2,6 +2,7 @@ from dataclasses import dataclass +from aioazuredevops.helper import WorkItemTypeAndState from aioazuredevops.models.build import Build from aioazuredevops.models.core import Project @@ -13,3 +14,4 @@ class AzureDevOpsData: organization: str project: Project builds: list[Build] + work_items: list[WorkItemTypeAndState] diff --git a/homeassistant/components/azure_devops/icons.json b/homeassistant/components/azure_devops/icons.json index de720b46106..ea6b4c632ea 100644 --- a/homeassistant/components/azure_devops/icons.json +++ b/homeassistant/components/azure_devops/icons.json @@ -3,6 +3,9 @@ "sensor": { "latest_build": { "default": "mdi:pipe" + }, + "work_item_count": { + "default": "mdi:ticket" } } } diff --git a/homeassistant/components/azure_devops/sensor.py b/homeassistant/components/azure_devops/sensor.py index 7b7af1dd666..fd47115214a 100644 --- a/homeassistant/components/azure_devops/sensor.py +++ b/homeassistant/components/azure_devops/sensor.py @@ -8,6 +8,7 @@ from datetime import datetime import logging from typing import Any +from aioazuredevops.helper import WorkItemState, WorkItemTypeAndState from aioazuredevops.models.build import Build from homeassistant.components.sensor import ( @@ -29,12 +30,19 @@ _LOGGER = logging.getLogger(__name__) @dataclass(frozen=True, kw_only=True) class AzureDevOpsBuildSensorEntityDescription(SensorEntityDescription): - """Class describing Azure DevOps base build sensor entities.""" + """Class describing Azure DevOps build sensor entities.""" attr_fn: Callable[[Build], dict[str, Any] | None] = lambda _: None value_fn: Callable[[Build], datetime | StateType] +@dataclass(frozen=True, kw_only=True) +class AzureDevOpsWorkItemSensorEntityDescription(SensorEntityDescription): + """Class describing Azure DevOps work item sensor entities.""" + + value_fn: Callable[[WorkItemState], datetime | StateType] + + BASE_BUILD_SENSOR_DESCRIPTIONS: tuple[AzureDevOpsBuildSensorEntityDescription, ...] = ( # Attributes are deprecated in 2024.7 and can be removed in 2025.1 AzureDevOpsBuildSensorEntityDescription( @@ -116,6 +124,16 @@ BASE_BUILD_SENSOR_DESCRIPTIONS: tuple[AzureDevOpsBuildSensorEntityDescription, . ), ) +BASE_WORK_ITEM_SENSOR_DESCRIPTIONS: tuple[ + AzureDevOpsWorkItemSensorEntityDescription, ... +] = ( + AzureDevOpsWorkItemSensorEntityDescription( + key="work_item_count", + translation_key="work_item_count", + value_fn=lambda work_item_state: len(work_item_state.work_items), + ), +) + def parse_datetime(value: str | None) -> datetime | None: """Parse datetime string.""" @@ -134,7 +152,7 @@ async def async_setup_entry( coordinator = entry.runtime_data initial_builds: list[Build] = coordinator.data.builds - async_add_entities( + entities: list[SensorEntity] = [ AzureDevOpsBuildSensor( coordinator, description, @@ -143,8 +161,22 @@ async def async_setup_entry( for description in BASE_BUILD_SENSOR_DESCRIPTIONS for key, build in enumerate(initial_builds) if build.project and build.definition + ] + + entities.extend( + AzureDevOpsWorkItemSensor( + coordinator, + description, + key, + state_key, + ) + for description in BASE_WORK_ITEM_SENSOR_DESCRIPTIONS + for key, work_item_type_state in enumerate(coordinator.data.work_items) + for state_key, _ in enumerate(work_item_type_state.state_items) ) + async_add_entities(entities) + class AzureDevOpsBuildSensor(AzureDevOpsEntity, SensorEntity): """Define a Azure DevOps build sensor.""" @@ -162,8 +194,8 @@ class AzureDevOpsBuildSensor(AzureDevOpsEntity, SensorEntity): self.entity_description = description self.item_key = item_key self._attr_unique_id = ( - f"{self.coordinator.data.organization}_" - f"{self.build.project.id}_" + f"{coordinator.data.organization}_" + f"{coordinator.data.project.id}_" f"{self.build.definition.build_id}_" f"{description.key}" ) @@ -185,3 +217,48 @@ class AzureDevOpsBuildSensor(AzureDevOpsEntity, SensorEntity): def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return the state attributes of the entity.""" return self.entity_description.attr_fn(self.build) + + +class AzureDevOpsWorkItemSensor(AzureDevOpsEntity, SensorEntity): + """Define a Azure DevOps work item sensor.""" + + entity_description: AzureDevOpsWorkItemSensorEntityDescription + + def __init__( + self, + coordinator: AzureDevOpsDataUpdateCoordinator, + description: AzureDevOpsWorkItemSensorEntityDescription, + wits_key: int, + state_key: int, + ) -> None: + """Initialize.""" + super().__init__(coordinator) + self.entity_description = description + self.wits_key = wits_key + self.state_key = state_key + self._attr_unique_id = ( + f"{coordinator.data.organization}_" + f"{coordinator.data.project.id}_" + f"{self.work_item_type.name}_" + f"{self.work_item_state.name}_" + f"{description.key}" + ) + self._attr_translation_placeholders = { + "item_type": self.work_item_type.name, + "item_state": self.work_item_state.name, + } + + @property + def work_item_type(self) -> WorkItemTypeAndState: + """Return the work item.""" + return self.coordinator.data.work_items[self.wits_key] + + @property + def work_item_state(self) -> WorkItemState: + """Return the work item state.""" + return self.work_item_type.state_items[self.state_key] + + @property + def native_value(self) -> datetime | StateType: + """Return the state.""" + return self.entity_description.value_fn(self.work_item_state) diff --git a/homeassistant/components/azure_devops/strings.json b/homeassistant/components/azure_devops/strings.json index 8a17169fb6b..c5304270396 100644 --- a/homeassistant/components/azure_devops/strings.json +++ b/homeassistant/components/azure_devops/strings.json @@ -60,6 +60,9 @@ }, "url": { "name": "{definition_name} latest build url" + }, + "work_item_count": { + "name": "{item_type} {item_state} work items" } } }, diff --git a/tests/components/azure_devops/__init__.py b/tests/components/azure_devops/__init__.py index cc4732b1495..6414fe0257c 100644 --- a/tests/components/azure_devops/__init__.py +++ b/tests/components/azure_devops/__init__.py @@ -1,9 +1,12 @@ """Tests for the Azure DevOps integration.""" +from datetime import datetime from typing import Final from aioazuredevops.models.build import Build, BuildDefinition from aioazuredevops.models.core import Project +from aioazuredevops.models.work_item import WorkItem, WorkItemFields +from aioazuredevops.models.work_item_type import Category, Icon, State, WorkItemType from homeassistant.components.azure_devops.const import CONF_ORG, CONF_PAT, CONF_PROJECT from homeassistant.core import HomeAssistant @@ -77,6 +80,55 @@ DEVOPS_BUILD_MISSING_PROJECT_DEFINITION = Build( build_id=9876, ) +DEVOPS_WORK_ITEM_TYPES = [ + WorkItemType( + name="Bug", + reference_name="System.Bug", + description="Bug", + color="ff0000", + icon=Icon(id="1234", url="https://example.com/icon.png"), + is_disabled=False, + xml_form="", + fields=[], + field_instances=[], + transitions={}, + states=[ + State(name="New", color="ff0000", category=Category.PROPOSED), + State(name="Active", color="ff0000", category=Category.IN_PROGRESS), + State(name="Resolved", color="ff0000", category=Category.RESOLVED), + State(name="Closed", color="ff0000", category=Category.COMPLETED), + ], + url="", + ) +] + +DEVOPS_WORK_ITEM_IDS = [1] + +DEVOPS_WORK_ITEMS = [ + WorkItem( + id=1, + rev=1, + fields=WorkItemFields( + area_path="", + team_project="", + iteration_path="", + work_item_type="Bug", + state="New", + reason="New", + assigned_to=None, + created_date=datetime(2021, 1, 1), + created_by=None, + changed_date=datetime(2021, 1, 1), + changed_by=None, + comment_count=0, + title="Test", + microsoft_vsts_common_state_change_date=datetime(2021, 1, 1), + microsoft_vsts_common_priority=1, + ), + url="https://example.com", + ) +] + async def setup_integration( hass: HomeAssistant, diff --git a/tests/components/azure_devops/conftest.py b/tests/components/azure_devops/conftest.py index c65adaa4da5..54c730f9523 100644 --- a/tests/components/azure_devops/conftest.py +++ b/tests/components/azure_devops/conftest.py @@ -7,7 +7,16 @@ import pytest from homeassistant.components.azure_devops.const import DOMAIN -from . import DEVOPS_BUILD, DEVOPS_PROJECT, FIXTURE_USER_INPUT, PAT, UNIQUE_ID +from . import ( + DEVOPS_BUILD, + DEVOPS_PROJECT, + DEVOPS_WORK_ITEM_IDS, + DEVOPS_WORK_ITEM_TYPES, + DEVOPS_WORK_ITEMS, + FIXTURE_USER_INPUT, + PAT, + UNIQUE_ID, +) from tests.common import MockConfigEntry @@ -33,8 +42,9 @@ async def mock_devops_client() -> AsyncGenerator[MagicMock]: devops_client.get_project.return_value = DEVOPS_PROJECT devops_client.get_builds.return_value = [DEVOPS_BUILD] devops_client.get_build.return_value = DEVOPS_BUILD - devops_client.get_work_item_ids.return_value = None - devops_client.get_work_items.return_value = None + devops_client.get_work_item_types.return_value = DEVOPS_WORK_ITEM_TYPES + devops_client.get_work_item_ids.return_value = DEVOPS_WORK_ITEM_IDS + devops_client.get_work_items.return_value = DEVOPS_WORK_ITEMS yield devops_client diff --git a/tests/components/azure_devops/test_init.py b/tests/components/azure_devops/test_init.py index a7655042f25..dd512cb12e0 100644 --- a/tests/components/azure_devops/test_init.py +++ b/tests/components/azure_devops/test_init.py @@ -91,3 +91,48 @@ async def test_no_builds( assert mock_devops_client.get_builds.call_count == 1 assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_no_work_item_types( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_devops_client: MagicMock, +) -> None: + """Test a failed update entry.""" + mock_devops_client.get_work_item_types.return_value = None + + await setup_integration(hass, mock_config_entry) + + assert mock_devops_client.get_work_item_types.call_count == 1 + + assert mock_config_entry.state is ConfigEntryState.LOADED + + +async def test_no_work_item_ids( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_devops_client: MagicMock, +) -> None: + """Test a failed update entry.""" + mock_devops_client.get_work_item_ids.return_value = None + + await setup_integration(hass, mock_config_entry) + + assert mock_devops_client.get_work_item_ids.call_count == 1 + + assert mock_config_entry.state is ConfigEntryState.LOADED + + +async def test_no_work_items( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_devops_client: MagicMock, +) -> None: + """Test a failed update entry.""" + mock_devops_client.get_work_items.return_value = None + + await setup_integration(hass, mock_config_entry) + + assert mock_devops_client.get_work_items.call_count == 1 + + assert mock_config_entry.state is ConfigEntryState.LOADED