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/garages_amsterdam/sensor.py
homeassistant/components/gc100/* homeassistant/components/gc100/*
homeassistant/components/geniushub/* homeassistant/components/geniushub/*
homeassistant/components/github/__init__.py
homeassistant/components/github/coordinator.py
homeassistant/components/github/sensor.py homeassistant/components/github/sensor.py
homeassistant/components/gitlab_ci/sensor.py homeassistant/components/gitlab_ci/sensor.py
homeassistant/components/gitter/sensor.py homeassistant/components/gitter/sensor.py

View file

@ -336,6 +336,7 @@ tests/components/geonetnz_volcano/* @exxamalte
homeassistant/components/gios/* @bieniu homeassistant/components/gios/* @bieniu
tests/components/gios/* @bieniu tests/components/gios/* @bieniu
homeassistant/components/github/* @timmo001 @ludeeus homeassistant/components/github/* @timmo001 @ludeeus
tests/components/github/* @timmo001 @ludeeus
homeassistant/components/gitter/* @fabaff homeassistant/components/gitter/* @fabaff
homeassistant/components/glances/* @fabaff @engrbm87 homeassistant/components/glances/* @fabaff @engrbm87
tests/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", "@timmo001",
"@ludeeus" "@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 from __future__ import annotations
import asyncio from collections.abc import Callable, Mapping
from datetime import timedelta from dataclasses import dataclass
import logging
from aiogithubapi import GitHubAPI, GitHubException from aiogithubapi import GitHubRepositoryModel
import voluptuous as vol
from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity from homeassistant.components.sensor import (
from homeassistant.const import ( SensorEntity,
ATTR_NAME, SensorEntityDescription,
CONF_ACCESS_TOKEN, SensorStateClass,
CONF_NAME,
CONF_PATH,
CONF_URL,
) )
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceEntryType
import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import DeviceInfo, EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback 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__) from .const import DOMAIN, IssuesPulls
from .coordinator import (
CONF_REPOS = "repositories" CoordinatorKeyType,
DataUpdateCoordinators,
ATTR_LATEST_COMMIT_MESSAGE = "latest_commit_message" GitHubBaseDataUpdateCoordinator,
ATTR_LATEST_COMMIT_SHA = "latest_commit_sha" RepositoryCommitDataUpdateCoordinator,
ATTR_LATEST_RELEASE_TAG = "latest_release_tag" RepositoryIssueDataUpdateCoordinator,
ATTR_LATEST_RELEASE_URL = "latest_release_url" RepositoryReleaseDataUpdateCoordinator,
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]),
}
) )
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, hass: HomeAssistant,
config: ConfigType, entry: ConfigEntry,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None: ) -> None:
"""Set up the GitHub sensor platform.""" """Set up GitHub sensor based on a config entry."""
sensors = [] repositories: dict[str, DataUpdateCoordinators] = hass.data[DOMAIN]
session = async_get_clientsession(hass) entities: list[GitHubSensorBaseEntity] = []
for repository in config[CONF_REPOS]:
data = GitHubData( for coordinators in repositories.values():
repository=repository, repository_information = coordinators["information"].data
access_token=config[CONF_ACCESS_TOKEN], entities.extend(
session=session, sensor(coordinators, repository_information)
server_url=config.get(CONF_URL), 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): class GitHubSensorBaseEntity(CoordinatorEntity, SensorEntity):
"""Representation of a GitHub sensor.""" """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" _attr_icon = "mdi:github"
def __init__(self, github_data): def __init__(
"""Initialize the GitHub sensor.""" self,
self._attr_unique_id = github_data.repository_path coordinators: DataUpdateCoordinators,
self._repository_path = None repository_information: GitHubRepositoryModel,
self._latest_commit_message = None ) -> None:
self._latest_commit_sha = None """Initialize a GitHub sensor entity."""
self._latest_release_tag = None super().__init__(
self._latest_release_url = None coordinator=coordinators[self._coordinator_key],
self._open_issue_count = None repository_information=repository_information,
self._latest_open_issue_url = None )
self._pull_request_count = None self._attr_name = f"{repository_information.full_name} {self._name}"
self._latest_open_pr_url = None self._attr_unique_id = (
self._stargazers = None f"{repository_information.id}_{self._name.lower().replace(' ', '_')}"
self._forks = None )
self._clones = None
self._clones_unique = None
self._views = None
self._views_unique = None
self._github_data = github_data
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 class GitHubSensorLatestReleaseEntity(GitHubSensorLatestBaseEntity):
self._attr_native_value = self._github_data.last_commit.sha[0:7] """Defines a GitHub latest release sensor entity."""
self._latest_commit_message = self._github_data.last_commit.commit.message _coordinator_key: CoordinatorKeyType = "release"
self._latest_commit_sha = self._github_data.last_commit.sha _name: str = "Latest Release"
self._stargazers = self._github_data.repository_response.data.stargazers_count
self._forks = self._github_data.repository_response.data.forks_count
self._pull_request_count = len(self._github_data.pulls_response.data) _attr_entity_registry_enabled_default = True
self._open_issue_count = (
self._github_data.repository_response.data.open_issues_count or 0
) - self._pull_request_count
if self._github_data.last_release: coordinator: RepositoryReleaseDataUpdateCoordinator
self._latest_release_tag = self._github_data.last_release.tag_name
self._latest_release_url = self._github_data.last_release.html_url
if self._github_data.last_issue: @property
self._latest_open_issue_url = self._github_data.last_issue.html_url def native_value(self) -> StateType:
"""Return the state of the sensor."""
return self.coordinator.data.name[:255]
if self._github_data.last_pull_request: @property
self._latest_open_pr_url = self._github_data.last_pull_request.html_url def extra_state_attributes(self) -> Mapping[str, str | None]:
"""Return the extra state attributes."""
if self._github_data.clones_response: release = self.coordinator.data
self._clones = self._github_data.clones_response.data.count return {
self._clones_unique = self._github_data.clones_response.data.uniques "url": release.html_url,
"tag": release.tag_name,
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,
} }
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: class GitHubSensorLatestIssueEntity(GitHubSensorLatestBaseEntity):
"""GitHub Data object.""" """Defines a GitHub latest issue sensor entity."""
def __init__(self, repository, access_token, session, server_url=None): _name: str = "Latest Issue"
"""Set up GitHub.""" _coordinator_key: CoordinatorKeyType = "issue"
self._repository = repository
self.repository_path = repository[CONF_PATH]
self._github = GitHubAPI(
token=access_token, session=session, **{"base_url": server_url}
)
self.available = False coordinator: RepositoryIssueDataUpdateCoordinator
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
@property @property
def name(self): def available(self) -> bool:
"""Return the name of the sensor.""" """Return True if entity is available."""
return self._repository.get(CONF_NAME, self.repository_response.data.name) return super().available and self.coordinator.data.issues_count != 0
@property @property
def last_commit(self): def native_value(self) -> StateType:
"""Return the last issue.""" """Return the state of the sensor."""
return self.commit_response.data[0] if self.commit_response.data else None if (issue := self.coordinator.data.issue_last) is None:
return None
return issue.title[:255]
@property @property
def last_issue(self): def extra_state_attributes(self) -> Mapping[str, str | int | None] | None:
"""Return the last issue.""" """Return the extra state attributes."""
return self.issues_response.data[0] if self.issues_response.data else None 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 @property
def last_pull_request(self): def available(self) -> bool:
"""Return the last pull request.""" """Return True if entity is available."""
return self.pulls_response.data[0] if self.pulls_response.data else None return super().available and self.coordinator.data.pulls_count != 0
@property @property
def last_release(self): def native_value(self) -> StateType:
"""Return the last release.""" """Return the state of the sensor."""
return self.releases_response.data[0] if self.releases_response.data else None if (pull := self.coordinator.data.pull_last) is None:
return None
return pull.title[:255]
async def async_update(self): @property
"""Update GitHub data.""" def extra_state_attributes(self) -> Mapping[str, str | int | None] | None:
try: """Return the extra state attributes."""
await asyncio.gather( if (pull := self.coordinator.data.pull_last) is None:
self._update_repository(), return None
self._update_commit(), return {
self._update_issues(), "url": pull.html_url,
self._update_pulls(), "number": pull.number,
self._update_releases(), }
)
if self.repository_response.data.permissions.push:
await asyncio.gather(
self._update_clones(),
self._update_views(),
)
self.available = True class GitHubSensorLatestCommitEntity(GitHubSensorLatestBaseEntity):
except GitHubException as err: """Defines a GitHub latest commit sensor entity."""
_LOGGER.error("GitHub error for %s: %s", self.repository_path, err)
self.available = False
async def _update_repository(self): _coordinator_key: CoordinatorKeyType = "commit"
"""Update repository data.""" _name: str = "Latest Commit"
self.repository_response = await self._github.repos.get(self.repository_path)
async def _update_commit(self): coordinator: RepositoryCommitDataUpdateCoordinator
"""Update commit data."""
self.commit_response = await self._github.repos.list_commits(
self.repository_path, **{"params": {"per_page": 1}}
)
async def _update_issues(self): @property
"""Update issues data.""" def native_value(self) -> StateType:
self.issues_response = await self._github.repos.issues.list( """Return the state of the sensor."""
self.repository_path return self.coordinator.data.commit.message.splitlines()[0][:255]
)
async def _update_releases(self): @property
"""Update releases data.""" def extra_state_attributes(self) -> Mapping[str, str | int | None]:
self.releases_response = await self._github.repos.releases.list( """Return the extra state attributes."""
self.repository_path return {
) "sha": self.coordinator.data.sha,
"url": self.coordinator.data.html_url,
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

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_quakes",
"geonetnz_volcano", "geonetnz_volcano",
"gios", "gios",
"github",
"glances", "glances",
"goalzero", "goalzero",
"gogogate2", "gogogate2",

View file

@ -124,6 +124,9 @@ aioesphomeapi==10.6.0
# homeassistant.components.flo # homeassistant.components.flo
aioflo==2021.11.0 aioflo==2021.11.0
# homeassistant.components.github
aiogithubapi==22.1.0
# homeassistant.components.guardian # homeassistant.components.guardian
aioguardian==2021.11.0 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]