From dcba45e67d2920fa531ef3521e8d0fa1ddf3dd97 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Sat, 25 Jul 2020 23:04:10 +0100 Subject: [PATCH] Add Azure DevOps Integration (#33765) Co-authored-by: Martin Hjelmare --- .coveragerc | 3 + CODEOWNERS | 1 + .../components/azure_devops/__init__.py | 121 +++++++++ .../components/azure_devops/config_flow.py | 134 +++++++++ .../components/azure_devops/const.py | 11 + .../components/azure_devops/manifest.json | 8 + .../components/azure_devops/sensor.py | 148 ++++++++++ .../components/azure_devops/strings.json | 33 +++ .../azure_devops/translations/en.json | 33 +++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/azure_devops/__init__.py | 1 + .../azure_devops/test_config_flow.py | 257 ++++++++++++++++++ 14 files changed, 757 insertions(+) create mode 100644 homeassistant/components/azure_devops/__init__.py create mode 100644 homeassistant/components/azure_devops/config_flow.py create mode 100644 homeassistant/components/azure_devops/const.py create mode 100644 homeassistant/components/azure_devops/manifest.json create mode 100644 homeassistant/components/azure_devops/sensor.py create mode 100644 homeassistant/components/azure_devops/strings.json create mode 100644 homeassistant/components/azure_devops/translations/en.json create mode 100644 tests/components/azure_devops/__init__.py create mode 100644 tests/components/azure_devops/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 7384ee23245..eb24287069e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -72,6 +72,9 @@ omit = homeassistant/components/avion/light.py homeassistant/components/avri/const.py homeassistant/components/avri/sensor.py + homeassistant/components/azure_devops/__init__.py + homeassistant/components/azure_devops/const.py + homeassistant/components/azure_devops/sensor.py homeassistant/components/azure_service_bus/* homeassistant/components/baidu/tts.py homeassistant/components/beewi_smartclim/sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 3b17bca9595..f488eace2bd 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -49,6 +49,7 @@ homeassistant/components/avri/* @timvancann homeassistant/components/awair/* @ahayworth @danielsjf homeassistant/components/aws/* @awarecan homeassistant/components/axis/* @Kane610 +homeassistant/components/azure_devops/* @timmo001 homeassistant/components/azure_event_hub/* @eavanvalkenburg homeassistant/components/azure_service_bus/* @hfurubotten homeassistant/components/beewi_smartclim/* @alemuro diff --git a/homeassistant/components/azure_devops/__init__.py b/homeassistant/components/azure_devops/__init__.py new file mode 100644 index 00000000000..00f08496dd3 --- /dev/null +++ b/homeassistant/components/azure_devops/__init__.py @@ -0,0 +1,121 @@ +"""Support for Azure DevOps.""" +import logging +from typing import Any, Dict + +from aioazuredevops.client import DevOpsClient +import aiohttp + +from homeassistant.components.azure_devops.const import ( + CONF_ORG, + CONF_PAT, + CONF_PROJECT, + DATA_AZURE_DEVOPS_CLIENT, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import ConfigType, HomeAssistantType + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: + """Set up the Azure DevOps components.""" + return True + + +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: + """Set up Azure DevOps from a config entry.""" + client = DevOpsClient() + + try: + if entry.data[CONF_PAT] is not None: + await client.authorize(entry.data[CONF_PAT], entry.data[CONF_ORG]) + if not client.authorized: + _LOGGER.warning( + "Could not authorize with Azure DevOps. You may need to update your token" + ) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": "reauth"}, data=entry.data, + ) + ) + return False + await client.get_project(entry.data[CONF_ORG], entry.data[CONF_PROJECT]) + except aiohttp.ClientError as exception: + _LOGGER.warning(exception) + raise ConfigEntryNotReady from exception + + instance_key = f"{DOMAIN}_{entry.data[CONF_ORG]}_{entry.data[CONF_PROJECT]}" + hass.data.setdefault(instance_key, {})[DATA_AZURE_DEVOPS_CLIENT] = client + + # Setup components + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, "sensor") + ) + + return True + + +async def async_unload_entry(hass: HomeAssistantType, entry: ConfigType) -> bool: + """Unload Azure DevOps config entry.""" + del hass.data[f"{DOMAIN}_{entry.data[CONF_ORG]}_{entry.data[CONF_PROJECT]}"] + + return await hass.config_entries.async_forward_entry_unload(entry, "sensor") + + +class AzureDevOpsEntity(Entity): + """Defines a base Azure DevOps entity.""" + + def __init__(self, organization: str, project: str, name: str, icon: str) -> None: + """Initialize the Azure DevOps entity.""" + self._name = name + self._icon = icon + self._available = True + self.organization = organization + self.project = project + + @property + def name(self) -> str: + """Return the name of the entity.""" + return self._name + + @property + def icon(self) -> str: + """Return the mdi icon of the entity.""" + return self._icon + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._available + + async def async_update(self) -> None: + """Update Azure DevOps entity.""" + if await self._azure_devops_update(): + self._available = True + else: + if self._available: + _LOGGER.debug( + "An error occurred while updating Azure DevOps sensor.", + exc_info=True, + ) + self._available = False + + async def _azure_devops_update(self) -> None: + """Update Azure DevOps entity.""" + raise NotImplementedError() + + +class AzureDevOpsDeviceEntity(AzureDevOpsEntity): + """Defines a Azure DevOps device entity.""" + + @property + def device_info(self) -> Dict[str, Any]: + """Return device information about this Azure DevOps instance.""" + return { + "identifiers": {(DOMAIN, self.organization, self.project,)}, + "manufacturer": self.organization, + "name": self.project, + } diff --git a/homeassistant/components/azure_devops/config_flow.py b/homeassistant/components/azure_devops/config_flow.py new file mode 100644 index 00000000000..69030871b8d --- /dev/null +++ b/homeassistant/components/azure_devops/config_flow.py @@ -0,0 +1,134 @@ +"""Config flow to configure the Azure DevOps integration.""" +import logging + +from aioazuredevops.client import DevOpsClient +import aiohttp +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.azure_devops.const import ( # pylint:disable=unused-import + CONF_ORG, + CONF_PAT, + CONF_PROJECT, + DOMAIN, +) +from homeassistant.config_entries import ConfigFlow + +_LOGGER = logging.getLogger(__name__) + + +class AzureDevOpsFlowHandler(ConfigFlow, domain=DOMAIN): + """Handle a Azure DevOps config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self): + """Initialize config flow.""" + self._organization = None + self._project = None + self._pat = None + + async def _show_setup_form(self, errors=None): + """Show the setup form to the user.""" + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_ORG, default=self._organization): str, + vol.Required(CONF_PROJECT, default=self._project): str, + vol.Optional(CONF_PAT): str, + } + ), + errors=errors or {}, + ) + + async def _show_reauth_form(self, errors=None): + """Show the reauth form to the user.""" + return self.async_show_form( + step_id="reauth", + description_placeholders={ + "project_url": f"{self._organization}/{self._project}" + }, + data_schema=vol.Schema({vol.Required(CONF_PAT): str}), + errors=errors or {}, + ) + + async def _check_setup(self): + """Check the setup of the flow.""" + errors = {} + + client = DevOpsClient() + + try: + if self._pat is not None: + await client.authorize(self._pat, self._organization) + if not client.authorized: + errors["base"] = "authorization_error" + return errors + project_info = await client.get_project(self._organization, self._project) + if project_info is None: + errors["base"] = "project_error" + return errors + except aiohttp.ClientError: + errors["base"] = "connection_error" + return errors + return None + + async def async_step_user(self, user_input=None): + """Handle a flow initiated by the user.""" + if user_input is None: + return await self._show_setup_form(user_input) + + self._organization = user_input[CONF_ORG] + self._project = user_input[CONF_PROJECT] + self._pat = user_input.get(CONF_PAT) + + await self.async_set_unique_id(f"{self._organization}_{self._project}") + self._abort_if_unique_id_configured() + + errors = await self._check_setup() + if errors is not None: + return await self._show_setup_form(errors) + return self._async_create_entry() + + async def async_step_reauth(self, user_input): + """Handle configuration by re-auth.""" + if user_input.get(CONF_ORG) and user_input.get(CONF_PROJECT): + self._organization = user_input[CONF_ORG] + self._project = user_input[CONF_PROJECT] + self._pat = user_input[CONF_PAT] + + # pylint: disable=no-member + self.context["title_placeholders"] = { + "project_url": f"{self._organization}/{self._project}", + } + + await self.async_set_unique_id(f"{self._organization}_{self._project}") + + errors = await self._check_setup() + if errors is not None: + return await self._show_reauth_form(errors) + + for entry in self._async_current_entries(): + if entry.unique_id == self.unique_id: + self.hass.config_entries.async_update_entry( + entry, + data={ + CONF_ORG: self._organization, + CONF_PROJECT: self._project, + CONF_PAT: self._pat, + }, + ) + return self.async_abort(reason="reauth_successful") + + def _async_create_entry(self): + """Handle create entry.""" + return self.async_create_entry( + title=f"{self._organization}/{self._project}", + data={ + CONF_ORG: self._organization, + CONF_PROJECT: self._project, + CONF_PAT: self._pat, + }, + ) diff --git a/homeassistant/components/azure_devops/const.py b/homeassistant/components/azure_devops/const.py new file mode 100644 index 00000000000..40610ba7baa --- /dev/null +++ b/homeassistant/components/azure_devops/const.py @@ -0,0 +1,11 @@ +"""Constants for the Azure DevOps integration.""" +DOMAIN = "azure_devops" + +DATA_AZURE_DEVOPS_CLIENT = "azure_devops_client" +DATA_ORG = "organization" +DATA_PROJECT = "project" +DATA_PAT = "personal_access_token" + +CONF_ORG = "organization" +CONF_PROJECT = "project" +CONF_PAT = "personal_access_token" diff --git a/homeassistant/components/azure_devops/manifest.json b/homeassistant/components/azure_devops/manifest.json new file mode 100644 index 00000000000..be0d2fb0fbe --- /dev/null +++ b/homeassistant/components/azure_devops/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "azure_devops", + "name": "Azure DevOps", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/azure_devops", + "requirements": ["aioazuredevops==1.3.4"], + "codeowners": ["@timmo001"] +} diff --git a/homeassistant/components/azure_devops/sensor.py b/homeassistant/components/azure_devops/sensor.py new file mode 100644 index 00000000000..6f259afb9a9 --- /dev/null +++ b/homeassistant/components/azure_devops/sensor.py @@ -0,0 +1,148 @@ +"""Support for Azure DevOps sensors.""" +from datetime import timedelta +import logging +from typing import List + +from aioazuredevops.builds import DevOpsBuild +from aioazuredevops.client import DevOpsClient +import aiohttp + +from homeassistant.components.azure_devops import AzureDevOpsDeviceEntity +from homeassistant.components.azure_devops.const import ( + CONF_ORG, + CONF_PROJECT, + DATA_AZURE_DEVOPS_CLIENT, + DATA_ORG, + DATA_PROJECT, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.typing import HomeAssistantType + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=300) +PARALLEL_UPDATES = 4 + +BUILDS_QUERY = "?queryOrder=queueTimeDescending&maxBuildsPerDefinition=1" + + +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +) -> None: + """Set up Azure DevOps sensor based on a config entry.""" + instance_key = f"{DOMAIN}_{entry.data[CONF_ORG]}_{entry.data[CONF_PROJECT]}" + client = hass.data[instance_key][DATA_AZURE_DEVOPS_CLIENT] + organization = entry.data[DATA_ORG] + project = entry.data[DATA_PROJECT] + sensors = [] + + try: + builds: List[DevOpsBuild] = await client.get_builds( + organization, project, BUILDS_QUERY + ) + except aiohttp.ClientError as exception: + _LOGGER.warning(exception) + raise PlatformNotReady from exception + + for build in builds: + sensors.append( + AzureDevOpsLatestBuildSensor(client, organization, project, build) + ) + + async_add_entities(sensors, True) + + +class AzureDevOpsSensor(AzureDevOpsDeviceEntity): + """Defines a Azure DevOps sensor.""" + + def __init__( + self, + client: DevOpsClient, + organization: str, + project: str, + key: str, + name: str, + icon: str, + measurement: str = "", + unit_of_measurement: str = "", + ) -> None: + """Initialize Azure DevOps sensor.""" + self._state = None + self._attributes = None + self._available = False + self._unit_of_measurement = unit_of_measurement + self.measurement = measurement + self.client = client + self.organization = organization + self.project = project + self.key = key + + super().__init__(organization, project, name, icon) + + @property + def unique_id(self) -> str: + """Return the unique ID for this sensor.""" + return "_".join([self.organization, self.key]) + + @property + def state(self) -> str: + """Return the state of the sensor.""" + return self._state + + @property + def device_state_attributes(self) -> object: + """Return the attributes of the sensor.""" + return self._attributes + + @property + def unit_of_measurement(self) -> str: + """Return the unit this state is expressed in.""" + return self._unit_of_measurement + + +class AzureDevOpsLatestBuildSensor(AzureDevOpsSensor): + """Defines a Azure DevOps card count sensor.""" + + def __init__( + self, client: DevOpsClient, organization: str, project: str, build: DevOpsBuild + ): + """Initialize Azure DevOps sensor.""" + self.build: DevOpsBuild = build + super().__init__( + client, + organization, + project, + f"{build.project.id}_{build.definition.id}_latest_build", + f"{build.project.name} {build.definition.name} Latest Build", + "mdi:pipe", + ) + + async def _azure_devops_update(self) -> bool: + """Update Azure DevOps entity.""" + try: + build: DevOpsBuild = await self.client.get_build( + self.organization, self.project, self.build.id + ) + except aiohttp.ClientError as exception: + _LOGGER.warning(exception) + self._available = False + return False + self._state = build.build_number + self._attributes = { + "definition_id": build.definition.id, + "definition_name": build.definition.name, + "id": build.id, + "reason": build.reason, + "result": build.result, + "source_branch": build.source_branch, + "source_version": build.source_version, + "status": build.status, + "url": build.links.web, + "queue_time": build.queue_time, + "start_time": build.start_time, + "finish_time": build.finish_time, + } + self._available = True + return True diff --git a/homeassistant/components/azure_devops/strings.json b/homeassistant/components/azure_devops/strings.json new file mode 100644 index 00000000000..2bb53010153 --- /dev/null +++ b/homeassistant/components/azure_devops/strings.json @@ -0,0 +1,33 @@ +{ + "config": { + "flow_title": "Azure DevOps: {project_url}", + "error": { + "authorization_error": "Authorization error. Check you have access to the project and have the correct credentials.", + "connection_error": "Could not connect to Azure DevOps.", + "project_error": "Could not get project info." + }, + "step": { + "user": { + "data": { + "organization": "Organization", + "project": "Project", + "personal_access_token": "Personal Access Token (PAT)" + }, + "description": "Set up an Azure DevOps instance to access your project. A Personal Access Token is only required for a private project.", + "title": "Add Azure DevOps Project" + }, + "reauth": { + "data": { + "personal_access_token": "Personal Access Token (PAT)" + }, + "description": "Authentication failed for {project_url}. Please enter your current credentials.", + "title": "Reauthentication" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::data::access_token%] updated successfully" + } + }, + "title": "Azure DevOps" +} diff --git a/homeassistant/components/azure_devops/translations/en.json b/homeassistant/components/azure_devops/translations/en.json new file mode 100644 index 00000000000..2bb53010153 --- /dev/null +++ b/homeassistant/components/azure_devops/translations/en.json @@ -0,0 +1,33 @@ +{ + "config": { + "flow_title": "Azure DevOps: {project_url}", + "error": { + "authorization_error": "Authorization error. Check you have access to the project and have the correct credentials.", + "connection_error": "Could not connect to Azure DevOps.", + "project_error": "Could not get project info." + }, + "step": { + "user": { + "data": { + "organization": "Organization", + "project": "Project", + "personal_access_token": "Personal Access Token (PAT)" + }, + "description": "Set up an Azure DevOps instance to access your project. A Personal Access Token is only required for a private project.", + "title": "Add Azure DevOps Project" + }, + "reauth": { + "data": { + "personal_access_token": "Personal Access Token (PAT)" + }, + "description": "Authentication failed for {project_url}. Please enter your current credentials.", + "title": "Reauthentication" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::data::access_token%] updated successfully" + } + }, + "title": "Azure DevOps" +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index a686116229e..736f16e3581 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -22,6 +22,7 @@ FLOWS = [ "avri", "awair", "axis", + "azure_devops", "blebox", "blink", "bond", diff --git a/requirements_all.txt b/requirements_all.txt index 8f9d85e1524..dcf12d9b274 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -147,6 +147,9 @@ aioambient==1.1.1 # homeassistant.components.asuswrt aioasuswrt==1.2.7 +# homeassistant.components.azure_devops +aioazuredevops==1.3.4 + # homeassistant.components.aws aiobotocore==0.11.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1e235e38aba..7d8bb75841e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -75,6 +75,9 @@ aioambient==1.1.1 # homeassistant.components.asuswrt aioasuswrt==1.2.7 +# homeassistant.components.azure_devops +aioazuredevops==1.3.4 + # homeassistant.components.aws aiobotocore==0.11.1 diff --git a/tests/components/azure_devops/__init__.py b/tests/components/azure_devops/__init__.py new file mode 100644 index 00000000000..da15bc6723d --- /dev/null +++ b/tests/components/azure_devops/__init__.py @@ -0,0 +1 @@ +"""Tests for the Azure DevOps integration.""" diff --git a/tests/components/azure_devops/test_config_flow.py b/tests/components/azure_devops/test_config_flow.py new file mode 100644 index 00000000000..b89c9cb69aa --- /dev/null +++ b/tests/components/azure_devops/test_config_flow.py @@ -0,0 +1,257 @@ +"""Test the Azure DevOps config flow.""" +from aioazuredevops.core import DevOpsProject +import aiohttp + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.azure_devops.const import ( + CONF_ORG, + CONF_PAT, + CONF_PROJECT, + DOMAIN, +) +from homeassistant.core import HomeAssistant + +from tests.async_mock import patch +from tests.common import MockConfigEntry + +FIXTURE_REAUTH_INPUT = {CONF_PAT: "abc123"} +FIXTURE_USER_INPUT = {CONF_ORG: "random", CONF_PROJECT: "project", CONF_PAT: "abc123"} + +UNIQUE_ID = "random_project" + + +async def test_show_user_form(hass: HomeAssistant) -> None: + """Test that the setup form is served.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + +async def test_authorization_error(hass: HomeAssistant) -> None: + """Test we show user form on Azure DevOps authorization error.""" + with patch( + "homeassistant.components.azure_devops.config_flow.DevOpsClient.authorize", + return_value=False, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "authorization_error"} + + +async def test_reauth_authorization_error(hass: HomeAssistant) -> None: + """Test we show user form on Azure DevOps authorization error.""" + with patch( + "homeassistant.components.azure_devops.config_flow.DevOpsClient.authorize", + return_value=False, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "reauth"}, data=FIXTURE_USER_INPUT + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_REAUTH_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "reauth" + assert result2["errors"] == {"base": "authorization_error"} + + +async def test_connection_error(hass: HomeAssistant) -> None: + """Test we show user form on Azure DevOps connection error.""" + with patch( + "homeassistant.components.azure_devops.config_flow.DevOpsClient.authorize", + side_effect=aiohttp.ClientError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "connection_error"} + + +async def test_reauth_connection_error(hass: HomeAssistant) -> None: + """Test we show user form on Azure DevOps connection error.""" + with patch( + "homeassistant.components.azure_devops.config_flow.DevOpsClient.authorize", + side_effect=aiohttp.ClientError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "reauth"}, data=FIXTURE_USER_INPUT + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_REAUTH_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "reauth" + assert result2["errors"] == {"base": "connection_error"} + + +async def test_project_error(hass: HomeAssistant) -> None: + """Test we show user form on Azure DevOps connection error.""" + with patch( + "homeassistant.components.azure_devops.config_flow.DevOpsClient.authorized", + return_value=True, + ), patch( + "homeassistant.components.azure_devops.config_flow.DevOpsClient.authorize", + ), patch( + "homeassistant.components.azure_devops.config_flow.DevOpsClient.get_project", + return_value=None, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "project_error"} + + +async def test_reauth_project_error(hass: HomeAssistant) -> None: + """Test we show user form on Azure DevOps project error.""" + with patch( + "homeassistant.components.azure_devops.config_flow.DevOpsClient.authorize", + ), patch( + "homeassistant.components.azure_devops.config_flow.DevOpsClient.authorized", + return_value=True, + ), patch( + "homeassistant.components.azure_devops.config_flow.DevOpsClient.get_project", + return_value=None, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "reauth"}, data=FIXTURE_USER_INPUT + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_REAUTH_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "reauth" + assert result2["errors"] == {"base": "project_error"} + + +async def test_reauth_flow(hass: HomeAssistant) -> None: + """Test reauth works.""" + with patch( + "homeassistant.components.azure_devops.config_flow.DevOpsClient.authorize", + return_value=False, + ): + mock_config = MockConfigEntry( + domain=DOMAIN, unique_id=UNIQUE_ID, data=FIXTURE_USER_INPUT + ) + mock_config.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "reauth"}, data=FIXTURE_USER_INPUT + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth" + assert result["errors"] == {"base": "authorization_error"} + + with patch( + "homeassistant.components.azure_devops.config_flow.DevOpsClient.authorize", + ), patch( + "homeassistant.components.azure_devops.config_flow.DevOpsClient.authorized", + return_value=True, + ), patch( + "homeassistant.components.azure_devops.config_flow.DevOpsClient.get_project", + return_value=DevOpsProject( + "abcd-abcd-abcd-abcd", FIXTURE_USER_INPUT[CONF_PROJECT] + ), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_REAUTH_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result2["reason"] == "reauth_successful" + + +async def test_full_flow_implementation(hass: HomeAssistant) -> None: + """Test registering an integration and finishing flow works.""" + with patch( + "homeassistant.components.azure_devops.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.azure_devops.async_setup_entry", return_value=True, + ) as mock_setup_entry, patch( + "homeassistant.components.azure_devops.config_flow.DevOpsClient.authorized", + return_value=True, + ), patch( + "homeassistant.components.azure_devops.config_flow.DevOpsClient.authorize", + ), patch( + "homeassistant.components.azure_devops.config_flow.DevOpsClient.get_project", + return_value=DevOpsProject( + "abcd-abcd-abcd-abcd", FIXTURE_USER_INPUT[CONF_PROJECT] + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT, + ) + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert ( + result2["title"] + == f"{FIXTURE_USER_INPUT[CONF_ORG]}/{FIXTURE_USER_INPUT[CONF_PROJECT]}" + ) + assert result2["data"][CONF_ORG] == FIXTURE_USER_INPUT[CONF_ORG] + assert result2["data"][CONF_PROJECT] == FIXTURE_USER_INPUT[CONF_PROJECT]