├── .github └── workflows │ ├── hassfest.yaml │ └── pythonpackage.yaml ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── custom_components ├── __init__.py └── github_custom │ ├── __init__.py │ ├── config_flow.py │ ├── const.py │ ├── manifest.json │ ├── sensor.py │ ├── strings.json │ └── translations │ └── en.json ├── hacs.json ├── requirements.test.txt ├── setup.cfg └── tests ├── __init__.py ├── bandit.yaml ├── conftest.py ├── test_config_flow.py └── test_sensor.py /.github/workflows/hassfest.yaml: -------------------------------------------------------------------------------- 1 | name: Validate with hassfest 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | validate: 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - uses: "actions/checkout@v4" 14 | - uses: home-assistant/actions/hassfest@master 15 | -------------------------------------------------------------------------------- /.github/workflows/pythonpackage.yaml: -------------------------------------------------------------------------------- 1 | name: Python package 2 | 3 | on: 4 | push: 5 | schedule: 6 | - cron: "30 16 * * WED" 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | max-parallel: 4 13 | matrix: 14 | python-version: ["3.10"] 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Set up Python ${{ matrix.python-version }} 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install --upgrade pip 26 | pip install -r requirements.test.txt 27 | 28 | - name: Run pytest 29 | run: | 30 | pytest 31 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/asottile/pyupgrade 3 | rev: v2.7.4 4 | hooks: 5 | - id: pyupgrade 6 | args: [--py37-plus] 7 | - repo: https://github.com/psf/black 8 | rev: 22.3.0 9 | hooks: 10 | - id: black 11 | args: 12 | - --safe 13 | - --quiet 14 | files: ^((homeassistant|script|tests)/.+)?[^/]+\.py$ 15 | - repo: https://github.com/codespell-project/codespell 16 | rev: v1.17.1 17 | hooks: 18 | - id: codespell 19 | args: 20 | - --ignore-words-list=hass,alot,datas,dof,dur,farenheit,hist,iff,ines,ist,lightsensor,mut,nd,pres,referer,ser,serie,te,technik,ue,uint,visability,wan,wanna,withing 21 | - --skip="./.*,*.csv,*.json" 22 | - --quiet-level=2 23 | exclude_types: [csv, json] 24 | - repo: https://github.com/pycqa/flake8.git 25 | rev: 3.9.2 26 | hooks: 27 | - id: flake8 28 | additional_dependencies: 29 | - flake8-docstrings==1.5.0 30 | - pydocstyle==5.0.2 31 | files: ^(homeassistant|script|tests)/.+\.py$ 32 | - repo: https://github.com/PyCQA/bandit 33 | rev: 1.6.2 34 | hooks: 35 | - id: bandit 36 | args: 37 | - --quiet 38 | - --format=custom 39 | - --configfile=tests/bandit.yaml 40 | files: ^(homeassistant|script|tests)/.+\.py$ 41 | - repo: https://github.com/pre-commit/mirrors-isort 42 | rev: v5.6.4 43 | hooks: 44 | - id: isort 45 | - repo: https://github.com/pre-commit/pre-commit-hooks 46 | rev: v3.3.0 47 | hooks: 48 | - id: check-executables-have-shebangs 49 | stages: [manual] 50 | - id: check-json 51 | - repo: https://github.com/pre-commit/mirrors-mypy 52 | rev: v0.790 53 | hooks: 54 | - id: mypy 55 | args: 56 | - --pretty 57 | - --show-error-codes 58 | - --show-error-context 59 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Aaron Godfrey 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Github Custom for Home Assistant 2 | 3 | [![](https://img.shields.io/github/license/boralyl/github-custom-component-tutorial?style=for-the-badge)](LICENSE) 4 | [![](https://img.shields.io/github/actions/workflow/status/boralyl/github-custom-component-tutorial/pythonpackage.yaml?branch=main&style=for-the-badge)](https://github.com/boralyl/github-custom-component-tutorial/actions) 5 | 6 | ## About 7 | 8 | This repo contains a custom component for [Home Assistant](https://www.home-assistant.io) that was created in a tutorial series 9 | on [aarongodfrey.dev](https://aarongodfrey.dev/home%20automation/building_a_home_assistant_custom_component_part_1/). 10 | 11 | The tutorial walks through the steps to create a custom component for use in Home Assistant. 12 | 13 | - [Part 1: Project Structure and Basics](https://aarongodfrey.dev/home%20automation/building_a_home_assistant_custom_component_part_1/) 14 | - [Part 2: Unit Testing and Continuous Integration](https://aarongodfrey.dev/home%20automation/building_a_home_assistant_custom_component_part_2/) 15 | - [Part 3: Adding a Config Flow](https://aarongodfrey.dev/home%20automation/building_a_home_assistant_custom_component_part_3/) 16 | - [Part 4: Adding an Options Flow](https://aarongodfrey.dev/home%20automation/building_a_home_assistant_custom_component_part_4/) 17 | - [Part 5: Debugging](https://aarongodfrey.dev/home%20automation/building_a_home_assistant_custom_component_part_5/) 18 | 19 | ## What It Is 20 | 21 | An integration that monitors [GitHub](https://github.com/) repositories specified in a `configuration.yaml` file 22 | or optionally via the Integrations UI. 23 | 24 | ## Running Tests 25 | 26 | To run the test suite create a virtualenv (I recommend checking out [pyenv](https://github.com/pyenv/pyenv) and [pyenv-virtualenv](https://github.com/pyenv/pyenv-virtualenv) for this) and install the test requirements. 27 | 28 | ```bash 29 | $ pip install -r requirements.test.txt 30 | ``` 31 | 32 | After the test dependencies are installed you can simply invoke `pytest` to run 33 | the test suite. 34 | 35 | ```bash 36 | $ pytest 37 | Test session starts (platform: linux, Python 3.7.5, pytest 5.4.3, pytest-sugar 0.9.4) 38 | rootdir: /home/aaron/projects/github-custom, inifile: setup.cfg, testpaths: tests 39 | plugins: forked-1.3.0, timeout-1.4.2, cov-2.10.1, aiohttp-0.3.0, requests-mock-1.8.0, xdist-2.1.0, sugar-0.9.4, test-groups-1.0.3, homeassistant-custom-component-0.0.20 40 | collecting ... 41 | tests/test_config_flow.py ✓✓✓✓✓✓✓✓✓✓✓ 85% ████████▌ 42 | tests/test_sensor.py ✓✓ 100% ██████████ 43 | 44 | ----------- coverage: platform linux, python 3.7.5-final-0 ----------- 45 | Name Stmts Miss Cover Missing 46 | ------------------------------------------------------------------------------ 47 | custom_components/__init__.py 0 0 100% 48 | custom_components/github_custom/__init__.py 12 0 100% 49 | custom_components/github_custom/config_flow.py 23 0 100% 50 | custom_components/github_custom/const.py 18 0 100% 51 | custom_components/github_custom/sensor.py 97 5 95% 86-89, 121 52 | ------------------------------------------------------------------------------ 53 | TOTAL 150 5 97% 54 | 55 | Required test coverage of 93.0% reached. Total coverage: 96.67% 56 | 57 | Results (0.73s): 58 | 13 passed 59 | ``` 60 | -------------------------------------------------------------------------------- /custom_components/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boralyl/github-custom-component-tutorial/99ec818da34bf85a8e9a8eea5ef9fda5a5d88c97/custom_components/__init__.py -------------------------------------------------------------------------------- /custom_components/github_custom/__init__.py: -------------------------------------------------------------------------------- 1 | """GitHub Custom Component.""" 2 | import asyncio 3 | import logging 4 | 5 | from homeassistant import config_entries, core 6 | from homeassistant.const import Platform 7 | 8 | from .const import DOMAIN 9 | 10 | _LOGGER = logging.getLogger(__name__) 11 | 12 | PLATFORMS = [Platform.SENSOR] 13 | 14 | 15 | async def async_setup_entry( 16 | hass: core.HomeAssistant, entry: config_entries.ConfigEntry 17 | ) -> bool: 18 | """Set up platform from a ConfigEntry.""" 19 | hass.data.setdefault(DOMAIN, {}) 20 | hass_data = dict(entry.data) 21 | # Registers update listener to update config entry when options are updated. 22 | unsub_options_update_listener = entry.add_update_listener(options_update_listener) 23 | # Store a reference to the unsubscribe function to cleanup if an entry is unloaded. 24 | hass_data["unsub_options_update_listener"] = unsub_options_update_listener 25 | hass.data[DOMAIN][entry.entry_id] = hass_data 26 | 27 | # Forward the setup to the sensor platform. 28 | await hass.config_entries.async_forward_entry_setups(entry, ["sensor"]) 29 | return True 30 | 31 | 32 | async def options_update_listener( 33 | hass: core.HomeAssistant, config_entry: config_entries.ConfigEntry 34 | ): 35 | """Handle options update.""" 36 | await hass.config_entries.async_reload(config_entry.entry_id) 37 | 38 | 39 | async def async_unload_entry( 40 | hass: core.HomeAssistant, entry: config_entries.ConfigEntry 41 | ) -> bool: 42 | """Unload a config entry.""" 43 | if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): 44 | # Remove config entry from domain. 45 | entry_data = hass.data[DOMAIN].pop(entry.entry_id) 46 | # Remove options_update_listener. 47 | entry_data["unsub_options_update_listener"]() 48 | 49 | return unload_ok 50 | 51 | 52 | async def async_setup(hass: core.HomeAssistant, config: dict) -> bool: 53 | """Set up the GitHub Custom component from yaml configuration.""" 54 | hass.data.setdefault(DOMAIN, {}) 55 | return True 56 | -------------------------------------------------------------------------------- /custom_components/github_custom/config_flow.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | import logging 3 | from typing import Any, Dict, Optional 4 | 5 | from gidgethub import BadRequest 6 | from gidgethub.aiohttp import GitHubAPI 7 | from homeassistant import config_entries, core 8 | from homeassistant.const import CONF_ACCESS_TOKEN, CONF_NAME, CONF_PATH, CONF_URL 9 | from homeassistant.core import callback 10 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 11 | import homeassistant.helpers.config_validation as cv 12 | from homeassistant.helpers.entity_registry import ( 13 | async_entries_for_config_entry, 14 | async_get, 15 | ) 16 | import voluptuous as vol 17 | 18 | from .const import CONF_REPOS, DOMAIN 19 | 20 | _LOGGER = logging.getLogger(__name__) 21 | 22 | AUTH_SCHEMA = vol.Schema( 23 | {vol.Required(CONF_ACCESS_TOKEN): cv.string, vol.Optional(CONF_URL): cv.string} 24 | ) 25 | REPO_SCHEMA = vol.Schema( 26 | { 27 | vol.Required(CONF_PATH): cv.string, 28 | vol.Optional(CONF_NAME): cv.string, 29 | vol.Optional("add_another"): cv.boolean, 30 | } 31 | ) 32 | 33 | OPTIONS_SHCEMA = vol.Schema({vol.Optional(CONF_NAME, default="foo"): cv.string}) 34 | 35 | 36 | async def validate_path(path: str, access_token: str, hass: core.HassJob) -> None: 37 | """Validates a GitHub repo path. 38 | 39 | Raises a ValueError if the path is invalid. 40 | """ 41 | if len(path.split("/")) != 2: 42 | raise ValueError 43 | session = async_get_clientsession(hass) 44 | gh = GitHubAPI(session, "requester", oauth_token=access_token) 45 | try: 46 | await gh.getitem(f"repos/{path}") 47 | except BadRequest: 48 | raise ValueError 49 | 50 | 51 | async def validate_auth(access_token: str, hass: core.HomeAssistant) -> None: 52 | """Validates a GitHub access token. 53 | 54 | Raises a ValueError if the auth token is invalid. 55 | """ 56 | session = async_get_clientsession(hass) 57 | gh = GitHubAPI(session, "requester", oauth_token=access_token) 58 | try: 59 | await gh.getitem("repos/home-assistant/core") 60 | except BadRequest: 61 | raise ValueError 62 | 63 | 64 | class GithubCustomConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 65 | """Github Custom config flow.""" 66 | 67 | data: Optional[Dict[str, Any]] 68 | 69 | async def async_step_user(self, user_input: Optional[Dict[str, Any]] = None): 70 | """Invoked when a user initiates a flow via the user interface.""" 71 | errors: Dict[str, str] = {} 72 | if user_input is not None: 73 | try: 74 | await validate_auth(user_input[CONF_ACCESS_TOKEN], self.hass) 75 | except ValueError: 76 | errors["base"] = "auth" 77 | if not errors: 78 | # Input is valid, set data. 79 | self.data = user_input 80 | self.data[CONF_REPOS] = [] 81 | # Return the form of the next step. 82 | return await self.async_step_repo() 83 | 84 | return self.async_show_form( 85 | step_id="user", data_schema=AUTH_SCHEMA, errors=errors 86 | ) 87 | 88 | async def async_step_repo(self, user_input: Optional[Dict[str, Any]] = None): 89 | """Second step in config flow to add a repo to watch.""" 90 | errors: Dict[str, str] = {} 91 | if user_input is not None: 92 | # Validate the path. 93 | try: 94 | await validate_path( 95 | user_input[CONF_PATH], self.data[CONF_ACCESS_TOKEN], self.hass 96 | ) 97 | except ValueError: 98 | errors["base"] = "invalid_path" 99 | 100 | if not errors: 101 | # Input is valid, set data. 102 | self.data[CONF_REPOS].append( 103 | { 104 | "path": user_input[CONF_PATH], 105 | "name": user_input.get(CONF_NAME, user_input[CONF_PATH]), 106 | } 107 | ) 108 | # If user ticked the box show this form again so they can add an 109 | # additional repo. 110 | if user_input.get("add_another", False): 111 | return await self.async_step_repo() 112 | 113 | # User is done adding repos, create the config entry. 114 | return self.async_create_entry(title="GitHub Custom", data=self.data) 115 | 116 | return self.async_show_form( 117 | step_id="repo", data_schema=REPO_SCHEMA, errors=errors 118 | ) 119 | 120 | @staticmethod 121 | @callback 122 | def async_get_options_flow(config_entry): 123 | """Get the options flow for this handler.""" 124 | return OptionsFlowHandler(config_entry) 125 | 126 | 127 | class OptionsFlowHandler(config_entries.OptionsFlow): 128 | """Handles options flow for the component.""" 129 | 130 | def __init__(self, config_entry: config_entries.ConfigEntry) -> None: 131 | self.config_entry = config_entry 132 | 133 | async def async_step_init( 134 | self, user_input: Dict[str, Any] = None 135 | ) -> Dict[str, Any]: 136 | """Manage the options for the custom component.""" 137 | errors: Dict[str, str] = {} 138 | # Grab all configured repos from the entity registry so we can populate the 139 | # multi-select dropdown that will allow a user to remove a repo. 140 | entity_registry = async_get(self.hass) 141 | entries = async_entries_for_config_entry( 142 | entity_registry, self.config_entry.entry_id 143 | ) 144 | # Default value for our multi-select. 145 | all_repos = {e.entity_id: e.original_name for e in entries} 146 | repo_map = {e.entity_id: e for e in entries} 147 | 148 | if user_input is not None: 149 | updated_repos = deepcopy(self.config_entry.data[CONF_REPOS]) 150 | 151 | # Remove any unchecked repos. 152 | removed_entities = [ 153 | entity_id 154 | for entity_id in repo_map.keys() 155 | if entity_id not in user_input["repos"] 156 | ] 157 | for entity_id in removed_entities: 158 | # Unregister from HA 159 | entity_registry.async_remove(entity_id) 160 | # Remove from our configured repos. 161 | entry = repo_map[entity_id] 162 | entry_path = entry.unique_id 163 | updated_repos = [e for e in updated_repos if e["path"] != entry_path] 164 | 165 | if user_input.get(CONF_PATH): 166 | # Validate the path. 167 | access_token = self.hass.data[DOMAIN][self.config_entry.entry_id][ 168 | CONF_ACCESS_TOKEN 169 | ] 170 | try: 171 | await validate_path(user_input[CONF_PATH], access_token, self.hass) 172 | except ValueError: 173 | errors["base"] = "invalid_path" 174 | 175 | if not errors: 176 | # Add the new repo. 177 | updated_repos.append( 178 | { 179 | "path": user_input[CONF_PATH], 180 | "name": user_input.get(CONF_NAME, user_input[CONF_PATH]), 181 | } 182 | ) 183 | 184 | if not errors: 185 | # Value of data will be set on the options property of our config_entry 186 | # instance. 187 | return self.async_create_entry( 188 | title="", 189 | data={CONF_REPOS: updated_repos}, 190 | ) 191 | 192 | options_schema = vol.Schema( 193 | { 194 | vol.Optional("repos", default=list(all_repos.keys())): cv.multi_select( 195 | all_repos 196 | ), 197 | vol.Optional(CONF_PATH): cv.string, 198 | vol.Optional(CONF_NAME): cv.string, 199 | } 200 | ) 201 | return self.async_show_form( 202 | step_id="init", data_schema=options_schema, errors=errors 203 | ) 204 | -------------------------------------------------------------------------------- /custom_components/github_custom/const.py: -------------------------------------------------------------------------------- 1 | DOMAIN = "github_custom" 2 | 3 | ATTR_CLONES = "clones" 4 | ATTR_CLONES_UNIQUE = "clones_unique" 5 | ATTR_FORKS = "forks" 6 | ATTR_LATEST_COMMIT_MESSAGE = "latest_commit_message" 7 | ATTR_LATEST_COMMIT_SHA = "latest_commit_sha" 8 | ATTR_LATEST_OPEN_ISSUE_URL = "latest_open_issue_url" 9 | ATTR_LATEST_OPEN_PULL_REQUEST_URL = "latest_open_pull_request_url" 10 | ATTR_LATEST_RELEASE_TAG = "latest_release_tag" 11 | ATTR_LATEST_RELEASE_URL = "latest_release_url" 12 | ATTR_OPEN_ISSUES = "open_issues" 13 | ATTR_OPEN_PULL_REQUESTS = "open_pull_requests" 14 | ATTR_PATH = "path" 15 | ATTR_STARGAZERS = "stargazers" 16 | ATTR_VIEWS = "views" 17 | ATTR_VIEWS_UNIQUE = "views_unique" 18 | 19 | BASE_API_URL = "https://api.github.com" 20 | 21 | CONF_REPOS = "repositories" 22 | -------------------------------------------------------------------------------- /custom_components/github_custom/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "github_custom", 3 | "name": "Github Custom", 4 | "codeowners": ["@boralyl"], 5 | "config_flow": true, 6 | "dependencies": [], 7 | "documentation": "https://github.com/boralyl/github-custom-component-tutorial", 8 | "iot_class": "cloud_polling", 9 | "requirements": ["gidgethub[aiohttp]", "cryptography"], 10 | "version": "1.0.2" 11 | } 12 | -------------------------------------------------------------------------------- /custom_components/github_custom/sensor.py: -------------------------------------------------------------------------------- 1 | """GitHub sensor platform.""" 2 | from __future__ import annotations 3 | 4 | from collections.abc import Callable 5 | from datetime import timedelta 6 | import logging 7 | from typing import Any 8 | 9 | from aiohttp import ClientError 10 | import gidgethub 11 | from gidgethub.aiohttp import GitHubAPI 12 | from homeassistant import config_entries, core 13 | from homeassistant.components.sensor import PLATFORM_SCHEMA 14 | from homeassistant.const import ( 15 | ATTR_NAME, 16 | CONF_ACCESS_TOKEN, 17 | CONF_NAME, 18 | CONF_PATH, 19 | CONF_URL, 20 | ) 21 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 22 | import homeassistant.helpers.config_validation as cv 23 | from homeassistant.helpers.entity import Entity 24 | from homeassistant.helpers.typing import ( 25 | ConfigType, 26 | DiscoveryInfoType, 27 | ) 28 | import voluptuous as vol 29 | 30 | from .const import ( 31 | ATTR_CLONES, 32 | ATTR_CLONES_UNIQUE, 33 | ATTR_FORKS, 34 | ATTR_LATEST_COMMIT_MESSAGE, 35 | ATTR_LATEST_COMMIT_SHA, 36 | ATTR_LATEST_OPEN_ISSUE_URL, 37 | ATTR_LATEST_OPEN_PULL_REQUEST_URL, 38 | ATTR_LATEST_RELEASE_TAG, 39 | ATTR_LATEST_RELEASE_URL, 40 | ATTR_OPEN_ISSUES, 41 | ATTR_OPEN_PULL_REQUESTS, 42 | ATTR_PATH, 43 | ATTR_STARGAZERS, 44 | ATTR_VIEWS, 45 | ATTR_VIEWS_UNIQUE, 46 | CONF_REPOS, 47 | DOMAIN, 48 | ) 49 | 50 | _LOGGER = logging.getLogger(__name__) 51 | # Time between updating data from GitHub 52 | SCAN_INTERVAL = timedelta(minutes=10) 53 | 54 | REPO_SCHEMA = vol.Schema( 55 | {vol.Required(CONF_PATH): cv.string, vol.Optional(CONF_NAME): cv.string} 56 | ) 57 | 58 | PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( 59 | { 60 | vol.Required(CONF_ACCESS_TOKEN): cv.string, 61 | vol.Required(CONF_REPOS): vol.All(cv.ensure_list, [REPO_SCHEMA]), 62 | vol.Optional(CONF_URL): cv.url, 63 | } 64 | ) 65 | 66 | 67 | async def async_setup_entry( 68 | hass: core.HomeAssistant, 69 | config_entry: config_entries.ConfigEntry, 70 | async_add_entities, 71 | ) -> None: 72 | """Setup sensors from a config entry created in the integrations UI.""" 73 | config = hass.data[DOMAIN][config_entry.entry_id] 74 | # Update our config to include new repos and remove those that have been removed. 75 | if config_entry.options: 76 | config.update(config_entry.options) 77 | session = async_get_clientsession(hass) 78 | github = GitHubAPI(session, "requester", oauth_token=config[CONF_ACCESS_TOKEN]) 79 | sensors = [GitHubRepoSensor(github, repo) for repo in config[CONF_REPOS]] 80 | async_add_entities(sensors, update_before_add=True) 81 | 82 | 83 | async def async_setup_platform( 84 | hass: core.HomeAssistant, 85 | config: ConfigType, 86 | async_add_entities: Callable, 87 | discovery_info: DiscoveryInfoType | None = None, 88 | ) -> None: 89 | """Set up the sensor platform.""" 90 | session = async_get_clientsession(hass) 91 | github = GitHubAPI(session, "requester", oauth_token=config[CONF_ACCESS_TOKEN]) 92 | sensors = [GitHubRepoSensor(github, repo) for repo in config[CONF_REPOS]] 93 | async_add_entities(sensors, update_before_add=True) 94 | 95 | 96 | class GitHubRepoSensor(Entity): 97 | """Representation of a GitHub Repo sensor.""" 98 | 99 | def __init__(self, github: GitHubAPI, repo: dict[str, str]): 100 | super().__init__() 101 | self.github = github 102 | self.repo = repo["path"] 103 | self.attrs: dict[str, Any] = {ATTR_PATH: self.repo} 104 | self._name = repo.get("name", self.repo) 105 | self._state = None 106 | self._available = True 107 | 108 | @property 109 | def name(self) -> str: 110 | """Return the name of the entity.""" 111 | return self._name 112 | 113 | @property 114 | def unique_id(self) -> str: 115 | """Return the unique ID of the sensor.""" 116 | return self.repo 117 | 118 | @property 119 | def available(self) -> bool: 120 | """Return True if entity is available.""" 121 | return self._available 122 | 123 | @property 124 | def state(self) -> str | None: 125 | return self._state 126 | 127 | @property 128 | def extra_state_attributes(self) -> dict[str, Any]: 129 | return self.attrs 130 | 131 | async def async_update(self) -> None: 132 | """Update all sensors.""" 133 | try: 134 | repo_url = f"/repos/{self.repo}" 135 | repo_data = await self.github.getitem(repo_url) 136 | self.attrs[ATTR_FORKS] = repo_data["forks_count"] 137 | self.attrs[ATTR_NAME] = repo_data["name"] 138 | self.attrs[ATTR_STARGAZERS] = repo_data["stargazers_count"] 139 | 140 | if repo_data["permissions"]["push"]: 141 | clones_url = f"{repo_url}/traffic/clones" 142 | clones_data = await self.github.getitem(clones_url) 143 | self.attrs[ATTR_CLONES] = clones_data["count"] 144 | self.attrs[ATTR_CLONES_UNIQUE] = clones_data["uniques"] 145 | 146 | views_url = f"{repo_url}/traffic/views" 147 | views_data = await self.github.getitem(views_url) 148 | self.attrs[ATTR_VIEWS] = views_data["count"] 149 | self.attrs[ATTR_VIEWS_UNIQUE] = views_data["uniques"] 150 | 151 | commits_url = f"/repos/{self.repo}/commits" 152 | commits_data = await self.github.getitem(commits_url) 153 | latest_commit = commits_data[0] 154 | self.attrs[ATTR_LATEST_COMMIT_MESSAGE] = latest_commit["commit"]["message"] 155 | self.attrs[ATTR_LATEST_COMMIT_SHA] = latest_commit["sha"] 156 | 157 | # Using the search api to fetch open PRs. 158 | prs_url = f"/search/issues?q=repo:{self.repo}+state:open+is:pr" 159 | prs_data = await self.github.getitem(prs_url) 160 | self.attrs[ATTR_OPEN_PULL_REQUESTS] = prs_data["total_count"] 161 | if prs_data and prs_data["items"]: 162 | self.attrs[ATTR_LATEST_OPEN_PULL_REQUEST_URL] = prs_data["items"][0][ 163 | "html_url" 164 | ] 165 | 166 | issues_url = f"/repos/{self.repo}/issues" 167 | issues_data = await self.github.getitem(issues_url) 168 | # GitHub issues include pull requests, so to just get the number of issues, 169 | # we need to subtract the total number of pull requests from this total. 170 | total_issues = repo_data["open_issues_count"] 171 | self.attrs[ATTR_OPEN_ISSUES] = ( 172 | total_issues - self.attrs[ATTR_OPEN_PULL_REQUESTS] 173 | ) 174 | if issues_data: 175 | self.attrs[ATTR_LATEST_OPEN_ISSUE_URL] = issues_data[0]["html_url"] 176 | 177 | releases_url = f"/repos/{self.repo}/releases" 178 | releases_data = await self.github.getitem(releases_url) 179 | if releases_data: 180 | self.attrs[ATTR_LATEST_RELEASE_URL] = releases_data[0]["html_url"] 181 | self.attrs[ATTR_LATEST_RELEASE_TAG] = releases_data[0][ 182 | "html_url" 183 | ].split("/")[-1] 184 | 185 | # Set state to short commit sha. 186 | self._state = latest_commit["sha"][:7] 187 | self._available = True 188 | except (ClientError, gidgethub.GitHubException): 189 | self._available = False 190 | _LOGGER.exception( 191 | "Error retrieving data from GitHub for sensor %s", self.name 192 | ) 193 | -------------------------------------------------------------------------------- /custom_components/github_custom/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "error": { 4 | "auth": "The auth token provided is not valid.", 5 | "invalid_path": "The path provided is not valid. Should be in the format `user/repo-name`." 6 | }, 7 | "step": { 8 | "user": { 9 | "data": { 10 | "access_token": "GitHub Access Token", 11 | "url": "GitHub Enterprise server URL" 12 | }, 13 | "description": "Enter your GitHub credentials.", 14 | "title": "Authentication" 15 | }, 16 | "repo": { 17 | "data": { 18 | "add_another": "Add another repo?", 19 | "path": "Path to the repository e.g. home-assistant/core", 20 | "name": "Name of the sensor." 21 | }, 22 | "description": "Add a GitHub repo, check the box to add another.", 23 | "title": "Add GitHub Repository" 24 | } 25 | } 26 | }, 27 | "options": { 28 | "error": { 29 | "invalid_path": "The path provided is not valid. Should be in the format `user/repo-name` and should be a valid github repository." 30 | }, 31 | "step": { 32 | "init": { 33 | "title": "Manage Repos", 34 | "data": { 35 | "repos": "Existing Repos: Uncheck any repos you want to remove.", 36 | "path": "New Repo: Path to the repository e.g. home-assistant-core", 37 | "name": "New Repo: Name of the sensor." 38 | }, 39 | "description": "Remove existing repos or add a new repo." 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /custom_components/github_custom/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "error": { 4 | "auth": "The auth token provided is not valid.", 5 | "invalid_path": "The path provided is not valid. Should be in the format `user/repo-name`." 6 | }, 7 | "step": { 8 | "user": { 9 | "data": { 10 | "access_token": "GitHub Access Token", 11 | "url": "GitHub Enterprise server URL" 12 | }, 13 | "description": "Enter your GitHub credentials.", 14 | "title": "Authentication" 15 | }, 16 | "repo": { 17 | "data": { 18 | "add_another": "Add another repo?", 19 | "path": "Path to the repository e.g. home-assistant/core", 20 | "name": "Name of the sensor." 21 | }, 22 | "description": "Add a GitHub repo, check the box to add another.", 23 | "title": "Add GitHub Repository" 24 | } 25 | } 26 | }, 27 | "options": { 28 | "error": { 29 | "invalid_path": "The path provided is not valid. Should be in the format `user/repo-name` and should be a valid github repository." 30 | }, 31 | "step": { 32 | "init": { 33 | "title": "Manage Repos", 34 | "data": { 35 | "repos": "Existing Repos: Uncheck any repos you want to remove.", 36 | "path": "New Repo: Path to the repository e.g. home-assistant/core", 37 | "name": "New Repo: Name of the sensor." 38 | }, 39 | "description": "Remove existing repos or add a new repo." 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Github Custom", 3 | "render_readme": true, 4 | "iot_class": "Cloud Polling" 5 | } 6 | -------------------------------------------------------------------------------- /requirements.test.txt: -------------------------------------------------------------------------------- 1 | # From our manifest.json for our custom component 2 | gidgethub[aiohttp]==5.2.1 3 | cryptography==40.0.1 4 | 5 | # Strictly for tests 6 | coverage==7.2.1 7 | pytest==7.2.2 8 | pytest-asyncio==0.20.3 9 | pytest-cov==3.0.0 10 | pytest-homeassistant-custom-component==0.13.20 11 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [coverage:run] 2 | source = 3 | custom_components 4 | 5 | [coverage:report] 6 | exclude_lines = 7 | pragma: no cover 8 | raise NotImplemented() 9 | if __name__ == '__main__': 10 | main() 11 | fail_under = 84 12 | show_missing = true 13 | 14 | [tool:pytest] 15 | testpaths = tests 16 | norecursedirs = .git 17 | addopts = 18 | --cov=custom_components 19 | 20 | [flake8] 21 | # https://github.com/ambv/black#line-length 22 | max-line-length = 88 23 | # E501: line too long 24 | # W503: Line break occurred before a binary operator 25 | # E203: Whitespace before ':' 26 | # D202 No blank lines allowed after function docstring 27 | # W504 line break after binary operator 28 | ignore = 29 | E501, 30 | W503, 31 | E203, 32 | D202, 33 | W504 34 | 35 | [isort] 36 | # https://github.com/timothycrosley/isort 37 | # https://github.com/timothycrosley/isort/wiki/isort-Settings 38 | # splits long import on multiple lines indented by 4 spaces 39 | multi_line_output = 3 40 | include_trailing_comma=True 41 | force_grid_wrap=0 42 | use_parentheses=True 43 | line_length=88 44 | indent = " " 45 | # will group `import x` and `from x import` of the same module. 46 | force_sort_within_sections = true 47 | sections = FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER 48 | default_section = THIRDPARTY 49 | known_first_party = custom_components,tests 50 | forced_separate = tests 51 | combine_as_imports = true 52 | 53 | [mypy] 54 | python_version = 3.7 55 | ignore_errors = true 56 | follow_imports = silent 57 | ignore_missing_imports = true 58 | warn_incomplete_stub = true 59 | warn_redundant_casts = true 60 | warn_unused_configs = true 61 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for the github-custom custom component.""" 2 | -------------------------------------------------------------------------------- /tests/bandit.yaml: -------------------------------------------------------------------------------- 1 | # https://bandit.readthedocs.io/en/latest/config.html 2 | 3 | tests: 4 | - B108 5 | - B306 6 | - B307 7 | - B313 8 | - B314 9 | - B315 10 | - B316 11 | - B317 12 | - B318 13 | - B319 14 | - B320 15 | - B325 16 | - B602 17 | - B604 18 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """pytest fixtures.""" 2 | import pytest 3 | 4 | 5 | @pytest.fixture(autouse=True) 6 | def auto_enable_custom_integrations(enable_custom_integrations): 7 | """Enable custom integrations defined in the test dir.""" 8 | yield 9 | -------------------------------------------------------------------------------- /tests/test_config_flow.py: -------------------------------------------------------------------------------- 1 | """Tests for the config flow.""" 2 | from unittest import mock 3 | from unittest.mock import AsyncMock, patch 4 | 5 | from gidgethub import BadRequest 6 | from homeassistant.const import CONF_ACCESS_TOKEN, CONF_NAME, CONF_PATH 7 | import pytest 8 | from pytest_homeassistant_custom_component.common import MockConfigEntry 9 | 10 | from custom_components.github_custom import config_flow 11 | from custom_components.github_custom.const import CONF_REPOS, DOMAIN 12 | 13 | 14 | @pytest.mark.asyncio 15 | @patch("custom_components.github_custom.config_flow.GitHubAPI") 16 | async def test_validate_path_valid(m_github, hass): 17 | """Test no exception is raised for a valid path.""" 18 | m_instance = AsyncMock() 19 | m_instance.getitem = AsyncMock() 20 | m_github.return_value = m_instance 21 | await config_flow.validate_path("home-assistant/core", "access-token", hass) 22 | 23 | 24 | @pytest.mark.asyncio 25 | async def test_validate_path_invalid(hass): 26 | """Test a ValueError is raised when the path is not valid.""" 27 | for bad_path in ("home-assistant", "home-assistant/core/foo"): 28 | with pytest.raises(ValueError): 29 | await config_flow.validate_path(bad_path, "access-token", hass) 30 | 31 | 32 | @pytest.mark.asyncio 33 | @patch("custom_components.github_custom.config_flow.GitHubAPI") 34 | async def test_validate_auth_valid(m_github, hass): 35 | """Test no exception is raised for valid auth.""" 36 | m_instance = AsyncMock() 37 | m_instance.getitem = AsyncMock() 38 | m_github.return_value = m_instance 39 | await config_flow.validate_auth("token", hass) 40 | 41 | 42 | @pytest.mark.asyncio 43 | @patch("custom_components.github_custom.config_flow.GitHubAPI") 44 | async def test_validate_auth_invalid(m_github, hass): 45 | """Test ValueError is raised when auth is invalid.""" 46 | m_instance = AsyncMock() 47 | m_instance.getitem = AsyncMock(side_effect=BadRequest(AsyncMock())) 48 | m_github.return_value = m_instance 49 | with pytest.raises(ValueError): 50 | await config_flow.validate_auth("token", hass) 51 | 52 | 53 | @pytest.mark.asyncio 54 | async def test_flow_user_init(hass): 55 | """Test the initialization of the form in the first step of the config flow.""" 56 | result = await hass.config_entries.flow.async_init( 57 | config_flow.DOMAIN, context={"source": "user"} 58 | ) 59 | expected = { 60 | "data_schema": config_flow.AUTH_SCHEMA, 61 | "description_placeholders": None, 62 | "errors": {}, 63 | "flow_id": mock.ANY, 64 | "handler": "github_custom", 65 | "last_step": None, 66 | "step_id": "user", 67 | "type": "form", 68 | } 69 | assert expected == result 70 | 71 | 72 | @pytest.mark.asyncio 73 | @patch("custom_components.github_custom.config_flow.validate_auth") 74 | async def test_flow_user_init_invalid_auth_token(m_validate_auth, hass): 75 | """Test errors populated when auth token is invalid.""" 76 | m_validate_auth.side_effect = ValueError 77 | _result = await hass.config_entries.flow.async_init( 78 | config_flow.DOMAIN, context={"source": "user"} 79 | ) 80 | result = await hass.config_entries.flow.async_configure( 81 | _result["flow_id"], user_input={CONF_ACCESS_TOKEN: "bad"} 82 | ) 83 | assert {"base": "auth"} == result["errors"] 84 | 85 | 86 | @pytest.mark.asyncio 87 | @patch("custom_components.github_custom.config_flow.validate_auth") 88 | async def test_flow_user_init_data_valid(m_validate_auth, hass): 89 | """Test we advance to the next step when data is valid.""" 90 | _result = await hass.config_entries.flow.async_init( 91 | config_flow.DOMAIN, context={"source": "user"} 92 | ) 93 | result = await hass.config_entries.flow.async_configure( 94 | _result["flow_id"], user_input={CONF_ACCESS_TOKEN: "bad"} 95 | ) 96 | assert "repo" == result["step_id"] 97 | assert "form" == result["type"] 98 | 99 | 100 | @pytest.mark.asyncio 101 | async def test_flow_repo_init_form(hass): 102 | """Test the initialization of the form in the second step of the config flow.""" 103 | result = await hass.config_entries.flow.async_init( 104 | config_flow.DOMAIN, context={"source": "repo"} 105 | ) 106 | expected = { 107 | "data_schema": config_flow.REPO_SCHEMA, 108 | "description_placeholders": None, 109 | "errors": {}, 110 | "flow_id": mock.ANY, 111 | "handler": "github_custom", 112 | "step_id": "repo", 113 | "last_step": None, 114 | "type": "form", 115 | } 116 | assert expected == result 117 | 118 | 119 | @pytest.mark.asyncio 120 | async def test_flow_repo_path_invalid(hass): 121 | """Test errors populated when path is invalid.""" 122 | config_flow.GithubCustomConfigFlow.data = { 123 | CONF_ACCESS_TOKEN: "token", 124 | } 125 | _result = await hass.config_entries.flow.async_init( 126 | config_flow.DOMAIN, context={"source": "repo"} 127 | ) 128 | result = await hass.config_entries.flow.async_configure( 129 | _result["flow_id"], user_input={CONF_NAME: "bad", CONF_PATH: "bad"} 130 | ) 131 | assert {"base": "invalid_path"} == result["errors"] 132 | 133 | 134 | @pytest.mark.asyncio 135 | @patch("custom_components.github_custom.config_flow.GitHubAPI") 136 | async def test_flow_repo_add_another(github, hass): 137 | """Test we show the repo flow again if the add_another box was checked.""" 138 | instance = AsyncMock() 139 | instance.getitem = AsyncMock() 140 | github.return_value = instance 141 | 142 | config_flow.GithubCustomConfigFlow.data = { 143 | CONF_ACCESS_TOKEN: "token", 144 | CONF_REPOS: [], 145 | } 146 | _result = await hass.config_entries.flow.async_init( 147 | config_flow.DOMAIN, context={"source": "repo"} 148 | ) 149 | result = await hass.config_entries.flow.async_configure( 150 | _result["flow_id"], 151 | user_input={CONF_PATH: "home-assistant/core", "add_another": True}, 152 | ) 153 | assert "repo" == result["step_id"] 154 | assert "form" == result["type"] 155 | 156 | 157 | @pytest.mark.asyncio 158 | @patch("custom_components.github_custom.config_flow.GitHubAPI") 159 | async def test_flow_repo_creates_config_entry(m_github, hass): 160 | """Test the config entry is successfully created.""" 161 | m_instance = AsyncMock() 162 | m_instance.getitem = AsyncMock() 163 | m_github.return_value = m_instance 164 | config_flow.GithubCustomConfigFlow.data = { 165 | CONF_ACCESS_TOKEN: "token", 166 | CONF_REPOS: [], 167 | } 168 | with patch("custom_components.github_custom.async_setup_entry", return_value=True): 169 | _result = await hass.config_entries.flow.async_init( 170 | config_flow.DOMAIN, context={"source": "repo"} 171 | ) 172 | await hass.async_block_till_done() 173 | 174 | result = await hass.config_entries.flow.async_configure( 175 | _result["flow_id"], 176 | user_input={CONF_PATH: "home-assistant/core"}, 177 | ) 178 | expected = { 179 | "context": {"source": "repo"}, 180 | "version": 1, 181 | "type": "create_entry", 182 | "flow_id": mock.ANY, 183 | "handler": "github_custom", 184 | "title": "GitHub Custom", 185 | "data": { 186 | "access_token": "token", 187 | "repositories": [ 188 | {"path": "home-assistant/core", "name": "home-assistant/core"} 189 | ], 190 | }, 191 | "description": None, 192 | "description_placeholders": None, 193 | "options": {}, 194 | "result": mock.ANY, 195 | } 196 | assert expected == result 197 | 198 | 199 | @pytest.mark.asyncio 200 | @patch("custom_components.github_custom.sensor.GitHubAPI") 201 | async def test_options_flow_init(m_github, hass): 202 | """Test config flow options.""" 203 | m_instance = AsyncMock() 204 | m_instance.getitem = AsyncMock() 205 | m_github.return_value = m_instance 206 | 207 | config_entry = MockConfigEntry( 208 | domain=DOMAIN, 209 | unique_id="kodi_recently_added_media", 210 | data={ 211 | CONF_ACCESS_TOKEN: "access-token", 212 | CONF_REPOS: [{"path": "home-assistant/core", "name": "HA Core"}], 213 | }, 214 | ) 215 | config_entry.add_to_hass(hass) 216 | assert await hass.config_entries.async_setup(config_entry.entry_id) 217 | await hass.async_block_till_done() 218 | 219 | # show initial form 220 | result = await hass.config_entries.options.async_init(config_entry.entry_id) 221 | assert "form" == result["type"] 222 | assert "init" == result["step_id"] 223 | assert {} == result["errors"] 224 | # Verify multi-select options populated with configured repos. 225 | assert {"sensor.ha_core": "HA Core"} == result["data_schema"].schema[ 226 | "repos" 227 | ].options 228 | -------------------------------------------------------------------------------- /tests/test_sensor.py: -------------------------------------------------------------------------------- 1 | """Tests for the sensor module.""" 2 | from unittest.mock import AsyncMock, MagicMock 3 | 4 | from gidgethub import GitHubException 5 | import pytest 6 | 7 | from custom_components.github_custom.sensor import GitHubRepoSensor 8 | 9 | 10 | @pytest.mark.asyncio 11 | async def test_async_update_success(hass, aioclient_mock): 12 | """Tests a fully successful async_update.""" 13 | github = MagicMock() 14 | github.getitem = AsyncMock( 15 | side_effect=[ 16 | # repos response 17 | { 18 | "forks_count": 1000, 19 | "name": "Home Assistant", 20 | "permissions": {"admin": False, "push": True, "pull": False}, 21 | "stargazers_count": 9000, 22 | "open_issues_count": 5000, 23 | }, 24 | # clones response 25 | {"count": 100, "uniques": 50}, 26 | # views response 27 | {"count": 10000, "uniques": 5000}, 28 | # commits response 29 | [ 30 | { 31 | "commit": {"message": "Did a thing."}, 32 | "sha": "e751664d95917dbdb856c382bfe2f4655e2a83c1", 33 | } 34 | ], 35 | # pulls response 36 | { 37 | "incomplete_results": False, 38 | "total_count": 345, 39 | "items": [ 40 | {"html_url": "https://github.com/homeassistant/core/pull/1347"} 41 | ], 42 | }, 43 | # issues response 44 | [{"html_url": "https://github.com/homeassistant/core/issues/1"}], 45 | # releases response 46 | [{"html_url": "https://github.com/homeassistant/core/releases/v0.1.112"}], 47 | ] 48 | ) 49 | sensor = GitHubRepoSensor(github, {"path": "homeassistant/core"}) 50 | await sensor.async_update() 51 | 52 | expected = { 53 | "clones": 100, 54 | "clones_unique": 50, 55 | "forks": 1000, 56 | "latest_commit_message": "Did a thing.", 57 | "latest_commit_sha": "e751664d95917dbdb856c382bfe2f4655e2a83c1", 58 | "latest_open_issue_url": "https://github.com/homeassistant/core/issues/1", 59 | "latest_open_pull_request_url": "https://github.com/homeassistant/core/pull/1347", 60 | "latest_release_tag": "v0.1.112", 61 | "latest_release_url": "https://github.com/homeassistant/core/releases/v0.1.112", 62 | "name": "Home Assistant", 63 | "open_issues": 4655, 64 | "open_pull_requests": 345, 65 | "path": "homeassistant/core", 66 | "stargazers": 9000, 67 | "views": 10000, 68 | "views_unique": 5000, 69 | } 70 | assert expected == sensor.attrs 71 | assert expected == sensor.extra_state_attributes 72 | assert sensor.available is True 73 | 74 | 75 | @pytest.mark.asyncio 76 | async def test_async_update_failed(): 77 | """Tests a failed async_update.""" 78 | github = MagicMock() 79 | github.getitem = AsyncMock(side_effect=GitHubException) 80 | 81 | sensor = GitHubRepoSensor(github, {"path": "homeassistant/core"}) 82 | await sensor.async_update() 83 | 84 | assert sensor.available is False 85 | assert {"path": "homeassistant/core"} == sensor.attrs 86 | --------------------------------------------------------------------------------