From 6a0c3843e51085e59d6fb69920733485f2f98fe5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= <joasoe@gmail.com>
Date: Tue, 18 Jan 2022 20:04:01 +0100
Subject: [PATCH] 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>
---
 .coveragerc                                   |   2 +
 CODEOWNERS                                    |   1 +
 homeassistant/components/github/__init__.py   |  83 ++-
 .../components/github/config_flow.py          | 229 +++++++
 homeassistant/components/github/const.py      |  29 +
 .../components/github/coordinator.py          | 141 +++++
 homeassistant/components/github/manifest.json |   3 +-
 homeassistant/components/github/sensor.py     | 566 ++++++++++--------
 homeassistant/components/github/strings.json  |  19 +
 .../components/github/translations/en.json    |  29 +
 homeassistant/generated/config_flows.py       |   1 +
 requirements_test_all.txt                     |   3 +
 tests/components/github/__init__.py           |   1 +
 tests/components/github/common.py             |   3 +
 tests/components/github/conftest.py           |  34 ++
 tests/components/github/test_config_flow.py   | 233 +++++++
 16 files changed, 1122 insertions(+), 255 deletions(-)
 create mode 100644 homeassistant/components/github/config_flow.py
 create mode 100644 homeassistant/components/github/const.py
 create mode 100644 homeassistant/components/github/coordinator.py
 create mode 100644 homeassistant/components/github/strings.json
 create mode 100644 homeassistant/components/github/translations/en.json
 create mode 100644 tests/components/github/__init__.py
 create mode 100644 tests/components/github/common.py
 create mode 100644 tests/components/github/conftest.py
 create mode 100644 tests/components/github/test_config_flow.py

diff --git a/.coveragerc b/.coveragerc
index edf8043e4f6..8583f9a5229 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -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
diff --git a/CODEOWNERS b/CODEOWNERS
index 6d5b91f5ef6..75c9465f6d6 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -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
diff --git a/homeassistant/components/github/__init__.py b/homeassistant/components/github/__init__.py
index 6dd5d7f16ff..abc376e0337 100644
--- a/homeassistant/components/github/__init__.py
+++ b/homeassistant/components/github/__init__.py
@@ -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)
diff --git a/homeassistant/components/github/config_flow.py b/homeassistant/components/github/config_flow.py
new file mode 100644
index 00000000000..f0e27283355
--- /dev/null
+++ b/homeassistant/components/github/config_flow.py
@@ -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)
diff --git a/homeassistant/components/github/const.py b/homeassistant/components/github/const.py
new file mode 100644
index 00000000000..7a0c471ab03
--- /dev/null
+++ b/homeassistant/components/github/const.py
@@ -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
diff --git a/homeassistant/components/github/coordinator.py b/homeassistant/components/github/coordinator.py
new file mode 100644
index 00000000000..790a8680926
--- /dev/null
+++ b/homeassistant/components/github/coordinator.py
@@ -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
diff --git a/homeassistant/components/github/manifest.json b/homeassistant/components/github/manifest.json
index 158203b4dc6..474d08c4b0c 100644
--- a/homeassistant/components/github/manifest.json
+++ b/homeassistant/components/github/manifest.json
@@ -9,5 +9,6 @@
     "@timmo001",
     "@ludeeus"
   ],
-  "iot_class": "cloud_polling"
+  "iot_class": "cloud_polling",
+  "config_flow": true
 }
\ No newline at end of file
diff --git a/homeassistant/components/github/sensor.py b/homeassistant/components/github/sensor.py
index 37f8439964c..95c1cc26f34 100644
--- a/homeassistant/components/github/sensor.py
+++ b/homeassistant/components/github/sensor.py
@@ -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
-
-_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}
+from homeassistant.helpers.typing import StateType
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DOMAIN, IssuesPulls
+from .coordinator import (
+    CoordinatorKeyType,
+    DataUpdateCoordinators,
+    GitHubBaseDataUpdateCoordinator,
+    RepositoryCommitDataUpdateCoordinator,
+    RepositoryIssueDataUpdateCoordinator,
+    RepositoryReleaseDataUpdateCoordinator,
 )
 
-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]),
-    }
+
+@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_platform(
+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 GitHubSensorBaseEntity(CoordinatorEntity, SensorEntity):
+    """Defines a base GitHub sensor entity."""
 
-class GitHubSensor(SensorEntity):
-    """Representation of a GitHub sensor."""
+    _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
-
-    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]
-
-        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
-
-        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
-
-        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
-
-        if self._github_data.last_issue:
-            self._latest_open_issue_url = self._github_data.last_issue.html_url
-
-        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,
-        }
-        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."""
-
-    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}
+    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(' ', '_')}"
         )
 
-        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
+
+class GitHubSensorLatestReleaseEntity(GitHubSensorLatestBaseEntity):
+    """Defines a GitHub latest release sensor entity."""
+
+    _coordinator_key: CoordinatorKeyType = "release"
+    _name: str = "Latest Release"
+
+    _attr_entity_registry_enabled_default = True
+
+    coordinator: RepositoryReleaseDataUpdateCoordinator
+
+    @property
+    def native_value(self) -> StateType:
+        """Return the state of the sensor."""
+        return self.coordinator.data.name[:255]
+
+    @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,
+        }
+
+
+class GitHubSensorLatestIssueEntity(GitHubSensorLatestBaseEntity):
+    """Defines a GitHub latest issue sensor entity."""
+
+    _name: str = "Latest Issue"
+    _coordinator_key: CoordinatorKeyType = "issue"
+
+    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
-
-    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(),
-            )
+    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]
 
-            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
-
-    async def _update_repository(self):
-        """Update repository data."""
-        self.repository_response = await self._github.repos.get(self.repository_path)
-
-    async def _update_commit(self):
-        """Update commit data."""
-        self.commit_response = await self._github.repos.list_commits(
-            self.repository_path, **{"params": {"per_page": 1}}
-        )
+    @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,
+        }
 
-    async def _update_issues(self):
-        """Update issues data."""
-        self.issues_response = await self._github.repos.issues.list(
-            self.repository_path
-        )
 
-    async def _update_releases(self):
-        """Update releases data."""
-        self.releases_response = await self._github.repos.releases.list(
-            self.repository_path
-        )
+class GitHubSensorLatestCommitEntity(GitHubSensorLatestBaseEntity):
+    """Defines a GitHub latest commit sensor entity."""
 
-    async def _update_clones(self):
-        """Update clones data."""
-        self.clones_response = await self._github.repos.traffic.clones(
-            self.repository_path
-        )
+    _coordinator_key: CoordinatorKeyType = "commit"
+    _name: str = "Latest Commit"
 
-    async def _update_views(self):
-        """Update views data."""
-        self.views_response = await self._github.repos.traffic.views(
-            self.repository_path
-        )
+    coordinator: RepositoryCommitDataUpdateCoordinator
 
-    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)
+    @property
+    def native_value(self) -> StateType:
+        """Return the state of the sensor."""
+        return self.coordinator.data.commit.message.splitlines()[0][:255]
 
-        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,
+        }
diff --git a/homeassistant/components/github/strings.json b/homeassistant/components/github/strings.json
new file mode 100644
index 00000000000..ac80086ed3a
--- /dev/null
+++ b/homeassistant/components/github/strings.json
@@ -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"
+    }
+  }
+}
\ No newline at end of file
diff --git a/homeassistant/components/github/translations/en.json b/homeassistant/components/github/translations/en.json
new file mode 100644
index 00000000000..f53626c219f
--- /dev/null
+++ b/homeassistant/components/github/translations/en.json
@@ -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"
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py
index 9d8b644c0f7..fbc4035d230 100644
--- a/homeassistant/generated/config_flows.py
+++ b/homeassistant/generated/config_flows.py
@@ -115,6 +115,7 @@ FLOWS = [
     "geonetnz_quakes",
     "geonetnz_volcano",
     "gios",
+    "github",
     "glances",
     "goalzero",
     "gogogate2",
diff --git a/requirements_test_all.txt b/requirements_test_all.txt
index 3d06f7de7ad..4013901fcde 100644
--- a/requirements_test_all.txt
+++ b/requirements_test_all.txt
@@ -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
 
diff --git a/tests/components/github/__init__.py b/tests/components/github/__init__.py
new file mode 100644
index 00000000000..55c9fb86994
--- /dev/null
+++ b/tests/components/github/__init__.py
@@ -0,0 +1 @@
+"""Tests for the GitHub integration."""
diff --git a/tests/components/github/common.py b/tests/components/github/common.py
new file mode 100644
index 00000000000..9686fa8544d
--- /dev/null
+++ b/tests/components/github/common.py
@@ -0,0 +1,3 @@
+"""Common helpers for GitHub integration tests."""
+
+MOCK_ACCESS_TOKEN = "gho_16C7e42F292c6912E7710c838347Ae178B4a"
diff --git a/tests/components/github/conftest.py b/tests/components/github/conftest.py
new file mode 100644
index 00000000000..bcceea56bc9
--- /dev/null
+++ b/tests/components/github/conftest.py
@@ -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
diff --git a/tests/components/github/test_config_flow.py b/tests/components/github/test_config_flow.py
new file mode 100644
index 00000000000..f040cbd2998
--- /dev/null
+++ b/tests/components/github/test_config_flow.py
@@ -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]
-- 
GitLab