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
16 changed files with 1113 additions and 246 deletions

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,
}