"""Custom data update coordinators for the GitHub integration.""" from __future__ import annotations from typing import Literal, TypedDict from aiogithubapi import ( GitHubAPI, GitHubCommitModel, GitHubConnectionException, GitHubException, GitHubNotModifiedException, GitHubRatelimitException, GitHubReleaseModel, GitHubRepositoryModel, GitHubResponseModel, ) 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 self._last_response: GitHubResponseModel[T] | None = None super().__init__( hass, LOGGER, name=DOMAIN, update_interval=DEFAULT_UPDATE_INTERVAL, ) @property def _etag(self) -> str: """Return the ETag of the last response.""" return self._last_response.etag if self._last_response is not None else None async def fetch_data(self) -> GitHubResponseModel[T]: """Fetch data from GitHub API.""" @staticmethod def _parse_response(response: GitHubResponseModel[T]) -> T: """Parse the response from GitHub API.""" return response.data async def _async_update_data(self) -> T: try: response = await self.fetch_data() except GitHubNotModifiedException: LOGGER.debug( "Content for %s with %s not modified", self.repository, self.__class__.__name__, ) # Return the last known data if the request result was not modified return self.data except (GitHubConnectionException, GitHubRatelimitException) as exception: # These are expected and we dont log anything extra raise UpdateFailed(exception) from exception except GitHubException as exception: # These are unexpected and we log the trace to help with troubleshooting LOGGER.exception(exception) raise UpdateFailed(exception) from exception else: self._last_response = response return self._parse_response(response) class RepositoryInformationDataUpdateCoordinator( GitHubBaseDataUpdateCoordinator[GitHubRepositoryModel] ): """Data update coordinator for repository information.""" async def fetch_data(self) -> GitHubResponseModel[GitHubRepositoryModel]: """Get the latest data from GitHub.""" return await self._client.repos.get(self.repository, **{"etag": self._etag}) class RepositoryReleaseDataUpdateCoordinator( GitHubBaseDataUpdateCoordinator[GitHubReleaseModel] ): """Data update coordinator for repository release.""" @staticmethod def _parse_response( response: GitHubResponseModel[GitHubReleaseModel | None], ) -> GitHubReleaseModel | None: """Parse the response from GitHub API.""" if not response.data: return None for release in response.data: if not release.prerelease and not release.draft: return release # Fall back to the latest release if no non-prerelease release is found return response.data[0] async def fetch_data(self) -> GitHubReleaseModel | None: """Get the latest data from GitHub.""" return await self._client.repos.releases.list( self.repository, **{"etag": self._etag} ) class RepositoryIssueDataUpdateCoordinator( GitHubBaseDataUpdateCoordinator[IssuesPulls] ): """Data update coordinator for repository issues.""" _issue_etag: str | None = None _pull_etag: str | None = None @staticmethod def _parse_response(response: IssuesPulls) -> IssuesPulls: """Parse the response from GitHub API.""" return response async def fetch_data(self) -> IssuesPulls: """Get the latest data from GitHub.""" pulls_count = 0 pull_last = None issues_count = 0 issue_last = None try: pull_response = await self._client.repos.pulls.list( self.repository, **{"params": {"per_page": 1}, "etag": self._pull_etag}, ) except GitHubNotModifiedException: # Return the last known data if the request result was not modified pulls_count = self.data.pulls_count pull_last = self.data.pull_last else: self._pull_etag = pull_response.etag pulls_count = pull_response.last_page_number or len(pull_response.data) pull_last = pull_response.data[0] if pull_response.data else None try: issue_response = await self._client.repos.issues.list( self.repository, **{"params": {"per_page": 1}, "etag": self._issue_etag}, ) except GitHubNotModifiedException: # Return the last known data if the request result was not modified issues_count = self.data.issues_count issue_last = self.data.issue_last else: self._issue_etag = issue_response.etag issues_count = ( issue_response.last_page_number or len(issue_response.data) ) - pulls_count issue_last = issue_response.data[0] if issue_response.data 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_last, ) class RepositoryCommitDataUpdateCoordinator( GitHubBaseDataUpdateCoordinator[GitHubCommitModel] ): """Data update coordinator for repository commit.""" @staticmethod def _parse_response( response: GitHubResponseModel[GitHubCommitModel | None], ) -> GitHubCommitModel | None: """Parse the response from GitHub API.""" return response.data[0] if response.data else None async def fetch_data(self) -> GitHubCommitModel | None: """Get the latest data from GitHub.""" return await self._client.repos.list_commits( self.repository, **{"params": {"per_page": 1}, "etag": self._etag} ) class DataUpdateCoordinators(TypedDict): """Custom data update coordinators for the GitHub integration.""" information: RepositoryInformationDataUpdateCoordinator release: RepositoryReleaseDataUpdateCoordinator issue: RepositoryIssueDataUpdateCoordinator commit: RepositoryCommitDataUpdateCoordinator