Add work items per type and state counter sensors to Azure DevOps (#119737)

* Add work item data

* Add work item sensors

* Add icon

* Add test fixtures

* Add none return tests

* Apply suggestions from code review

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Apply suggestion

* Use icon translations

* Apply suggestions from code review

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update test

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Aidan Timson 2024-08-30 15:45:46 +01:00 committed by GitHub
parent 240bd6c3bf
commit 1d05a917f9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 253 additions and 7 deletions

View file

@ -6,8 +6,14 @@ import logging
from typing import Final from typing import Final
from aioazuredevops.client import DevOpsClient 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.build import Build
from aioazuredevops.models.core import Project from aioazuredevops.models.core import Project
from aioazuredevops.models.work_item_type import Category
import aiohttp import aiohttp
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@ -20,6 +26,7 @@ from .const import CONF_ORG, DOMAIN
from .data import AzureDevOpsData from .data import AzureDevOpsData
BUILDS_QUERY: Final = "?queryOrder=queueTimeDescending&maxBuildsPerDefinition=1" BUILDS_QUERY: Final = "?queryOrder=queueTimeDescending&maxBuildsPerDefinition=1"
IGNORED_CATEGORIES: Final[list[Category]] = [Category.COMPLETED, Category.REMOVED]
def ado_exception_none_handler(func: Callable) -> Callable: def ado_exception_none_handler(func: Callable) -> Callable:
@ -105,13 +112,60 @@ class AzureDevOpsDataUpdateCoordinator(DataUpdateCoordinator[AzureDevOpsData]):
BUILDS_QUERY, 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: async def _async_update_data(self) -> AzureDevOpsData:
"""Fetch data from Azure DevOps.""" """Fetch data from Azure DevOps."""
# Get the builds from the project # Get the builds from the project
builds = await self._get_builds(self.project.name) builds = await self._get_builds(self.project.name)
work_items = await self._get_work_items(self.project.name)
return AzureDevOpsData( return AzureDevOpsData(
organization=self.organization, organization=self.organization,
project=self.project, project=self.project,
builds=builds, builds=builds,
work_items=work_items,
) )

View file

@ -2,6 +2,7 @@
from dataclasses import dataclass from dataclasses import dataclass
from aioazuredevops.helper import WorkItemTypeAndState
from aioazuredevops.models.build import Build from aioazuredevops.models.build import Build
from aioazuredevops.models.core import Project from aioazuredevops.models.core import Project
@ -13,3 +14,4 @@ class AzureDevOpsData:
organization: str organization: str
project: Project project: Project
builds: list[Build] builds: list[Build]
work_items: list[WorkItemTypeAndState]

View file

@ -3,6 +3,9 @@
"sensor": { "sensor": {
"latest_build": { "latest_build": {
"default": "mdi:pipe" "default": "mdi:pipe"
},
"work_item_count": {
"default": "mdi:ticket"
} }
} }
} }

View file

@ -8,6 +8,7 @@ from datetime import datetime
import logging import logging
from typing import Any from typing import Any
from aioazuredevops.helper import WorkItemState, WorkItemTypeAndState
from aioazuredevops.models.build import Build from aioazuredevops.models.build import Build
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
@ -29,12 +30,19 @@ _LOGGER = logging.getLogger(__name__)
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
class AzureDevOpsBuildSensorEntityDescription(SensorEntityDescription): 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 attr_fn: Callable[[Build], dict[str, Any] | None] = lambda _: None
value_fn: Callable[[Build], datetime | StateType] 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, ...] = ( BASE_BUILD_SENSOR_DESCRIPTIONS: tuple[AzureDevOpsBuildSensorEntityDescription, ...] = (
# Attributes are deprecated in 2024.7 and can be removed in 2025.1 # Attributes are deprecated in 2024.7 and can be removed in 2025.1
AzureDevOpsBuildSensorEntityDescription( 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: def parse_datetime(value: str | None) -> datetime | None:
"""Parse datetime string.""" """Parse datetime string."""
@ -134,7 +152,7 @@ async def async_setup_entry(
coordinator = entry.runtime_data coordinator = entry.runtime_data
initial_builds: list[Build] = coordinator.data.builds initial_builds: list[Build] = coordinator.data.builds
async_add_entities( entities: list[SensorEntity] = [
AzureDevOpsBuildSensor( AzureDevOpsBuildSensor(
coordinator, coordinator,
description, description,
@ -143,8 +161,22 @@ async def async_setup_entry(
for description in BASE_BUILD_SENSOR_DESCRIPTIONS for description in BASE_BUILD_SENSOR_DESCRIPTIONS
for key, build in enumerate(initial_builds) for key, build in enumerate(initial_builds)
if build.project and build.definition 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): class AzureDevOpsBuildSensor(AzureDevOpsEntity, SensorEntity):
"""Define a Azure DevOps build sensor.""" """Define a Azure DevOps build sensor."""
@ -162,8 +194,8 @@ class AzureDevOpsBuildSensor(AzureDevOpsEntity, SensorEntity):
self.entity_description = description self.entity_description = description
self.item_key = item_key self.item_key = item_key
self._attr_unique_id = ( self._attr_unique_id = (
f"{self.coordinator.data.organization}_" f"{coordinator.data.organization}_"
f"{self.build.project.id}_" f"{coordinator.data.project.id}_"
f"{self.build.definition.build_id}_" f"{self.build.definition.build_id}_"
f"{description.key}" f"{description.key}"
) )
@ -185,3 +217,48 @@ class AzureDevOpsBuildSensor(AzureDevOpsEntity, SensorEntity):
def extra_state_attributes(self) -> Mapping[str, Any] | None: def extra_state_attributes(self) -> Mapping[str, Any] | None:
"""Return the state attributes of the entity.""" """Return the state attributes of the entity."""
return self.entity_description.attr_fn(self.build) 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)

View file

@ -60,6 +60,9 @@
}, },
"url": { "url": {
"name": "{definition_name} latest build url" "name": "{definition_name} latest build url"
},
"work_item_count": {
"name": "{item_type} {item_state} work items"
} }
} }
}, },

View file

@ -1,9 +1,12 @@
"""Tests for the Azure DevOps integration.""" """Tests for the Azure DevOps integration."""
from datetime import datetime
from typing import Final from typing import Final
from aioazuredevops.models.build import Build, BuildDefinition from aioazuredevops.models.build import Build, BuildDefinition
from aioazuredevops.models.core import Project 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.components.azure_devops.const import CONF_ORG, CONF_PAT, CONF_PROJECT
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@ -77,6 +80,55 @@ DEVOPS_BUILD_MISSING_PROJECT_DEFINITION = Build(
build_id=9876, 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( async def setup_integration(
hass: HomeAssistant, hass: HomeAssistant,

View file

@ -7,7 +7,16 @@ import pytest
from homeassistant.components.azure_devops.const import DOMAIN 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 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_project.return_value = DEVOPS_PROJECT
devops_client.get_builds.return_value = [DEVOPS_BUILD] devops_client.get_builds.return_value = [DEVOPS_BUILD]
devops_client.get_build.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_item_types.return_value = DEVOPS_WORK_ITEM_TYPES
devops_client.get_work_items.return_value = None 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 yield devops_client

View file

@ -91,3 +91,48 @@ async def test_no_builds(
assert mock_devops_client.get_builds.call_count == 1 assert mock_devops_client.get_builds.call_count == 1
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY 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