Revamp github integration (#64190)

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
Co-authored-by: Franck Nijhof <git@frenck.dev>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Joakim Sørensen 2022-01-18 20:04:01 +01:00 committed by GitHub
parent 37caa22a36
commit 6a0c3843e5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 1113 additions and 246 deletions

View file

@ -391,6 +391,8 @@ omit =
homeassistant/components/garages_amsterdam/sensor.py
homeassistant/components/gc100/*
homeassistant/components/geniushub/*
homeassistant/components/github/__init__.py
homeassistant/components/github/coordinator.py
homeassistant/components/github/sensor.py
homeassistant/components/gitlab_ci/sensor.py
homeassistant/components/gitter/sensor.py

View file

@ -336,6 +336,7 @@ tests/components/geonetnz_volcano/* @exxamalte
homeassistant/components/gios/* @bieniu
tests/components/gios/* @bieniu
homeassistant/components/github/* @timmo001 @ludeeus
tests/components/github/* @timmo001 @ludeeus
homeassistant/components/gitter/* @fabaff
homeassistant/components/glances/* @fabaff @engrbm87
tests/components/glances/* @fabaff @engrbm87

View file

@ -1 +1,82 @@
"""The github component."""
"""The GitHub integration."""
from __future__ import annotations
import asyncio
from aiogithubapi import GitHubAPI
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import (
SERVER_SOFTWARE,
async_get_clientsession,
)
from .const import CONF_REPOSITORIES, DOMAIN
from .coordinator import (
DataUpdateCoordinators,
RepositoryCommitDataUpdateCoordinator,
RepositoryInformationDataUpdateCoordinator,
RepositoryIssueDataUpdateCoordinator,
RepositoryReleaseDataUpdateCoordinator,
)
PLATFORMS: list[Platform] = [Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up GitHub from a config entry."""
hass.data.setdefault(DOMAIN, {})
client = GitHubAPI(
token=entry.data[CONF_ACCESS_TOKEN],
session=async_get_clientsession(hass),
**{"client_name": SERVER_SOFTWARE},
)
repositories: list[str] = entry.options[CONF_REPOSITORIES]
for repository in repositories:
coordinators: DataUpdateCoordinators = {
"information": RepositoryInformationDataUpdateCoordinator(
hass=hass, entry=entry, client=client, repository=repository
),
"release": RepositoryReleaseDataUpdateCoordinator(
hass=hass, entry=entry, client=client, repository=repository
),
"issue": RepositoryIssueDataUpdateCoordinator(
hass=hass, entry=entry, client=client, repository=repository
),
"commit": RepositoryCommitDataUpdateCoordinator(
hass=hass, entry=entry, client=client, repository=repository
),
}
await asyncio.gather(
*(
coordinators["information"].async_config_entry_first_refresh(),
coordinators["release"].async_config_entry_first_refresh(),
coordinators["issue"].async_config_entry_first_refresh(),
coordinators["commit"].async_config_entry_first_refresh(),
)
)
hass.data[DOMAIN][repository] = coordinators
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(async_reload_entry))
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data.pop(DOMAIN)
return unload_ok
async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle an options update."""
await hass.config_entries.async_reload(entry.entry_id)

View file

@ -0,0 +1,229 @@
"""Config flow for GitHub integration."""
from __future__ import annotations
import asyncio
from typing import Any
from aiogithubapi import (
GitHubAPI,
GitHubDeviceAPI,
GitHubException,
GitHubLoginDeviceModel,
GitHubLoginOauthModel,
GitHubRepositoryModel,
)
from aiogithubapi.const import OAUTH_USER_LOGIN
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.core import HomeAssistant, callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.aiohttp_client import (
SERVER_SOFTWARE,
async_get_clientsession,
)
import homeassistant.helpers.config_validation as cv
from .const import (
CLIENT_ID,
CONF_ACCESS_TOKEN,
CONF_REPOSITORIES,
DEFAULT_REPOSITORIES,
DOMAIN,
LOGGER,
)
async def starred_repositories(hass: HomeAssistant, access_token: str) -> list[str]:
"""Return a list of repositories that the user has starred."""
client = GitHubAPI(token=access_token, session=async_get_clientsession(hass))
async def _get_starred() -> list[GitHubRepositoryModel] | None:
response = await client.user.starred(**{"params": {"per_page": 100}})
if not response.is_last_page:
results = await asyncio.gather(
*(
client.user.starred(
**{"params": {"per_page": 100, "page": page_number}},
)
for page_number in range(
response.next_page_number, response.last_page_number + 1
)
)
)
for result in results:
response.data.extend(result.data)
return response.data
try:
result = await _get_starred()
except GitHubException:
return DEFAULT_REPOSITORIES
if not result or len(result) == 0:
return DEFAULT_REPOSITORIES
return sorted((repo.full_name for repo in result), key=str.casefold)
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for GitHub."""
VERSION = 1
login_task: asyncio.Task | None = None
def __init__(self) -> None:
"""Initialize."""
self._device: GitHubDeviceAPI | None = None
self._login: GitHubLoginOauthModel | None = None
self._login_device: GitHubLoginDeviceModel | None = None
async def async_step_user(
self,
user_input: dict[str, Any] | None = None,
) -> FlowResult:
"""Handle the initial step."""
if self._async_current_entries():
return self.async_abort(reason="already_configured")
return await self.async_step_device(user_input)
async def async_step_device(
self,
user_input: dict[str, Any] | None = None,
) -> FlowResult:
"""Handle device steps."""
async def _wait_for_login() -> None:
# mypy is not aware that we can't get here without having these set already
assert self._device is not None
assert self._login_device is not None
try:
response = await self._device.activation(
device_code=self._login_device.device_code
)
self._login = response.data
finally:
self.hass.async_create_task(
self.hass.config_entries.flow.async_configure(flow_id=self.flow_id)
)
if not self._device:
self._device = GitHubDeviceAPI(
client_id=CLIENT_ID,
session=async_get_clientsession(self.hass),
**{"client_name": SERVER_SOFTWARE},
)
try:
response = await self._device.register()
self._login_device = response.data
except GitHubException as exception:
LOGGER.exception(exception)
return self.async_abort(reason="could_not_register")
if not self.login_task:
self.login_task = self.hass.async_create_task(_wait_for_login())
return self.async_show_progress(
step_id="device",
progress_action="wait_for_device",
description_placeholders={
"url": OAUTH_USER_LOGIN,
"code": self._login_device.user_code,
},
)
try:
await self.login_task
except GitHubException as 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="repositories")
async def async_step_repositories(
self,
user_input: dict[str, Any] | None = None,
) -> FlowResult:
"""Handle repositories step."""
# mypy is not aware that we can't get here without having this set already
assert self._login is not None
if not user_input:
repositories = await starred_repositories(
self.hass, self._login.access_token
)
return self.async_show_form(
step_id="repositories",
data_schema=vol.Schema(
{
vol.Required(CONF_REPOSITORIES): cv.multi_select(
{k: k for k in repositories}
),
}
),
)
return self.async_create_entry(
title="",
data={CONF_ACCESS_TOKEN: self._login.access_token},
options={CONF_REPOSITORIES: user_input[CONF_REPOSITORIES]},
)
async def async_step_could_not_register(
self,
user_input: dict[str, Any] | None = None,
) -> FlowResult:
"""Handle issues that need transition await from progress step."""
return self.async_abort(reason="could_not_register")
@staticmethod
@callback
def async_get_options_flow(
config_entry: config_entries.ConfigEntry,
) -> OptionsFlowHandler:
"""Get the options flow for this handler."""
return OptionsFlowHandler(config_entry)
class OptionsFlowHandler(config_entries.OptionsFlow):
"""Handle a option flow for GitHub."""
def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
"""Initialize options flow."""
self.config_entry = config_entry
async def async_step_init(
self,
user_input: dict[str, Any] | None = None,
) -> FlowResult:
"""Handle options flow."""
if not user_input:
configured_repositories: list[str] = self.config_entry.options[
CONF_REPOSITORIES
]
repositories = await starred_repositories(
self.hass, self.config_entry.data[CONF_ACCESS_TOKEN]
)
# In case the user has removed a starred repository that is already tracked
for repository in configured_repositories:
if repository not in repositories:
repositories.append(repository)
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(
{
vol.Required(
CONF_REPOSITORIES,
default=configured_repositories,
): cv.multi_select({k: k for k in repositories}),
}
),
)
return self.async_create_entry(title="", data=user_input)

View file

@ -0,0 +1,29 @@
"""Constants for the GitHub integration."""
from __future__ import annotations
from datetime import timedelta
from logging import Logger, getLogger
from typing import NamedTuple
from aiogithubapi import GitHubIssueModel
LOGGER: Logger = getLogger(__package__)
DOMAIN = "github"
CLIENT_ID = "1440cafcc86e3ea5d6a2"
DEFAULT_REPOSITORIES = ["home-assistant/core", "esphome/esphome"]
DEFAULT_UPDATE_INTERVAL = timedelta(seconds=300)
CONF_ACCESS_TOKEN = "access_token"
CONF_REPOSITORIES = "repositories"
class IssuesPulls(NamedTuple):
"""Issues and pull requests."""
issues_count: int
issue_last: GitHubIssueModel | None
pulls_count: int
pull_last: GitHubIssueModel | None

View file

@ -0,0 +1,141 @@
"""Custom data update coordinators for the GitHub integration."""
from __future__ import annotations
from typing import Literal, TypedDict
from aiogithubapi import (
GitHubAPI,
GitHubCommitModel,
GitHubException,
GitHubReleaseModel,
GitHubRepositoryModel,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, T
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DEFAULT_UPDATE_INTERVAL, DOMAIN, LOGGER, IssuesPulls
CoordinatorKeyType = Literal["information", "release", "issue", "commit"]
class GitHubBaseDataUpdateCoordinator(DataUpdateCoordinator[T]):
"""Base class for GitHub data update coordinators."""
def __init__(
self,
hass: HomeAssistant,
entry: ConfigEntry,
client: GitHubAPI,
repository: str,
) -> None:
"""Initialize GitHub data update coordinator base class."""
self.config_entry = entry
self.repository = repository
self._client = client
super().__init__(
hass,
LOGGER,
name=DOMAIN,
update_interval=DEFAULT_UPDATE_INTERVAL,
)
async def fetch_data(self) -> T:
"""Fetch data from GitHub API."""
async def _async_update_data(self) -> T:
try:
return await self.fetch_data()
except GitHubException as exception:
LOGGER.exception(exception)
raise UpdateFailed(exception) from exception
class RepositoryInformationDataUpdateCoordinator(
GitHubBaseDataUpdateCoordinator[GitHubRepositoryModel]
):
"""Data update coordinator for repository information."""
async def fetch_data(self) -> GitHubRepositoryModel:
"""Get the latest data from GitHub."""
result = await self._client.repos.get(self.repository)
return result.data
class RepositoryReleaseDataUpdateCoordinator(
GitHubBaseDataUpdateCoordinator[GitHubReleaseModel]
):
"""Data update coordinator for repository release."""
async def fetch_data(self) -> GitHubReleaseModel | None:
"""Get the latest data from GitHub."""
result = await self._client.repos.releases.list(
self.repository, **{"params": {"per_page": 1}}
)
if not result.data:
return None
for release in result.data:
if not release.prerelease:
return release
# Fall back to the latest release if no non-prerelease release is found
return result.data[0]
class RepositoryIssueDataUpdateCoordinator(
GitHubBaseDataUpdateCoordinator[IssuesPulls]
):
"""Data update coordinator for repository issues."""
async def fetch_data(self) -> IssuesPulls:
"""Get the latest data from GitHub."""
base_issue_response = await self._client.repos.issues.list(
self.repository, **{"params": {"per_page": 1}}
)
pull_response = await self._client.repos.pulls.list(
self.repository, **{"params": {"per_page": 1}}
)
pulls_count = pull_response.last_page_number or 0
issues_count = (base_issue_response.last_page_number or 0) - pulls_count
issue_last = base_issue_response.data[0] if issues_count != 0 else None
if issue_last is not None and issue_last.pull_request:
issue_response = await self._client.repos.issues.list(self.repository)
for issue in issue_response.data:
if not issue.pull_request:
issue_last = issue
break
return IssuesPulls(
issues_count=issues_count,
issue_last=issue_last,
pulls_count=pulls_count,
pull_last=pull_response.data[0] if pulls_count != 0 else None,
)
class RepositoryCommitDataUpdateCoordinator(
GitHubBaseDataUpdateCoordinator[GitHubCommitModel]
):
"""Data update coordinator for repository commit."""
async def fetch_data(self) -> GitHubCommitModel | None:
"""Get the latest data from GitHub."""
result = await self._client.repos.list_commits(
self.repository, **{"params": {"per_page": 1}}
)
return result.data[0] if result.data else None
class DataUpdateCoordinators(TypedDict):
"""Custom data update coordinators for the GitHub integration."""
information: RepositoryInformationDataUpdateCoordinator
release: RepositoryReleaseDataUpdateCoordinator
issue: RepositoryIssueDataUpdateCoordinator
commit: RepositoryCommitDataUpdateCoordinator

View file

@ -9,5 +9,6 @@
"@timmo001",
"@ludeeus"
],
"iot_class": "cloud_polling"
"iot_class": "cloud_polling",
"config_flow": true
}

View file

@ -1,293 +1,353 @@
"""Sensor platform for the GitHub integratiom."""
"""Sensor platform for the GitHub integration."""
from __future__ import annotations
import asyncio
from datetime import timedelta
import logging
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from aiogithubapi import GitHubAPI, GitHubException
import voluptuous as vol
from aiogithubapi import GitHubRepositoryModel
from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity
from homeassistant.const import (
ATTR_NAME,
CONF_ACCESS_TOKEN,
CONF_NAME,
CONF_PATH,
CONF_URL,
from homeassistant.components.sensor import (
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.device_registry import DeviceEntryType
from homeassistant.helpers.entity import DeviceInfo, EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
_LOGGER = logging.getLogger(__name__)
CONF_REPOS = "repositories"
ATTR_LATEST_COMMIT_MESSAGE = "latest_commit_message"
ATTR_LATEST_COMMIT_SHA = "latest_commit_sha"
ATTR_LATEST_RELEASE_TAG = "latest_release_tag"
ATTR_LATEST_RELEASE_URL = "latest_release_url"
ATTR_LATEST_OPEN_ISSUE_URL = "latest_open_issue_url"
ATTR_OPEN_ISSUES = "open_issues"
ATTR_LATEST_OPEN_PULL_REQUEST_URL = "latest_open_pull_request_url"
ATTR_OPEN_PULL_REQUESTS = "open_pull_requests"
ATTR_PATH = "path"
ATTR_STARGAZERS = "stargazers"
ATTR_FORKS = "forks"
ATTR_CLONES = "clones"
ATTR_CLONES_UNIQUE = "clones_unique"
ATTR_VIEWS = "views"
ATTR_VIEWS_UNIQUE = "views_unique"
DEFAULT_NAME = "GitHub"
SCAN_INTERVAL = timedelta(seconds=300)
REPO_SCHEMA = vol.Schema(
{vol.Required(CONF_PATH): cv.string, vol.Optional(CONF_NAME): cv.string}
)
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_ACCESS_TOKEN): cv.string,
vol.Optional(CONF_URL): cv.url,
vol.Required(CONF_REPOS): vol.All(cv.ensure_list, [REPO_SCHEMA]),
}
from .const import DOMAIN, IssuesPulls
from .coordinator import (
CoordinatorKeyType,
DataUpdateCoordinators,
GitHubBaseDataUpdateCoordinator,
RepositoryCommitDataUpdateCoordinator,
RepositoryIssueDataUpdateCoordinator,
RepositoryReleaseDataUpdateCoordinator,
)
async def async_setup_platform(
@dataclass
class GitHubSensorBaseEntityDescriptionMixin:
"""Mixin for required GitHub base description keys."""
coordinator_key: CoordinatorKeyType
@dataclass
class GitHubSensorInformationEntityDescriptionMixin(
GitHubSensorBaseEntityDescriptionMixin
):
"""Mixin for required GitHub information description keys."""
value_fn: Callable[[GitHubRepositoryModel], StateType]
@dataclass
class GitHubSensorIssueEntityDescriptionMixin(GitHubSensorBaseEntityDescriptionMixin):
"""Mixin for required GitHub information description keys."""
value_fn: Callable[[IssuesPulls], StateType]
@dataclass
class GitHubSensorBaseEntityDescription(SensorEntityDescription):
"""Describes GitHub sensor entity default overrides."""
icon: str = "mdi:github"
entity_registry_enabled_default: bool = False
@dataclass
class GitHubSensorInformationEntityDescription(
GitHubSensorBaseEntityDescription,
GitHubSensorInformationEntityDescriptionMixin,
):
"""Describes GitHub information sensor entity."""
@dataclass
class GitHubSensorIssueEntityDescription(
GitHubSensorBaseEntityDescription,
GitHubSensorIssueEntityDescriptionMixin,
):
"""Describes GitHub issue sensor entity."""
SENSOR_DESCRIPTIONS: tuple[
GitHubSensorInformationEntityDescription | GitHubSensorIssueEntityDescription,
...,
] = (
GitHubSensorInformationEntityDescription(
key="stargazers_count",
name="Stars",
icon="mdi:star",
native_unit_of_measurement="Stars",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data.stargazers_count,
coordinator_key="information",
),
GitHubSensorInformationEntityDescription(
key="subscribers_count",
name="Watchers",
icon="mdi:glasses",
native_unit_of_measurement="Watchers",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
# The API returns a watcher_count, but subscribers_count is more accurate
value_fn=lambda data: data.subscribers_count,
coordinator_key="information",
),
GitHubSensorInformationEntityDescription(
key="forks_count",
name="Forks",
icon="mdi:source-fork",
native_unit_of_measurement="Forks",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data.forks_count,
coordinator_key="information",
),
GitHubSensorIssueEntityDescription(
key="issues_count",
name="Issues",
native_unit_of_measurement="Issues",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data.issues_count,
coordinator_key="issue",
),
GitHubSensorIssueEntityDescription(
key="pulls_count",
name="Pull Requests",
native_unit_of_measurement="Pull Requests",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data.pulls_count,
coordinator_key="issue",
),
)
async def async_setup_entry(
hass: HomeAssistant,
config: ConfigType,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the GitHub sensor platform."""
sensors = []
session = async_get_clientsession(hass)
for repository in config[CONF_REPOS]:
data = GitHubData(
repository=repository,
access_token=config[CONF_ACCESS_TOKEN],
session=session,
server_url=config.get(CONF_URL),
"""Set up GitHub sensor based on a config entry."""
repositories: dict[str, DataUpdateCoordinators] = hass.data[DOMAIN]
entities: list[GitHubSensorBaseEntity] = []
for coordinators in repositories.values():
repository_information = coordinators["information"].data
entities.extend(
sensor(coordinators, repository_information)
for sensor in (
GitHubSensorLatestCommitEntity,
GitHubSensorLatestIssueEntity,
GitHubSensorLatestPullEntity,
GitHubSensorLatestReleaseEntity,
)
)
sensors.append(GitHubSensor(data))
async_add_entities(sensors, True)
entities.extend(
GitHubSensorDescriptionEntity(
coordinators, description, repository_information
)
for description in SENSOR_DESCRIPTIONS
)
async_add_entities(entities)
class GitHubSensor(SensorEntity):
"""Representation of a GitHub sensor."""
class GitHubSensorBaseEntity(CoordinatorEntity, SensorEntity):
"""Defines a base GitHub sensor entity."""
_attr_attribution = "Data provided by the GitHub API"
coordinator: GitHubBaseDataUpdateCoordinator
def __init__(
self,
coordinator: GitHubBaseDataUpdateCoordinator,
repository_information: GitHubRepositoryModel,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self.coordinator.repository)},
name=repository_information.full_name,
manufacturer="GitHub",
configuration_url=f"https://github.com/{self.coordinator.repository}",
entry_type=DeviceEntryType.SERVICE,
)
@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self.coordinator.data is not None
class GitHubSensorDescriptionEntity(GitHubSensorBaseEntity):
"""Defines a GitHub sensor entity based on entity descriptions."""
coordinator: GitHubBaseDataUpdateCoordinator
entity_description: GitHubSensorInformationEntityDescription | GitHubSensorIssueEntityDescription
def __init__(
self,
coordinators: DataUpdateCoordinators,
description: GitHubSensorInformationEntityDescription
| GitHubSensorIssueEntityDescription,
repository_information: GitHubRepositoryModel,
) -> None:
"""Initialize a GitHub sensor entity."""
super().__init__(
coordinator=coordinators[description.coordinator_key],
repository_information=repository_information,
)
self.entity_description = description
self._attr_name = f"{repository_information.full_name} {description.name}"
self._attr_unique_id = f"{repository_information.id}_{description.key}"
@property
def native_value(self) -> StateType:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.coordinator.data)
class GitHubSensorLatestBaseEntity(GitHubSensorBaseEntity):
"""Defines a base GitHub latest sensor entity."""
_name: str = "Latest"
_coordinator_key: CoordinatorKeyType = "information"
_attr_entity_registry_enabled_default = False
_attr_icon = "mdi:github"
def __init__(self, github_data):
"""Initialize the GitHub sensor."""
self._attr_unique_id = github_data.repository_path
self._repository_path = None
self._latest_commit_message = None
self._latest_commit_sha = None
self._latest_release_tag = None
self._latest_release_url = None
self._open_issue_count = None
self._latest_open_issue_url = None
self._pull_request_count = None
self._latest_open_pr_url = None
self._stargazers = None
self._forks = None
self._clones = None
self._clones_unique = None
self._views = None
self._views_unique = None
self._github_data = github_data
def __init__(
self,
coordinators: DataUpdateCoordinators,
repository_information: GitHubRepositoryModel,
) -> None:
"""Initialize a GitHub sensor entity."""
super().__init__(
coordinator=coordinators[self._coordinator_key],
repository_information=repository_information,
)
self._attr_name = f"{repository_information.full_name} {self._name}"
self._attr_unique_id = (
f"{repository_information.id}_{self._name.lower().replace(' ', '_')}"
)
async def async_update(self):
"""Collect updated data from GitHub API."""
await self._github_data.async_update()
self._attr_available = self._github_data.available
if not self.available:
return
self._attr_name = self._github_data.name
self._attr_native_value = self._github_data.last_commit.sha[0:7]
class GitHubSensorLatestReleaseEntity(GitHubSensorLatestBaseEntity):
"""Defines a GitHub latest release sensor entity."""
self._latest_commit_message = self._github_data.last_commit.commit.message
self._latest_commit_sha = self._github_data.last_commit.sha
self._stargazers = self._github_data.repository_response.data.stargazers_count
self._forks = self._github_data.repository_response.data.forks_count
_coordinator_key: CoordinatorKeyType = "release"
_name: str = "Latest Release"
self._pull_request_count = len(self._github_data.pulls_response.data)
self._open_issue_count = (
self._github_data.repository_response.data.open_issues_count or 0
) - self._pull_request_count
_attr_entity_registry_enabled_default = True
if self._github_data.last_release:
self._latest_release_tag = self._github_data.last_release.tag_name
self._latest_release_url = self._github_data.last_release.html_url
coordinator: RepositoryReleaseDataUpdateCoordinator
if self._github_data.last_issue:
self._latest_open_issue_url = self._github_data.last_issue.html_url
@property
def native_value(self) -> StateType:
"""Return the state of the sensor."""
return self.coordinator.data.name[:255]
if self._github_data.last_pull_request:
self._latest_open_pr_url = self._github_data.last_pull_request.html_url
if self._github_data.clones_response:
self._clones = self._github_data.clones_response.data.count
self._clones_unique = self._github_data.clones_response.data.uniques
if self._github_data.views_response:
self._views = self._github_data.views_response.data.count
self._views_unique = self._github_data.views_response.data.uniques
self._attr_extra_state_attributes = {
ATTR_PATH: self._github_data.repository_path,
ATTR_NAME: self.name,
ATTR_LATEST_COMMIT_MESSAGE: self._latest_commit_message,
ATTR_LATEST_COMMIT_SHA: self._latest_commit_sha,
ATTR_LATEST_RELEASE_URL: self._latest_release_url,
ATTR_LATEST_OPEN_ISSUE_URL: self._latest_open_issue_url,
ATTR_OPEN_ISSUES: self._open_issue_count,
ATTR_LATEST_OPEN_PULL_REQUEST_URL: self._latest_open_pr_url,
ATTR_OPEN_PULL_REQUESTS: self._pull_request_count,
ATTR_STARGAZERS: self._stargazers,
ATTR_FORKS: self._forks,
@property
def extra_state_attributes(self) -> Mapping[str, str | None]:
"""Return the extra state attributes."""
release = self.coordinator.data
return {
"url": release.html_url,
"tag": release.tag_name,
}
if self._latest_release_tag is not None:
self._attr_extra_state_attributes[
ATTR_LATEST_RELEASE_TAG
] = self._latest_release_tag
if self._clones is not None:
self._attr_extra_state_attributes[ATTR_CLONES] = self._clones
if self._clones_unique is not None:
self._attr_extra_state_attributes[ATTR_CLONES_UNIQUE] = self._clones_unique
if self._views is not None:
self._attr_extra_state_attributes[ATTR_VIEWS] = self._views
if self._views_unique is not None:
self._attr_extra_state_attributes[ATTR_VIEWS_UNIQUE] = self._views_unique
class GitHubData:
"""GitHub Data object."""
class GitHubSensorLatestIssueEntity(GitHubSensorLatestBaseEntity):
"""Defines a GitHub latest issue sensor entity."""
def __init__(self, repository, access_token, session, server_url=None):
"""Set up GitHub."""
self._repository = repository
self.repository_path = repository[CONF_PATH]
self._github = GitHubAPI(
token=access_token, session=session, **{"base_url": server_url}
)
_name: str = "Latest Issue"
_coordinator_key: CoordinatorKeyType = "issue"
self.available = False
self.repository_response = None
self.commit_response = None
self.issues_response = None
self.pulls_response = None
self.releases_response = None
self.views_response = None
self.clones_response = None
coordinator: RepositoryIssueDataUpdateCoordinator
@property
def name(self):
"""Return the name of the sensor."""
return self._repository.get(CONF_NAME, self.repository_response.data.name)
def available(self) -> bool:
"""Return True if entity is available."""
return super().available and self.coordinator.data.issues_count != 0
@property
def last_commit(self):
"""Return the last issue."""
return self.commit_response.data[0] if self.commit_response.data else None
def native_value(self) -> StateType:
"""Return the state of the sensor."""
if (issue := self.coordinator.data.issue_last) is None:
return None
return issue.title[:255]
@property
def last_issue(self):
"""Return the last issue."""
return self.issues_response.data[0] if self.issues_response.data else None
def extra_state_attributes(self) -> Mapping[str, str | int | None] | None:
"""Return the extra state attributes."""
if (issue := self.coordinator.data.issue_last) is None:
return None
return {
"url": issue.html_url,
"number": issue.number,
}
class GitHubSensorLatestPullEntity(GitHubSensorLatestBaseEntity):
"""Defines a GitHub latest pull sensor entity."""
_coordinator_key: CoordinatorKeyType = "issue"
_name: str = "Latest Pull Request"
coordinator: RepositoryIssueDataUpdateCoordinator
@property
def last_pull_request(self):
"""Return the last pull request."""
return self.pulls_response.data[0] if self.pulls_response.data else None
def available(self) -> bool:
"""Return True if entity is available."""
return super().available and self.coordinator.data.pulls_count != 0
@property
def last_release(self):
"""Return the last release."""
return self.releases_response.data[0] if self.releases_response.data else None
def native_value(self) -> StateType:
"""Return the state of the sensor."""
if (pull := self.coordinator.data.pull_last) is None:
return None
return pull.title[:255]
async def async_update(self):
"""Update GitHub data."""
try:
await asyncio.gather(
self._update_repository(),
self._update_commit(),
self._update_issues(),
self._update_pulls(),
self._update_releases(),
)
@property
def extra_state_attributes(self) -> Mapping[str, str | int | None] | None:
"""Return the extra state attributes."""
if (pull := self.coordinator.data.pull_last) is None:
return None
return {
"url": pull.html_url,
"number": pull.number,
}
if self.repository_response.data.permissions.push:
await asyncio.gather(
self._update_clones(),
self._update_views(),
)
self.available = True
except GitHubException as err:
_LOGGER.error("GitHub error for %s: %s", self.repository_path, err)
self.available = False
class GitHubSensorLatestCommitEntity(GitHubSensorLatestBaseEntity):
"""Defines a GitHub latest commit sensor entity."""
async def _update_repository(self):
"""Update repository data."""
self.repository_response = await self._github.repos.get(self.repository_path)
_coordinator_key: CoordinatorKeyType = "commit"
_name: str = "Latest Commit"
async def _update_commit(self):
"""Update commit data."""
self.commit_response = await self._github.repos.list_commits(
self.repository_path, **{"params": {"per_page": 1}}
)
coordinator: RepositoryCommitDataUpdateCoordinator
async def _update_issues(self):
"""Update issues data."""
self.issues_response = await self._github.repos.issues.list(
self.repository_path
)
@property
def native_value(self) -> StateType:
"""Return the state of the sensor."""
return self.coordinator.data.commit.message.splitlines()[0][:255]
async def _update_releases(self):
"""Update releases data."""
self.releases_response = await self._github.repos.releases.list(
self.repository_path
)
async def _update_clones(self):
"""Update clones data."""
self.clones_response = await self._github.repos.traffic.clones(
self.repository_path
)
async def _update_views(self):
"""Update views data."""
self.views_response = await self._github.repos.traffic.views(
self.repository_path
)
async def _update_pulls(self):
"""Update pulls data."""
response = await self._github.repos.pulls.list(
self.repository_path, **{"params": {"per_page": 100}}
)
if not response.is_last_page:
results = await asyncio.gather(
*(
self._github.repos.pulls.list(
self.repository_path,
**{"params": {"per_page": 100, "page": page_number}},
)
for page_number in range(
response.next_page_number, response.last_page_number + 1
)
)
)
for result in results:
response.data.extend(result.data)
self.pulls_response = response
@property
def extra_state_attributes(self) -> Mapping[str, str | int | None]:
"""Return the extra state attributes."""
return {
"sha": self.coordinator.data.sha,
"url": self.coordinator.data.html_url,
}

View file

@ -0,0 +1,19 @@
{
"config": {
"step": {
"repositories": {
"title": "Configure repositories",
"data": {
"repositories": "Select repositories to track."
}
}
},
"progress": {
"wait_for_device": "1. Open {url} \n2.Paste the following key to authorize the integration: \n```\n{code}\n```\n"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"could_not_register": "Could not register integration with GitHub"
}
}
}

View file

@ -0,0 +1,29 @@
{
"config": {
"step": {
"repositories": {
"title": "Configure repositories",
"data": {
"repositories": "Select repositories to track."
}
}
},
"progress": {
"wait_for_device": "1. Open {url} \n2.Paste the following key to authorize the integration: \n```\n{code}\n```\n"
},
"abort": {
"already_configured": "Service is already configured",
"could_not_register": "Could not register integration with GitHub"
}
},
"options": {
"step": {
"init": {
"data": {
"repositories": "Select repositories to track."
},
"title": "Configure options"
}
}
}
}

View file

@ -115,6 +115,7 @@ FLOWS = [
"geonetnz_quakes",
"geonetnz_volcano",
"gios",
"github",
"glances",
"goalzero",
"gogogate2",

View file

@ -124,6 +124,9 @@ aioesphomeapi==10.6.0
# homeassistant.components.flo
aioflo==2021.11.0
# homeassistant.components.github
aiogithubapi==22.1.0
# homeassistant.components.guardian
aioguardian==2021.11.0

View file

@ -0,0 +1 @@
"""Tests for the GitHub integration."""

View file

@ -0,0 +1,3 @@
"""Common helpers for GitHub integration tests."""
MOCK_ACCESS_TOKEN = "gho_16C7e42F292c6912E7710c838347Ae178B4a"

View file

@ -0,0 +1,34 @@
"""conftest for the GitHub integration."""
from collections.abc import Generator
from unittest.mock import patch
import pytest
from homeassistant.components.github.const import (
CONF_ACCESS_TOKEN,
CONF_REPOSITORIES,
DEFAULT_REPOSITORIES,
DOMAIN,
)
from .common import MOCK_ACCESS_TOKEN
from tests.common import MockConfigEntry
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return the default mocked config entry."""
return MockConfigEntry(
title="",
domain=DOMAIN,
data={CONF_ACCESS_TOKEN: MOCK_ACCESS_TOKEN},
options={CONF_REPOSITORIES: DEFAULT_REPOSITORIES},
)
@pytest.fixture
def mock_setup_entry() -> Generator[None, None, None]:
"""Mock setting up a config entry."""
with patch("homeassistant.components.github.async_setup_entry", return_value=True):
yield

View file

@ -0,0 +1,233 @@
"""Test the GitHub config flow."""
from unittest.mock import AsyncMock, MagicMock, patch
from aiogithubapi import GitHubException
from homeassistant import config_entries
from homeassistant.components.github.config_flow import starred_repositories
from homeassistant.components.github.const import (
CONF_ACCESS_TOKEN,
CONF_REPOSITORIES,
DEFAULT_REPOSITORIES,
DOMAIN,
)
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import (
RESULT_TYPE_ABORT,
RESULT_TYPE_CREATE_ENTRY,
RESULT_TYPE_SHOW_PROGRESS,
RESULT_TYPE_SHOW_PROGRESS_DONE,
)
from .common import MOCK_ACCESS_TOKEN
from tests.common import MockConfigEntry
from tests.test_util.aiohttp import AiohttpClientMocker
async def test_full_user_flow_implementation(
hass: HomeAssistant,
mock_setup_entry: None,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test the full manual user flow from start to finish."""
aioclient_mock.post(
"https://github.com/login/device/code",
json={
"device_code": "3584d83530557fdd1f46af8289938c8ef79f9dc5",
"user_code": "WDJB-MJHT",
"verification_uri": "https://github.com/login/device",
"expires_in": 900,
"interval": 5,
},
headers={"Content-Type": "application/json"},
)
aioclient_mock.post(
"https://github.com/login/oauth/access_token",
json={
CONF_ACCESS_TOKEN: MOCK_ACCESS_TOKEN,
"token_type": "bearer",
"scope": "",
},
headers={"Content-Type": "application/json"},
)
aioclient_mock.get(
"https://api.github.com/user/starred",
json=[{"full_name": "home-assistant/core"}, {"full_name": "esphome/esphome"}],
headers={"Content-Type": "application/json"},
)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
)
assert result["step_id"] == "device"
assert result["type"] == RESULT_TYPE_SHOW_PROGRESS
assert "flow_id" in result
result = await hass.config_entries.flow.async_configure(result["flow_id"])
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_REPOSITORIES: DEFAULT_REPOSITORIES,
},
)
assert result["title"] == ""
assert result["type"] == RESULT_TYPE_CREATE_ENTRY
assert "data" in result
assert result["data"][CONF_ACCESS_TOKEN] == MOCK_ACCESS_TOKEN
assert "options" in result
assert result["options"][CONF_REPOSITORIES] == DEFAULT_REPOSITORIES
async def test_flow_with_registration_failure(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test flow with registration failure of the device."""
aioclient_mock.post(
"https://github.com/login/device/code",
side_effect=GitHubException("Registration failed"),
)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
)
assert result["type"] == RESULT_TYPE_ABORT
assert result.get("reason") == "could_not_register"
async def test_flow_with_activation_failure(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test flow with activation failure of the device."""
aioclient_mock.post(
"https://github.com/login/device/code",
json={
"device_code": "3584d83530557fdd1f46af8289938c8ef79f9dc5",
"user_code": "WDJB-MJHT",
"verification_uri": "https://github.com/login/device",
"expires_in": 900,
"interval": 5,
},
headers={"Content-Type": "application/json"},
)
aioclient_mock.post(
"https://github.com/login/oauth/access_token",
side_effect=GitHubException("Activation failed"),
)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
)
assert result["step_id"] == "device"
assert result["type"] == RESULT_TYPE_SHOW_PROGRESS
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] == RESULT_TYPE_SHOW_PROGRESS_DONE
assert result["step_id"] == "could_not_register"
async def test_already_configured(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test we abort if already configured."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
)
assert result["type"] == RESULT_TYPE_ABORT
assert result.get("reason") == "already_configured"
async def test_starred_pagination_with_paginated_result(hass: HomeAssistant) -> None:
"""Test pagination of starred repositories with paginated result."""
with patch(
"homeassistant.components.github.config_flow.GitHubAPI",
return_value=MagicMock(
user=MagicMock(
starred=AsyncMock(
return_value=MagicMock(
is_last_page=False,
next_page_number=2,
last_page_number=2,
data=[MagicMock(full_name="home-assistant/core")],
)
)
)
),
):
repos = await starred_repositories(hass, MOCK_ACCESS_TOKEN)
assert len(repos) == 2
assert repos[-1] == DEFAULT_REPOSITORIES[0]
async def test_starred_pagination_with_no_starred(hass: HomeAssistant) -> None:
"""Test pagination of starred repositories with no starred."""
with patch(
"homeassistant.components.github.config_flow.GitHubAPI",
return_value=MagicMock(
user=MagicMock(
starred=AsyncMock(
return_value=MagicMock(
is_last_page=True,
data=[],
)
)
)
),
):
repos = await starred_repositories(hass, MOCK_ACCESS_TOKEN)
assert len(repos) == 2
assert repos == DEFAULT_REPOSITORIES
async def test_starred_pagination_with_exception(hass: HomeAssistant) -> None:
"""Test pagination of starred repositories with exception."""
with patch(
"homeassistant.components.github.config_flow.GitHubAPI",
return_value=MagicMock(
user=MagicMock(starred=AsyncMock(side_effect=GitHubException("Error")))
),
):
repos = await starred_repositories(hass, MOCK_ACCESS_TOKEN)
assert len(repos) == 2
assert repos == DEFAULT_REPOSITORIES
async def test_options_flow(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_setup_entry: None,
) -> None:
"""Test options flow."""
mock_config_entry.options = {
CONF_REPOSITORIES: ["homeassistant/core", "homeassistant/architecture"]
}
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
result = await hass.config_entries.options.async_init(mock_config_entry.entry_id)
assert result["type"] == "form"
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={CONF_REPOSITORIES: ["homeassistant/core"]},
)
assert "homeassistant/architecture" not in result["data"][CONF_REPOSITORIES]