Compare commits
4 commits
dev
...
github_rea
Author | SHA1 | Date | |
---|---|---|---|
|
4c78115fb3 | ||
|
3ff5bbe0fd | ||
|
912c74058c | ||
|
d5eab6a56f |
7 changed files with 155 additions and 7 deletions
|
@ -27,6 +27,7 @@ import homeassistant.helpers.config_validation as cv
|
||||||
from .const import (
|
from .const import (
|
||||||
CLIENT_ID,
|
CLIENT_ID,
|
||||||
CONF_ACCESS_TOKEN,
|
CONF_ACCESS_TOKEN,
|
||||||
|
CONF_REPO_SCOPE,
|
||||||
CONF_REPOSITORIES,
|
CONF_REPOSITORIES,
|
||||||
DEFAULT_REPOSITORIES,
|
DEFAULT_REPOSITORIES,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
|
@ -78,6 +79,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
self._device: GitHubDeviceAPI | None = None
|
self._device: GitHubDeviceAPI | None = None
|
||||||
self._login: GitHubLoginOauthModel | None = None
|
self._login: GitHubLoginOauthModel | None = None
|
||||||
self._login_device: GitHubLoginDeviceModel | None = None
|
self._login_device: GitHubLoginDeviceModel | None = None
|
||||||
|
self._repo_scope: bool | None = None
|
||||||
|
self._reauth = False
|
||||||
|
|
||||||
async def async_step_user(
|
async def async_step_user(
|
||||||
self,
|
self,
|
||||||
|
@ -87,6 +90,18 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
if self._async_current_entries():
|
if self._async_current_entries():
|
||||||
return self.async_abort(reason="already_configured")
|
return self.async_abort(reason="already_configured")
|
||||||
|
|
||||||
|
if not user_input:
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="user",
|
||||||
|
data_schema=vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Optional(CONF_REPO_SCOPE, default=False): cv.boolean,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self._repo_scope = user_input[CONF_REPO_SCOPE]
|
||||||
|
|
||||||
return await self.async_step_device(user_input)
|
return await self.async_step_device(user_input)
|
||||||
|
|
||||||
async def async_step_device(
|
async def async_step_device(
|
||||||
|
@ -94,6 +109,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
user_input: dict[str, Any] | None = None,
|
user_input: dict[str, Any] | None = None,
|
||||||
) -> FlowResult:
|
) -> FlowResult:
|
||||||
"""Handle device steps."""
|
"""Handle device steps."""
|
||||||
|
if existing_entry := self.hass.config_entries.async_get_entry(
|
||||||
|
self.context["entry_id"]
|
||||||
|
):
|
||||||
|
self._repo_scope = existing_entry.options.get(CONF_REPO_SCOPE, False)
|
||||||
|
|
||||||
async def _wait_for_login() -> None:
|
async def _wait_for_login() -> None:
|
||||||
# mypy is not aware that we can't get here without having these set already
|
# mypy is not aware that we can't get here without having these set already
|
||||||
|
@ -118,7 +137,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = await self._device.register()
|
response = await self._device.register(
|
||||||
|
**{"scope": "repo" if self._repo_scope else ""}
|
||||||
|
)
|
||||||
self._login_device = response.data
|
self._login_device = response.data
|
||||||
except GitHubException as exception:
|
except GitHubException as exception:
|
||||||
LOGGER.exception(exception)
|
LOGGER.exception(exception)
|
||||||
|
@ -141,6 +162,18 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
LOGGER.exception(exception)
|
LOGGER.exception(exception)
|
||||||
return self.async_show_progress_done(next_step_id="could_not_register")
|
return self.async_show_progress_done(next_step_id="could_not_register")
|
||||||
|
|
||||||
|
if existing_entry:
|
||||||
|
# mypy is not aware that we can't get here without having this set already
|
||||||
|
assert self._login is not None
|
||||||
|
|
||||||
|
self.hass.config_entries.async_update_entry(
|
||||||
|
existing_entry, data={CONF_ACCESS_TOKEN: self._login.access_token}
|
||||||
|
)
|
||||||
|
await self.hass.config_entries.async_reload(existing_entry.entry_id)
|
||||||
|
return self.async_show_progress_done(
|
||||||
|
next_step_id="async_step_reauth_completed"
|
||||||
|
)
|
||||||
|
|
||||||
return self.async_show_progress_done(next_step_id="repositories")
|
return self.async_show_progress_done(next_step_id="repositories")
|
||||||
|
|
||||||
async def async_step_repositories(
|
async def async_step_repositories(
|
||||||
|
@ -170,9 +203,31 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
return self.async_create_entry(
|
return self.async_create_entry(
|
||||||
title="",
|
title="",
|
||||||
data={CONF_ACCESS_TOKEN: self._login.access_token},
|
data={CONF_ACCESS_TOKEN: self._login.access_token},
|
||||||
options={CONF_REPOSITORIES: user_input[CONF_REPOSITORIES]},
|
options={
|
||||||
|
CONF_REPOSITORIES: user_input[CONF_REPOSITORIES],
|
||||||
|
CONF_REPO_SCOPE: self._repo_scope,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def async_step_reauth(
|
||||||
|
self,
|
||||||
|
user_input: dict[str, Any] | None = None,
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Perform reauth upon an API authentication error."""
|
||||||
|
return await self.async_step_reauth_confirm()
|
||||||
|
|
||||||
|
async def async_step_reauth_confirm(
|
||||||
|
self,
|
||||||
|
user_input: dict[str, Any] | None = None,
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Dialog that informs the user that reauth is required."""
|
||||||
|
if user_input is None:
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="reauth_confirm",
|
||||||
|
data_schema=vol.Schema({}),
|
||||||
|
)
|
||||||
|
return await self.async_step_device()
|
||||||
|
|
||||||
async def async_step_could_not_register(
|
async def async_step_could_not_register(
|
||||||
self,
|
self,
|
||||||
user_input: dict[str, Any] | None = None,
|
user_input: dict[str, Any] | None = None,
|
||||||
|
@ -180,6 +235,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
"""Handle issues that need transition await from progress step."""
|
"""Handle issues that need transition await from progress step."""
|
||||||
return self.async_abort(reason="could_not_register")
|
return self.async_abort(reason="could_not_register")
|
||||||
|
|
||||||
|
async def async_step_reauth_completed(
|
||||||
|
self,
|
||||||
|
user_input: dict[str, Any] | None = None,
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Abort with success message for reauth complete."""
|
||||||
|
return self.async_abort(reason="reauth_successful")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@callback
|
@callback
|
||||||
def async_get_options_flow(
|
def async_get_options_flow(
|
||||||
|
@ -222,6 +284,12 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
|
||||||
CONF_REPOSITORIES,
|
CONF_REPOSITORIES,
|
||||||
default=configured_repositories,
|
default=configured_repositories,
|
||||||
): cv.multi_select({k: k for k in repositories}),
|
): cv.multi_select({k: k for k in repositories}),
|
||||||
|
vol.Optional(
|
||||||
|
CONF_REPO_SCOPE,
|
||||||
|
default=self.config_entry.options.get(
|
||||||
|
CONF_REPO_SCOPE, False
|
||||||
|
),
|
||||||
|
): cv.boolean,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
@ -18,6 +18,7 @@ DEFAULT_UPDATE_INTERVAL = timedelta(seconds=300)
|
||||||
|
|
||||||
CONF_ACCESS_TOKEN = "access_token"
|
CONF_ACCESS_TOKEN = "access_token"
|
||||||
CONF_REPOSITORIES = "repositories"
|
CONF_REPOSITORIES = "repositories"
|
||||||
|
CONF_REPO_SCOPE = "repo_scope"
|
||||||
|
|
||||||
|
|
||||||
class IssuesPulls(NamedTuple):
|
class IssuesPulls(NamedTuple):
|
||||||
|
|
|
@ -15,9 +15,10 @@ from aiogithubapi import (
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant, T
|
from homeassistant.core import HomeAssistant, T
|
||||||
|
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
|
||||||
from .const import DEFAULT_UPDATE_INTERVAL, DOMAIN, LOGGER, IssuesPulls
|
from .const import CONF_REPO_SCOPE, DEFAULT_UPDATE_INTERVAL, DOMAIN, LOGGER, IssuesPulls
|
||||||
|
|
||||||
CoordinatorKeyType = Literal["information", "release", "issue", "commit"]
|
CoordinatorKeyType = Literal["information", "release", "issue", "commit"]
|
||||||
|
|
||||||
|
@ -25,6 +26,8 @@ CoordinatorKeyType = Literal["information", "release", "issue", "commit"]
|
||||||
class GitHubBaseDataUpdateCoordinator(DataUpdateCoordinator[T]):
|
class GitHubBaseDataUpdateCoordinator(DataUpdateCoordinator[T]):
|
||||||
"""Base class for GitHub data update coordinators."""
|
"""Base class for GitHub data update coordinators."""
|
||||||
|
|
||||||
|
config_entry: ConfigEntry
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
|
@ -84,7 +87,11 @@ class RepositoryInformationDataUpdateCoordinator(
|
||||||
|
|
||||||
async def fetch_data(self) -> GitHubResponseModel[GitHubRepositoryModel]:
|
async def fetch_data(self) -> GitHubResponseModel[GitHubRepositoryModel]:
|
||||||
"""Get the latest data from GitHub."""
|
"""Get the latest data from GitHub."""
|
||||||
return await self._client.repos.get(self.repository, **{"etag": self._etag})
|
response = await self._client.repos.get(self.repository, **{"etag": self._etag})
|
||||||
|
scope = "repo" if self.config_entry.options.get(CONF_REPO_SCOPE, False) else ""
|
||||||
|
if scope != response.headers.x_oauth_scopes:
|
||||||
|
raise ConfigEntryAuthFailed("Invalid OAuth scopes")
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
class RepositoryReleaseDataUpdateCoordinator(
|
class RepositoryReleaseDataUpdateCoordinator(
|
||||||
|
|
|
@ -1,11 +1,21 @@
|
||||||
{
|
{
|
||||||
"config": {
|
"config": {
|
||||||
"step": {
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"description": "Granting full access to repositories is needed if you want to use this integration with private repositories.",
|
||||||
|
"data": {
|
||||||
|
"repo_scope": "Grant full access to repositories"
|
||||||
|
}
|
||||||
|
},
|
||||||
"repositories": {
|
"repositories": {
|
||||||
"title": "Configure repositories",
|
"title": "Configure repositories",
|
||||||
"data": {
|
"data": {
|
||||||
"repositories": "Select repositories to track."
|
"repositories": "Select repositories to track."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"reauth_confirm": {
|
||||||
|
"title": "[%key:common::config_flow::title::reauth%]",
|
||||||
|
"description": "The GitHub integration needs to re-authenticate your account to match the selected scopes."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"progress": {
|
"progress": {
|
||||||
|
@ -13,7 +23,18 @@
|
||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
|
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
|
||||||
"could_not_register": "Could not register integration with GitHub"
|
"could_not_register": "Could not register integration with GitHub",
|
||||||
|
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"step": {
|
||||||
|
"init": {
|
||||||
|
"data": {
|
||||||
|
"repositories": "[%key:component::github::config::step::repositories::data::repositories%]",
|
||||||
|
"repo_scope": "[%key:component::github::config::step::user::data::repo_scope%]"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -2,17 +2,38 @@
|
||||||
"config": {
|
"config": {
|
||||||
"abort": {
|
"abort": {
|
||||||
"already_configured": "Service is already configured",
|
"already_configured": "Service is already configured",
|
||||||
"could_not_register": "Could not register integration with GitHub"
|
"could_not_register": "Could not register integration with GitHub",
|
||||||
|
"reauth_successful": "Re-authentication was successful"
|
||||||
},
|
},
|
||||||
"progress": {
|
"progress": {
|
||||||
"wait_for_device": "1. Open {url} \n2.Paste the following key to authorize the integration: \n```\n{code}\n```\n"
|
"wait_for_device": "1. Open {url} \n2.Paste the following key to authorize the integration: \n```\n{code}\n```\n"
|
||||||
},
|
},
|
||||||
"step": {
|
"step": {
|
||||||
|
"reauth_confirm": {
|
||||||
|
"description": "The GitHub integration needs to re-authenticate your account to match the selected scopes.",
|
||||||
|
"title": "Reauthenticate Integration"
|
||||||
|
},
|
||||||
"repositories": {
|
"repositories": {
|
||||||
"data": {
|
"data": {
|
||||||
"repositories": "Select repositories to track."
|
"repositories": "Select repositories to track."
|
||||||
},
|
},
|
||||||
"title": "Configure repositories"
|
"title": "Configure repositories"
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"data": {
|
||||||
|
"repo_scope": "Grant full access to repositories"
|
||||||
|
},
|
||||||
|
"description": "Granting full access to repositories is needed if you want to use this integration with private repositories."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"step": {
|
||||||
|
"init": {
|
||||||
|
"data": {
|
||||||
|
"repo_scope": "Grant full access to repositories",
|
||||||
|
"repositories": "Select repositories to track."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@ import pytest
|
||||||
|
|
||||||
from homeassistant.components.github.const import (
|
from homeassistant.components.github.const import (
|
||||||
CONF_ACCESS_TOKEN,
|
CONF_ACCESS_TOKEN,
|
||||||
|
CONF_REPO_SCOPE,
|
||||||
CONF_REPOSITORIES,
|
CONF_REPOSITORIES,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
)
|
)
|
||||||
|
@ -23,8 +24,9 @@ def mock_config_entry() -> MockConfigEntry:
|
||||||
return MockConfigEntry(
|
return MockConfigEntry(
|
||||||
title="",
|
title="",
|
||||||
domain=DOMAIN,
|
domain=DOMAIN,
|
||||||
|
unique_id=DOMAIN,
|
||||||
data={CONF_ACCESS_TOKEN: MOCK_ACCESS_TOKEN},
|
data={CONF_ACCESS_TOKEN: MOCK_ACCESS_TOKEN},
|
||||||
options={CONF_REPOSITORIES: [TEST_REPOSITORY]},
|
options={CONF_REPOSITORIES: [TEST_REPOSITORY], CONF_REPO_SCOPE: False},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -7,6 +7,7 @@ from homeassistant import config_entries
|
||||||
from homeassistant.components.github.config_flow import starred_repositories
|
from homeassistant.components.github.config_flow import starred_repositories
|
||||||
from homeassistant.components.github.const import (
|
from homeassistant.components.github.const import (
|
||||||
CONF_ACCESS_TOKEN,
|
CONF_ACCESS_TOKEN,
|
||||||
|
CONF_REPO_SCOPE,
|
||||||
CONF_REPOSITORIES,
|
CONF_REPOSITORIES,
|
||||||
DEFAULT_REPOSITORIES,
|
DEFAULT_REPOSITORIES,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
|
@ -62,6 +63,17 @@ async def test_full_user_flow_implementation(
|
||||||
context={"source": config_entries.SOURCE_USER},
|
context={"source": config_entries.SOURCE_USER},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assert result["step_id"] == "user"
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert "flow_id" in result
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
user_input={
|
||||||
|
CONF_REPO_SCOPE: False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
assert result["step_id"] == "device"
|
assert result["step_id"] == "device"
|
||||||
assert result["type"] == RESULT_TYPE_SHOW_PROGRESS
|
assert result["type"] == RESULT_TYPE_SHOW_PROGRESS
|
||||||
assert "flow_id" in result
|
assert "flow_id" in result
|
||||||
|
@ -92,10 +104,19 @@ async def test_flow_with_registration_failure(
|
||||||
"https://github.com/login/device/code",
|
"https://github.com/login/device/code",
|
||||||
exc=GitHubException("Registration failed"),
|
exc=GitHubException("Registration failed"),
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await hass.config_entries.flow.async_init(
|
result = await hass.config_entries.flow.async_init(
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
context={"source": config_entries.SOURCE_USER},
|
context={"source": config_entries.SOURCE_USER},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
user_input={
|
||||||
|
CONF_REPO_SCOPE: False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
assert result["type"] == RESULT_TYPE_ABORT
|
assert result["type"] == RESULT_TYPE_ABORT
|
||||||
assert result.get("reason") == "could_not_register"
|
assert result.get("reason") == "could_not_register"
|
||||||
|
|
||||||
|
@ -124,6 +145,13 @@ async def test_flow_with_activation_failure(
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
context={"source": config_entries.SOURCE_USER},
|
context={"source": config_entries.SOURCE_USER},
|
||||||
)
|
)
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
user_input={
|
||||||
|
CONF_REPO_SCOPE: False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
assert result["step_id"] == "device"
|
assert result["step_id"] == "device"
|
||||||
assert result["type"] == RESULT_TYPE_SHOW_PROGRESS
|
assert result["type"] == RESULT_TYPE_SHOW_PROGRESS
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue