├── .docs-requirements.txt ├── octomachinery ├── app │ ├── runtime │ │ ├── __init__.py │ │ ├── context.py │ │ ├── config.py │ │ ├── utils.py │ │ └── installation_utils.py │ ├── __init__.py │ ├── routing │ │ ├── abc.py │ │ ├── decorators.py │ │ ├── routers.py │ │ ├── __init__.py │ │ └── webhooks_dispatcher.py │ ├── server │ │ ├── config.py │ │ ├── runner.py │ │ └── machinery.py │ ├── action │ │ ├── config.py │ │ └── runner.py │ └── config.py ├── __init__.py ├── routing │ ├── __init__.py │ ├── decorators.py │ ├── default_router.py │ ├── abc.py │ ├── routers.py │ └── webhooks_dispatcher.py ├── github │ ├── errors │ │ └── __init__.py │ ├── models │ │ ├── _compat.py │ │ ├── utils.py │ │ ├── action_outcomes.py │ │ ├── __init__.py │ │ ├── private_key.py │ │ ├── checks_api_requests.py │ │ └── events.py │ ├── api │ │ ├── tokens.py │ │ ├── utils.py │ │ ├── raw_client.py │ │ └── app_client.py │ ├── entities │ │ ├── action.py │ │ └── app_installation.py │ ├── config │ │ └── app.py │ └── utils │ │ └── event_utils.py ├── runtime │ ├── context.py │ └── utils.py ├── utils │ ├── versiontools.py │ └── asynctools.py └── cli │ └── __main__.py ├── .github ├── deadpendency.yaml └── FUNDING.yml ├── .git_archival.txt ├── pyproject.toml ├── docs ├── _templates │ ├── project-description.html │ └── github-sponsors.html ├── index.rst ├── getting-started.rst ├── _static │ └── custom.css ├── howto-guides.rst └── conf.py ├── .gitattributes ├── .yamllint ├── mypy.ini ├── .readthedocs.yml ├── .isort.cfg ├── tests ├── utils │ ├── versiontools_test.py │ └── asynctools_test.py ├── conftest.py ├── app │ ├── routing │ │ └── decorators_test.py │ ├── action │ │ └── runner_test.py │ └── server │ │ └── machinery_test.py ├── github │ ├── models │ │ ├── private_key_test.py │ │ └── utils_test.py │ └── utils │ │ └── event_utils_test.py └── circular_imports_test.py ├── .gitignore ├── pytest.ini ├── setup.cfg ├── .git-blame-ignore-revs ├── README.rst ├── .flake8 ├── .pre-commit-config.yaml └── tox.ini /.docs-requirements.txt: -------------------------------------------------------------------------------- 1 | pip >= 19.0.3 2 | setuptools >= 40.9.0 3 | -------------------------------------------------------------------------------- /octomachinery/app/runtime/__init__.py: -------------------------------------------------------------------------------- 1 | """GitHub App runtime management.""" 2 | -------------------------------------------------------------------------------- /octomachinery/__init__.py: -------------------------------------------------------------------------------- 1 | """Octomachinery framework for developing GitHub Apps and Actions.""" 2 | -------------------------------------------------------------------------------- /.github/deadpendency.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | ignore-failures: 3 | python: 4 | - setuptools_scm_git_archive 5 | ... 6 | -------------------------------------------------------------------------------- /octomachinery/app/__init__.py: -------------------------------------------------------------------------------- 1 | """GitHub App infra.""" 2 | 3 | from .server.runner import run # noqa: F401 4 | -------------------------------------------------------------------------------- /.git_archival.txt: -------------------------------------------------------------------------------- 1 | node: 09b5e5ca2f7eb1f7d7b6f974392456a25411a11a 2 | node-date: 2024-08-26T12:05:57+02:00 3 | describe-name: v0.3.11-13-g09b5e5c 4 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools >= 68", 4 | "setuptools_scm [toml] >= 7.1.0", 5 | ] 6 | build-backend = "setuptools.build_meta" 7 | 8 | [tool.setuptools_scm] 9 | -------------------------------------------------------------------------------- /octomachinery/app/routing/abc.py: -------------------------------------------------------------------------------- 1 | """Octomachinery router base interface definitions proxy.""" 2 | 3 | # pylint: disable=unused-import 4 | from ...routing.abc import OctomachineryRouterBase # noqa: F401 5 | -------------------------------------------------------------------------------- /octomachinery/app/runtime/context.py: -------------------------------------------------------------------------------- 1 | """The globally accessible context vars mapping proxy.""" 2 | 3 | # pylint: disable=unused-import 4 | from ...runtime.context import RUNTIME_CONTEXT # noqa: F401 5 | -------------------------------------------------------------------------------- /octomachinery/app/routing/decorators.py: -------------------------------------------------------------------------------- 1 | """Webhook event processing helper decorators proxy.""" 2 | 3 | # pylint: disable=unused-import 4 | from ...routing.decorators import process_webhook_payload # noqa: F401 5 | -------------------------------------------------------------------------------- /docs/_templates/project-description.html: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /octomachinery/routing/__init__.py: -------------------------------------------------------------------------------- 1 | """GitHub webhooks routing.""" 2 | 3 | 4 | # pylint: disable=unused-import 5 | from .default_router import ( # noqa: F401 6 | WEBHOOK_EVENTS_ROUTER, dispatch_event, process_event, 7 | process_event_actions, 8 | ) 9 | -------------------------------------------------------------------------------- /octomachinery/app/routing/routers.py: -------------------------------------------------------------------------------- 1 | """Octomachinery event dispatchers collection proxy.""" 2 | 3 | # pylint: disable=unused-import 4 | from ...routing.routers import ( # noqa: F401 5 | ConcurrentRouter, GidgetHubRouterBase, NonBlockingConcurrentRouter, 6 | ) 7 | -------------------------------------------------------------------------------- /octomachinery/app/routing/__init__.py: -------------------------------------------------------------------------------- 1 | """GitHub webhooks routing proxy.""" 2 | 3 | # pylint: disable=unused-import 4 | from ...routing.default_router import ( # noqa: F401 5 | WEBHOOK_EVENTS_ROUTER, dispatch_event, process_event, 6 | process_event_actions, 7 | ) 8 | -------------------------------------------------------------------------------- /docs/_templates/github-sponsors.html: -------------------------------------------------------------------------------- 1 | {# TODO: Reimplement this w/o an iframe and make it dark mode ready #} 2 | 9 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Force LF line endings for text files 2 | * text=auto eol=lf 3 | 4 | # Needed for setuptools-scm-git-archive 5 | .git_archival.txt export-subst 6 | 7 | # Blame ignore list entries are expected to always be appended, never edited 8 | .git-blame-ignore-revs merge=union 9 | -------------------------------------------------------------------------------- /.yamllint: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | extends: default 4 | 5 | rules: 6 | indentation: 7 | level: error 8 | indent-sequences: false 9 | truthy: 10 | allowed-values: 11 | - >- 12 | false 13 | - >- 14 | true 15 | - >- # Allow "on" key name in GHA CI/CD workflow definitions 16 | on 17 | 18 | ... 19 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | ignore_missing_imports = True 3 | 4 | check_untyped_defs = True 5 | 6 | disallow_any_generics = True 7 | # disallow_untyped_defs = True 8 | 9 | enable_error_code = 10 | ignore-without-code 11 | 12 | follow_imports = normal 13 | 14 | strict_optional = True 15 | 16 | warn_redundant_casts = True 17 | warn_unused_ignores = True 18 | -------------------------------------------------------------------------------- /octomachinery/app/server/config.py: -------------------------------------------------------------------------------- 1 | """The web-server configuration.""" 2 | 3 | import environ 4 | 5 | 6 | @environ.config 7 | class WebServerConfig: # pylint: disable=too-few-public-methods 8 | """Config of a web-server.""" 9 | 10 | host = environ.var('0.0.0.0', name='HOST') 11 | port = environ.var(8080, name='PORT', converter=int) 12 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | version: 2 4 | 5 | formats: all 6 | 7 | build: 8 | os: "ubuntu-22.04" 9 | tools: 10 | python: "3.7" 11 | 12 | python: 13 | install: 14 | # - requirements: .docs-requirements.txt 15 | # - method: pip 16 | # path: pip >= 19.0.3 17 | - method: pip 18 | path: . 19 | extra_requirements: 20 | - docs 21 | 22 | ... 23 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | custom: 4 | - https://www.comebackalive.in.ua/donate 5 | - https://github.com/vshymanskyy/StandWithUkraine#for-maintainers-and-authors 6 | - https://www.paypal.me/webknjazCZ 7 | - https://webknjaz.me 8 | 9 | github: 10 | - webknjaz 11 | 12 | ko_fi: webknjaz 13 | 14 | liberapay: webknjaz 15 | 16 | tidelift: pypi/octomachinery 17 | 18 | ... 19 | -------------------------------------------------------------------------------- /octomachinery/github/errors/__init__.py: -------------------------------------------------------------------------------- 1 | """Exceptions collection related to interactions with GitHub.""" 2 | 3 | from gidgethub import GitHubException 4 | 5 | import attr 6 | 7 | # pylint: disable=relative-beyond-top-level 8 | from ..models.action_outcomes import ActionOutcome 9 | 10 | 11 | class GitHubError(GitHubException): 12 | """Generic GitHub-related error.""" 13 | 14 | 15 | @attr.dataclass 16 | class GitHubActionError(GitHubError): 17 | """Generic GitHub-related error.""" 18 | 19 | _outcome: ActionOutcome 20 | 21 | def terminate_action(self): 22 | """Terminate current process using corresponding return code.""" 23 | self._outcome.raise_it() 24 | -------------------------------------------------------------------------------- /octomachinery/routing/decorators.py: -------------------------------------------------------------------------------- 1 | """Webhook event processing helper decorators.""" 2 | 3 | from __future__ import annotations 4 | 5 | from functools import wraps 6 | from typing import TYPE_CHECKING, Any 7 | 8 | 9 | if TYPE_CHECKING: 10 | # pylint: disable=relative-beyond-top-level 11 | from ..github.models.events import GitHubEvent 12 | 13 | 14 | __all__ = ('process_webhook_payload',) 15 | 16 | 17 | def process_webhook_payload(wrapped_function): 18 | """Bypass event object keys-values as args to the handler.""" 19 | @wraps(wrapped_function) 20 | def wrapper(event: GitHubEvent) -> Any: 21 | return wrapped_function(**event.payload) 22 | return wrapper 23 | -------------------------------------------------------------------------------- /octomachinery/runtime/context.py: -------------------------------------------------------------------------------- 1 | """The globally accessible context vars mapping. 2 | 3 | It is supposed to be used as follows: 4 | 5 | >>> from octomachinery.runtime.context import RUNTIME_CONTEXT 6 | 7 | Or shorter: 8 | >>> from octomachinery import RUNTIME_CONTEXT 9 | """ 10 | # pylint: disable=relative-beyond-top-level 11 | from .utils import _ContextMap 12 | 13 | 14 | RUNTIME_CONTEXT = _ContextMap( 15 | app_installation='app installation', 16 | app_installation_client='app installation client', 17 | config='config context', 18 | github_app='github app', 19 | github_event='GitHub Event', 20 | IS_GITHUB_ACTION='Is GitHub Action', 21 | IS_GITHUB_APP='Is GitHub App', 22 | ) 23 | -------------------------------------------------------------------------------- /octomachinery/app/runtime/config.py: -------------------------------------------------------------------------------- 1 | """The application runtime configuration.""" 2 | import attr 3 | import environ 4 | 5 | from .utils import detect_env_mode 6 | 7 | 8 | @environ.config 9 | class RuntimeConfig: # pylint: disable=too-few-public-methods 10 | """Config of runtime env.""" 11 | 12 | debug = environ.bool_var(False, name='DEBUG') 13 | env = environ.var( 14 | 'prod', name='ENV', 15 | validator=attr.validators.in_(('dev', 'prod')), 16 | ) 17 | mode = environ.var( 18 | 'auto', name='OCTOMACHINERY_APP_MODE', 19 | converter=lambda val: detect_env_mode() if val == 'auto' else val, 20 | validator=attr.validators.in_(('app', 'action')), 21 | ) 22 | -------------------------------------------------------------------------------- /octomachinery/github/models/_compat.py: -------------------------------------------------------------------------------- 1 | """Compatibility shims for the models subpackage.""" 2 | from functools import wraps as _wraps_function 3 | 4 | from jwt import encode as _compute_jwt 5 | 6 | 7 | try: 8 | from jwt import __version__ as _pyjwt_version_str 9 | except ImportError: 10 | _pyjwt_version_str = '0.0.0' 11 | 12 | 13 | _pyjwt_version_info = tuple(map(int, _pyjwt_version_str.split('.'))) 14 | _is_pyjwt_above_v2_0 = _pyjwt_version_info >= (2, 0, 0) 15 | 16 | 17 | @_wraps_function(_compute_jwt) 18 | def _compute_jwt_below_v2_0(*args, **kwargs) -> str: 19 | return _compute_jwt(*args, **kwargs).decode('utf-8') 20 | 21 | 22 | compute_jwt = _compute_jwt if _is_pyjwt_above_v2_0 else _compute_jwt_below_v2_0 23 | -------------------------------------------------------------------------------- /octomachinery/utils/versiontools.py: -------------------------------------------------------------------------------- 1 | """Version tools set.""" 2 | 3 | from typing import Callable, Optional, Union 4 | 5 | from setuptools_scm import get_version 6 | from setuptools_scm.version import ScmVersion 7 | 8 | 9 | def get_version_from_scm_tag( 10 | *, 11 | root: str = '.', 12 | relative_to: Optional[str] = None, 13 | local_scheme: Union[ 14 | Callable[[ScmVersion], str], str, 15 | ] = 'node-and-date', 16 | ) -> str: 17 | """Retrieve the version from SCM tag in Git or Hg.""" 18 | try: 19 | return get_version( 20 | root=root, 21 | relative_to=relative_to, 22 | local_scheme=local_scheme, 23 | ) 24 | except LookupError: 25 | return 'unknown' 26 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | # https://github.com/timothycrosley/isort/wiki/isort-Settings 2 | [settings] 3 | default_section = THIRDPARTY 4 | # force_to_top = file1.py,file2.py 5 | # forced_separate = django.contrib,django.utils 6 | include_trailing_comma = true 7 | indent = 4 8 | known_first_party = octomachinery 9 | # known_future_library = future,pies 10 | # known_standard_library = std,std2 11 | known_testing = pytest, unittest 12 | known_framework = aiohttp, anyio, gidgethub, multidict, yarl 13 | # known_third_party = Cython 14 | # length_sort = 1 15 | # Should be: 80 - 1 16 | line_length = 79 17 | lines_after_imports = 2 18 | # https://github.com/timothycrosley/isort#multi-line-output-modes 19 | multi_line_output = 5 20 | no_lines_before = LOCALFOLDER 21 | sections = FUTURE, STDLIB, FRAMEWORK, TESTING, THIRDPARTY, FIRSTPARTY, LOCALFOLDER 22 | # skip=file3.py,file4.py 23 | use_parentheses = true 24 | verbose = true 25 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. octomachinery documentation master file, created by 2 | sphinx-quickstart on Tue Mar 12 14:26:59 2019. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | |project|: the octobots engine 7 | ============================== 8 | 9 | .. include:: ../README.rst 10 | :end-before: DO-NOT-REMOVE-docs-badges-END 11 | 12 | .. include:: ../README.rst 13 | :start-after: DO-NOT-REMOVE-docs-intro-START 14 | 15 | .. toctree:: 16 | :maxdepth: 2 17 | :caption: Contents: 18 | 19 | getting-started 20 | howto-guides 21 | 22 | .. toctree:: 23 | :caption: Extras: 24 | 25 | Create a GitHub bot 🤖 26 | octomachinery [www] 🤖 27 | 28 | 29 | 30 | Indices and tables 31 | ================== 32 | 33 | * :ref:`genindex` 34 | * :ref:`modindex` 35 | * :ref:`search` 36 | -------------------------------------------------------------------------------- /octomachinery/app/runtime/utils.py: -------------------------------------------------------------------------------- 1 | """GitHub App runtime context helpers.""" 2 | 3 | import logging 4 | import os 5 | 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | def detect_env_mode(): 11 | """Figure out whether we're under GitHub Action environment.""" 12 | for var_suffix in ( 13 | 'WORKFLOW', 14 | 'ACTION', 'ACTOR', 15 | 'REPOSITORY', 16 | 'EVENT_NAME', 'EVENT_PATH', 17 | 'WORKSPACE', 18 | 'SHA', 'REF', 19 | 'TOKEN', 20 | ): 21 | if f'GITHUB_{var_suffix}' not in os.environ: 22 | logger.info( 23 | 'Detected GitHub App mode since ' 24 | 'GITHUB_%s is missing from the env', 25 | var_suffix, 26 | ) 27 | return 'app' 28 | logger.info( 29 | 'Detected GitHub Action mode since all the ' 30 | 'typical env vars are present in the env', 31 | ) 32 | return 'action' 33 | -------------------------------------------------------------------------------- /tests/utils/versiontools_test.py: -------------------------------------------------------------------------------- 1 | """Test suite for version utility helper functions.""" 2 | 3 | import contextlib 4 | import os 5 | import pathlib 6 | import tempfile 7 | 8 | import pytest 9 | 10 | from octomachinery.utils.versiontools import get_version_from_scm_tag 11 | 12 | 13 | @contextlib.contextmanager 14 | def working_directory(path): 15 | """Change working directory and return back to previous on exit.""" 16 | prev_cwd = pathlib.Path.cwd() 17 | os.chdir(path) 18 | try: 19 | yield path 20 | finally: 21 | os.chdir(prev_cwd) 22 | 23 | 24 | @pytest.fixture 25 | def temporary_working_directory(): 26 | """Create a temporary working directory, cd there and back on exit.""" 27 | with tempfile.TemporaryDirectory() as tmp_git_repo_dir: 28 | with working_directory(tmp_git_repo_dir) as path: 29 | yield path 30 | 31 | 32 | def test_get_version_from_scm_tag_outside_git_repo( 33 | temporary_working_directory, 34 | ): 35 | """Check that version is unknown outside of Git repo.""" 36 | assert get_version_from_scm_tag() == 'unknown' 37 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Shared fixtures for tests.""" 2 | import pytest 3 | 4 | from cryptography.hazmat.backends import default_backend 5 | from cryptography.hazmat.primitives.asymmetric.rsa import generate_private_key 6 | from cryptography.hazmat.primitives.serialization import ( 7 | Encoding, NoEncryption, PrivateFormat, 8 | ) 9 | 10 | 11 | @pytest.fixture 12 | def rsa_private_key(): 13 | """Generate an RSA private key.""" 14 | return generate_private_key( 15 | public_exponent=65537, 16 | key_size=4096, 17 | backend=default_backend(), 18 | ) 19 | 20 | 21 | @pytest.fixture 22 | def rsa_private_key_bytes(rsa_private_key) -> bytes: 23 | r"""Generate an unencrypted PKCS#1 formatted RSA private key. 24 | 25 | Encoded as PEM-bytes. 26 | 27 | This is what the GitHub-downloaded PEM files contain. 28 | 29 | Ref: https://developer.github.com/apps/building-github-apps/\ 30 | authenticating-with-github-apps/ 31 | """ 32 | return rsa_private_key.private_bytes( 33 | encoding=Encoding.PEM, 34 | format=PrivateFormat.TraditionalOpenSSL, # A.K.A. PKCS#1 35 | encryption_algorithm=NoEncryption(), 36 | ) 37 | -------------------------------------------------------------------------------- /docs/getting-started.rst: -------------------------------------------------------------------------------- 1 | Getting started 2 | =============== 3 | 4 | The documentation isn't ready yet so I suggest you going through the 5 | :doc:`How-to create a GitHub Bot tutorial ` 6 | which should give you basic understanding of GitHub Apps and how to 7 | write them with octomachinery. 8 | 9 | Runtime pre-requisites 10 | ---------------------- 11 | 12 | * Python 3.7+ as octomachinery relies on :py:mod:`contextvars` which 13 | doesn't have a backport. 14 | * GitHub App credentials and GitHub Action events are supplied via 15 | environment variables. They are also loaded from a ``.env`` file if it 16 | exists in a development environment. 17 | 18 | .. warning:: 19 | 20 | Be aware that some tools in your environment may conflict with 21 | autoloading vars from a ``.env`` file. It is recommended to disable 22 | those. One example of such tool is `Pipenv`_. 23 | 24 | .. _`Pipenv`: 25 | https://pipenv.readthedocs.io/en/latest/advanced/ 26 | #automatic-loading-of-env 27 | 28 | For the production deployments, please use a way of supplying env vars 29 | via tools provided by the application orchestration software of your 30 | choice. 31 | -------------------------------------------------------------------------------- /octomachinery/routing/default_router.py: -------------------------------------------------------------------------------- 1 | """Default GitHub event dispatcher.""" 2 | 3 | from functools import wraps 4 | 5 | from .routers import ConcurrentRouter 6 | 7 | 8 | __all__ = ( 9 | 'dispatch_event', 10 | 'process_event', 11 | 'process_event_actions', 12 | 'WEBHOOK_EVENTS_ROUTER', 13 | ) 14 | 15 | 16 | WEBHOOK_EVENTS_ROUTER = ConcurrentRouter() 17 | """An event dispatcher for webhooks.""" 18 | 19 | 20 | dispatch_event = WEBHOOK_EVENTS_ROUTER.dispatch # pylint: disable=invalid-name 21 | process_event = WEBHOOK_EVENTS_ROUTER.register # pylint: disable=invalid-name 22 | 23 | 24 | def process_event_actions(event_name, actions=None): 25 | """Subscribe to multiple events.""" 26 | if actions is None: 27 | actions = [] 28 | 29 | def decorator(original_function): 30 | 31 | def wrapper(*args, **kwargs): 32 | return original_function(*args, **kwargs) 33 | 34 | if not actions: 35 | wrapper = process_event(event_name)(wrapper) 36 | 37 | for action in actions: 38 | wrapper = process_event(event_name, action=action)(wrapper) 39 | 40 | return wraps(original_function)(wrapper) 41 | 42 | return decorator 43 | -------------------------------------------------------------------------------- /octomachinery/routing/abc.py: -------------------------------------------------------------------------------- 1 | """Octomachinery router base interface definitions.""" 2 | 3 | from abc import ABCMeta, abstractmethod 4 | from typing import TYPE_CHECKING, Any, Iterator 5 | 6 | from gidgethub.routing import AsyncCallback 7 | 8 | 9 | if TYPE_CHECKING: 10 | from ..github.models.events import GitHubEvent 11 | 12 | 13 | __all__ = ('OctomachineryRouterBase',) 14 | 15 | 16 | class OctomachineryRouterBase(metaclass=ABCMeta): 17 | """Octomachinery router base.""" 18 | 19 | # pylint: disable=unused-argument 20 | @abstractmethod 21 | def emit_routes_for( 22 | self, event_name: str, event_payload: Any, 23 | ) -> Iterator[AsyncCallback]: 24 | """Emit callbacks that match given event and payload. 25 | 26 | :param str event_name: name of the GitHub event 27 | :param str event_payload: details of the GitHub event 28 | 29 | :yields: coroutine event handlers 30 | """ 31 | 32 | # pylint: disable=unused-argument 33 | @abstractmethod 34 | async def dispatch( 35 | self, event: 'GitHubEvent', 36 | *args: Any, **kwargs: Any, 37 | ) -> None: 38 | """Invoke coroutine handler tasks for the given event.""" 39 | -------------------------------------------------------------------------------- /octomachinery/app/action/config.py: -------------------------------------------------------------------------------- 1 | """GitHub Action environment and metadata representation.""" 2 | 3 | from pathlib import Path 4 | 5 | import environ 6 | 7 | # pylint: disable=relative-beyond-top-level 8 | from ...github.models.utils import SecretStr 9 | 10 | 11 | @environ.config 12 | class GitHubActionConfig: # pylint: disable=too-few-public-methods 13 | """GitHub Action config.""" 14 | 15 | workflow = environ.var( 16 | None, name='GITHUB_WORKFLOW', 17 | ) 18 | action = environ.var( 19 | None, name='GITHUB_ACTION', 20 | ) 21 | actor = environ.var( 22 | None, name='GITHUB_ACTOR', 23 | ) 24 | repository = environ.var( 25 | None, name='GITHUB_REPOSITORY', 26 | ) 27 | event_name = environ.var( 28 | None, name='GITHUB_EVENT_NAME', 29 | ) 30 | event_path = environ.var( 31 | None, converter=lambda p: p if p is None else Path(p), 32 | name='GITHUB_EVENT_PATH', 33 | ) 34 | workspace = environ.var( 35 | None, name='GITHUB_WORKSPACE', 36 | ) 37 | sha = environ.var( 38 | None, name='GITHUB_SHA', 39 | ) 40 | ref = environ.var( 41 | None, name='GITHUB_REF', 42 | ) 43 | token = environ.var( 44 | None, name='GITHUB_TOKEN', 45 | converter=lambda t: t if t is None else SecretStr(t), 46 | ) 47 | -------------------------------------------------------------------------------- /octomachinery/github/api/tokens.py: -------------------------------------------------------------------------------- 1 | """GitHub token types definitions.""" 2 | 3 | import attr 4 | 5 | # pylint: disable=relative-beyond-top-level 6 | from ..models.utils import SecretStr 7 | 8 | 9 | @attr.dataclass 10 | class GitHubToken: # pylint: disable=too-few-public-methods 11 | """Base class for GitHub tokens.""" 12 | 13 | _token_value: SecretStr = attr.ib( 14 | lambda s: SecretStr(s) if s is not None else s, 15 | ) 16 | 17 | def __str__(self): 18 | """Render the token as its string value.""" 19 | return str(self._token_value) 20 | 21 | 22 | @attr.dataclass 23 | # pylint: disable=too-few-public-methods 24 | class GitHubOAuthToken(GitHubToken): 25 | r"""GitHub OAuth Token. 26 | 27 | It can represent either App Installation token or a personal one. 28 | 29 | Ref: https://developer.github.com\ 30 | /apps/building-github-apps/authenticating-with-github-apps\ 31 | /#authenticating-as-an-installation 32 | """ 33 | 34 | 35 | @attr.dataclass 36 | # pylint: disable=too-few-public-methods 37 | class GitHubJWTToken(GitHubToken): 38 | r"""GitHub JSON Web Token. 39 | 40 | It represents GitHub App token. 41 | 42 | Ref: https://developer.github.com\ 43 | /apps/building-github-apps\ 44 | /authenticating-with-github-apps/#authenticating-as-a-github-app 45 | """ 46 | -------------------------------------------------------------------------------- /tests/app/routing/decorators_test.py: -------------------------------------------------------------------------------- 1 | """Test event routing decorator helpers.""" 2 | 3 | import pytest 4 | 5 | from octomachinery.app.routing.decorators import process_webhook_payload 6 | from octomachinery.github.models.events import GitHubEvent 7 | 8 | 9 | @process_webhook_payload 10 | def fake_event_handler(*, arg1, arg2): 11 | """Process fake test event.""" 12 | return arg1, arg2 13 | 14 | 15 | @pytest.mark.parametrize( 16 | 'incoming_event,is_successful', 17 | ( 18 | ({'arg1': 'y', 'arg2': 'x'}, True), 19 | ({'arg0': 'p', 'arg1': 'u', 'arg2': 'n'}, False), 20 | ({'arg1': 'z'}, False), 21 | ({'arg3': 's'}, False), 22 | ({}, False), 23 | ), 24 | ) 25 | def test_process_webhook_payload(incoming_event, is_successful): 26 | """Test that @process_webhook_payload unpacks event into kw-args.""" 27 | event = GitHubEvent( 28 | name=None, # type: ignore[arg-type] 29 | payload=incoming_event, 30 | ) 31 | 32 | if is_successful: 33 | assert ( 34 | # pylint: disable=missing-kwoa,too-many-function-args 35 | fake_event_handler(event) 36 | == tuple(incoming_event.values()) 37 | ) 38 | else: 39 | with pytest.raises(TypeError): 40 | # pylint: disable=missing-kwoa,too-many-function-args 41 | fake_event_handler(event) 42 | -------------------------------------------------------------------------------- /docs/_static/custom.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: Georgia, serif, "apple color emoji", "segoe ui emoji", "android emoji", "emojisymbols", "emojione mozilla", "twemoji mozilla", "segoe ui symbol", "noto color emoji"; 3 | } 4 | 5 | div.sphinxsidebar h3, 6 | div.sphinxsidebar h4 { 7 | font-family: Georgia, serif, "apple color emoji", "segoe ui emoji", "android emoji", "emojisymbols", "emojione mozilla", "twemoji mozilla", "segoe ui symbol", "noto color emoji"; 8 | } 9 | 10 | div.sphinxsidebar input { 11 | font-family: Georgia, serif, "apple color emoji", "segoe ui emoji", "android emoji", "emojisymbols", "emojione mozilla", "twemoji mozilla", "segoe ui symbol", "noto color emoji"; 12 | } 13 | 14 | 15 | div.body h1, 16 | div.body h2, 17 | div.body h3, 18 | div.body h4, 19 | div.body h5, 20 | div.body h6 { 21 | font-family: Georgia, serif, "apple color emoji", "segoe ui emoji", "android emoji", "emojisymbols", "emojione mozilla", "twemoji mozilla", "segoe ui symbol", "noto color emoji"; 22 | } 23 | 24 | div.admonition p.admonition-title { 25 | font-family: Georgia, serif, "apple color emoji", "segoe ui emoji", "android emoji", "emojisymbols", "emojione mozilla", "twemoji mozilla", "segoe ui symbol", "noto color emoji"; 26 | } 27 | 28 | pre, tt, code { 29 | font-family: 'Consolas', 'Menlo', 'DejaVu Sans Mono', 'Bitstream Vera Sans Mono', monospace, "apple color emoji", "segoe ui emoji", "android emoji", "emojisymbols", "emojione mozilla", "twemoji mozilla", "segoe ui symbol", "noto color emoji"; 30 | } 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # sphinxcontrib.apidoc autogenerated module references 107 | docs/reference/ 108 | -------------------------------------------------------------------------------- /octomachinery/runtime/utils.py: -------------------------------------------------------------------------------- 1 | """Runtime context helpers.""" 2 | 3 | from __future__ import annotations 4 | 5 | import typing 6 | from contextvars import ContextVar, Token 7 | 8 | 9 | class ContextLookupError(AttributeError): 10 | """Context var lookup error.""" 11 | 12 | 13 | class _ContextMap: 14 | __slots__ = '__map__', '__token_map__' 15 | 16 | def __init__(self, **initial_vars): 17 | self.__map__: typing.Dict[str, ContextVar[typing.Any]] = { 18 | k: ContextVar(v) for k, v in initial_vars.items() 19 | } 20 | """Storage for all context vars.""" 21 | 22 | self.__token_map__: typing.Dict[str, Token[typing.Any]] = {} 23 | """Storage for individual context var reset tokens.""" 24 | 25 | def __dir__(self): 26 | """Render a list of public attributes.""" 27 | return self.__map__.keys() 28 | 29 | def __getattr__(self, name): 30 | if name in ('__map__', '__token_map__'): 31 | return getattr(self, name) 32 | try: 33 | return self.__map__[name].get() 34 | except LookupError: 35 | raise ContextLookupError( 36 | f'No `{name}` present in the context', 37 | ) from None 38 | 39 | def __setattr__(self, name, value): 40 | if name in ('__map__', '__token_map__'): 41 | object.__setattr__(self, name, value) 42 | elif name in self.__map__: 43 | reset_token = self.__map__[name].set(value) 44 | self.__token_map__[name] = reset_token 45 | else: 46 | raise ContextLookupError(f'No `{name}` present in the context') 47 | 48 | def __delattr__(self, name): 49 | if name not in self.__map__: 50 | raise ContextLookupError(f'No `{name}` present in the context') 51 | reset_token = self.__token_map__[name] 52 | self.__map__[name].reset(reset_token) 53 | del self.__token_map__[name] 54 | -------------------------------------------------------------------------------- /octomachinery/github/entities/action.py: -------------------------------------------------------------------------------- 1 | """GitHub Action wrapper.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | import typing 7 | 8 | import attr 9 | 10 | # pylint: disable=relative-beyond-top-level 11 | from ..api.app_client import GitHubApp 12 | # pylint: disable=relative-beyond-top-level 13 | from ..api.raw_client import RawGitHubAPI 14 | # pylint: disable=relative-beyond-top-level 15 | from ..api.tokens import GitHubOAuthToken 16 | # pylint: disable=relative-beyond-top-level,import-error 17 | from ..models.events import GidgetHubActionEvent 18 | 19 | 20 | if typing.TYPE_CHECKING: 21 | # pylint: disable=relative-beyond-top-level 22 | from ...app.action.config import GitHubActionConfig 23 | 24 | 25 | logger = logging.getLogger(__name__) 26 | 27 | 28 | @attr.dataclass 29 | class GitHubAction(GitHubApp): 30 | """GitHub Action API wrapper.""" 31 | 32 | _metadata: GitHubActionConfig = attr.ib(default=None) 33 | """A GitHub Action metadata from envronment vars.""" 34 | 35 | @_metadata.validator 36 | def _verify_metadata_is_set(self, attribute, value): 37 | if value is None: 38 | raise ValueError(f'{attribute} must be set.') 39 | 40 | @property 41 | def event(self): # noqa: D401 42 | """Parsed GitHub Action event data.""" 43 | return GidgetHubActionEvent.from_file( 44 | self._metadata.event_name, # pylint: disable=no-member 45 | self._metadata.event_path, # pylint: disable=no-member 46 | ) 47 | 48 | @property 49 | def token(self): 50 | """Return GitHub Action access token.""" 51 | return GitHubOAuthToken( 52 | self._metadata.token, # pylint: disable=no-member 53 | ) 54 | 55 | @property 56 | def api_client(self): # noqa: D401 57 | """The GitHub App client.""" 58 | return RawGitHubAPI( 59 | token=self.token, 60 | session=self._http_session, 61 | user_agent=self._config.user_agent, 62 | ) 63 | -------------------------------------------------------------------------------- /octomachinery/github/models/utils.py: -------------------------------------------------------------------------------- 1 | """A collection of utility functions helping with models.""" 2 | 3 | import sys 4 | from datetime import datetime, timezone 5 | from functools import singledispatch 6 | 7 | 8 | @singledispatch 9 | def convert_datetime(datetime_obj) -> datetime: 10 | """Convert arbitrary object into a datetime instance.""" 11 | raise ValueError( 12 | f'The input arg type {type(datetime_obj)} is not supported', 13 | ) 14 | 15 | 16 | @convert_datetime.register 17 | def _convert_datetime_from_unixtime(date_unixtime: int) -> datetime: 18 | return datetime.fromtimestamp(date_unixtime, timezone.utc) 19 | 20 | 21 | @convert_datetime.register 22 | def _convert_datetime_from_string(date_string: str) -> datetime: 23 | if not date_string: 24 | raise ValueError( 25 | f'The input arg {date_string!r} is unsupported', 26 | ) 27 | date_string = date_string.replace('.000Z', '.000000Z') 28 | if '.' not in date_string: 29 | date_string = date_string.replace('Z', '.000000Z') 30 | if '+' not in date_string: 31 | date_string += '+00:00' 32 | 33 | # datetime.fromisoformat() doesn't understand microseconds 34 | return datetime.strptime(date_string, '%Y-%m-%dT%H:%M:%S.%fZ%z') 35 | 36 | 37 | class SecretStr(str): 38 | """String that censors its __repr__ if called from another repr.""" 39 | 40 | def __repr__(self): 41 | """Produce a string representation.""" 42 | frame_depth = 1 43 | 44 | try: 45 | while True: 46 | frame = sys._getframe( # pylint: disable=protected-access 47 | frame_depth, 48 | ) 49 | frame_depth += 1 50 | 51 | if frame.f_code.co_name == '__repr__': 52 | return '' 53 | except ValueError: 54 | pass 55 | 56 | return super().__repr__() 57 | 58 | 59 | class SuperSecretStr(SecretStr): 60 | """String that always censors its __repr__.""" 61 | 62 | def __repr__(self): 63 | """Produce a string representation.""" 64 | return '' 65 | -------------------------------------------------------------------------------- /octomachinery/github/models/action_outcomes.py: -------------------------------------------------------------------------------- 1 | """Processing outcomes for use from within GitHub Action env.""" 2 | 3 | import logging 4 | 5 | import attr 6 | 7 | 8 | __all__ = ('ActionSuccess', 'ActionNeutral', 'ActionFailure') 9 | 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | @attr.dataclass 15 | class ActionOutcome: # pylint: disable=too-few-public-methods 16 | """GitHub Action processing outcome.""" 17 | 18 | message: str 19 | return_code: int 20 | 21 | def raise_it(self): 22 | """Print the message and exit the program with current code.""" 23 | logger.info( 24 | 'Terminating the GitHub Action processing: %s', 25 | self.message, 26 | ) 27 | raise SystemExit(self.return_code) 28 | 29 | 30 | @attr.dataclass 31 | # pylint: disable=too-few-public-methods 32 | class ActionSuccess(ActionOutcome): 33 | """GitHub Action successful outcome.""" 34 | 35 | return_code: int = attr.ib(default=0, init=False) 36 | 37 | 38 | @attr.dataclass 39 | # pylint: disable=too-few-public-methods 40 | class ActionFailure(ActionOutcome): 41 | """GitHub Action failed outcome.""" 42 | 43 | return_code: int = attr.ib(default=1) 44 | 45 | @return_code.validator 46 | def _validate_return_code( 47 | self, 48 | attribute, # pylint: disable=unused-argument 49 | value, 50 | ): 51 | if value in NON_FAIL_MODELS: 52 | raise ValueError( 53 | f'Return code of `{value}` is illegal to use for failure ' 54 | f'outcome. Use {NON_FAIL_MODELS[value]} instead', 55 | ) 56 | 57 | 58 | @attr.dataclass 59 | # pylint: disable=too-few-public-methods 60 | class ActionNeutral(ActionOutcome): 61 | """GitHub Action neutral outcome.""" 62 | 63 | # NOTE: It's EX_CONFIG under BSD and EREMCHG under GNU/Linux 64 | # NOTE: that's why we are using just 78 conventional constant 65 | # NOTE: here... 66 | # NOTE: Ref: 67 | # https://developer.github.com/actions/creating-github-actions\ 68 | # /accessing-the-runtime-environment/#exit-codes-and-statuses 69 | return_code: int = attr.ib(default=78, init=False) 70 | 71 | 72 | NON_FAIL_MODELS = { 73 | 0: 'ActionSuccess', 74 | 78: 'ActionNeutral', 75 | } 76 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = 3 | # `pytest-xdist` == -n auto: 4 | --numprocesses=auto 5 | 6 | # Show 10 slowest invocations: 7 | --durations=10 8 | 9 | # A bit of verbosity doesn't hurt: 10 | -v 11 | 12 | # Report all the things == -rxXs: 13 | -ra 14 | 15 | # Show values of the local vars in errors: 16 | --showlocals 17 | 18 | # Autocollect and invoke the doctests from all modules: 19 | # https://docs.pytest.org/en/stable/doctest.html 20 | --doctest-modules 21 | 22 | # Dump the test results in junit format: 23 | --junitxml=.tox/tmp/test-results/pytest/results.xml 24 | 25 | # Fail on config parsing warnings: 26 | # --strict-config 27 | 28 | # Fail on non-existing markers: 29 | # * Deprecated since v6.2.0 but may be reintroduced later covering a 30 | # broader scope: 31 | # --strict 32 | # * Exists since v4.5.0 (advised to be used instead of `--strict`): 33 | --strict-markers 34 | 35 | # `pytest-cov`: 36 | # `pytest-cov`, "-p" preloads the module early: 37 | -p pytest_cov 38 | --no-cov-on-fail 39 | --cov=octomachinery 40 | --cov=tests/ 41 | --cov-branch 42 | --cov-report=term-missing:skip-covered 43 | --cov-report=html:.tox/tmp/test-results/pytest/cov/ 44 | --cov-report=xml 45 | --cov-context=test 46 | --cov-config=.coveragerc 47 | 48 | doctest_optionflags = ALLOW_UNICODE ELLIPSIS 49 | 50 | # Marks tests with an empty parameterset as xfail(run=False) 51 | empty_parameter_set_mark = xfail 52 | 53 | faulthandler_timeout = 30 54 | 55 | filterwarnings = 56 | error 57 | # drop this once aiohttp>4 is out 58 | ignore:The loop argument is deprecated since Python 3.8, and scheduled for removal in Python 3.10.:DeprecationWarning 59 | 60 | junit_duration_report = call 61 | # xunit1 contains more metadata than xunit2 so it's better for CI UIs: 62 | junit_family = xunit1 63 | junit_logging = all 64 | junit_log_passing_tests = true 65 | junit_suite_name = octomachinery_test_suite 66 | 67 | # A mapping of markers to their descriptions allowed in strict mode: 68 | markers = 69 | 70 | minversion = 5.3.3 71 | 72 | # Optimize pytest's lookup by restricting potentially deep dir tree scan: 73 | norecursedirs = 74 | build 75 | dist 76 | docs 77 | octomachinery.egg-info 78 | .cache 79 | .eggs 80 | .git 81 | .github 82 | .tox 83 | 84 | testpaths = tests/ 85 | 86 | xfail_strict = true 87 | -------------------------------------------------------------------------------- /octomachinery/app/server/runner.py: -------------------------------------------------------------------------------- 1 | """Octomachinery CLI runner.""" 2 | 3 | import logging 4 | import sys 5 | from typing import Iterable, Optional 6 | 7 | from aiohttp.web_runner import GracefulExit 8 | from anyio import run as run_until_complete 9 | 10 | import attr 11 | 12 | # pylint: disable=relative-beyond-top-level 13 | from ..config import BotAppConfig 14 | # pylint: disable=relative-beyond-top-level 15 | from ..routing import WEBHOOK_EVENTS_ROUTER 16 | # pylint: disable=relative-beyond-top-level 17 | from ..routing.abc import OctomachineryRouterBase 18 | # pylint: disable=relative-beyond-top-level 19 | from .config import WebServerConfig 20 | # pylint: disable=relative-beyond-top-level 21 | from .machinery import run_forever as run_server_forever 22 | 23 | 24 | logger = logging.getLogger(__name__) 25 | 26 | 27 | def run( 28 | *, 29 | name: Optional[str] = None, 30 | version: Optional[str] = None, 31 | url: Optional[str] = None, 32 | config: Optional[BotAppConfig] = None, 33 | event_routers: Optional[Iterable[OctomachineryRouterBase]] = None, 34 | ): 35 | """Start up a server using CLI args for host and port.""" 36 | if event_routers is None: 37 | event_routers = {WEBHOOK_EVENTS_ROUTER} 38 | 39 | if ( 40 | config is not None and 41 | (name is not None or version is not None or url is not None) 42 | ): 43 | raise TypeError( 44 | 'run() takes either a BotAppConfig instance as a config argument ' 45 | 'or name, version and url arguments.', 46 | ) 47 | if config is None: 48 | config = BotAppConfig.from_dotenv( 49 | app_name=name, 50 | app_version=version, 51 | app_url=url, 52 | ) 53 | if len(sys.argv) > 2: 54 | config = attr.evolve( # type: ignore[misc] 55 | config, 56 | server=WebServerConfig(*sys.argv[1:3]), 57 | ) 58 | 59 | logging.basicConfig( 60 | level=logging.DEBUG 61 | if config.runtime.debug # pylint: disable=no-member 62 | else logging.INFO, 63 | ) 64 | if config.runtime.debug: # pylint: disable=no-member 65 | logger.debug( 66 | ' App version: %s '.center(50, '='), 67 | config.github.app_version, 68 | ) 69 | 70 | try: 71 | run_until_complete(run_server_forever, config, event_routers) 72 | except (GracefulExit, KeyboardInterrupt): 73 | logger.info(' Exiting the app '.center(50, '=')) 74 | -------------------------------------------------------------------------------- /octomachinery/app/config.py: -------------------------------------------------------------------------------- 1 | """GitHub App/bot configuration.""" 2 | 3 | import os 4 | from functools import lru_cache 5 | from typing import Optional 6 | 7 | import environ 8 | import envparse 9 | 10 | # pylint: disable=relative-beyond-top-level 11 | from ..github.config.app import GitHubAppIntegrationConfig 12 | # pylint: disable=relative-beyond-top-level 13 | from .action.config import GitHubActionConfig 14 | # pylint: disable=relative-beyond-top-level 15 | from .runtime.config import RuntimeConfig 16 | # pylint: disable=relative-beyond-top-level 17 | from .server.config import WebServerConfig 18 | 19 | 20 | @environ.config 21 | class BotAppConfig: 22 | """Bot app config. 23 | 24 | Construct it as follows:: 25 | >>> from octomachinery.app.config import BotAppConfig 26 | >>> config = BotAppConfig.from_dotenv() # for dev env 27 | >>> config = BotAppConfig.from_env() # for pure env 28 | >>> 29 | """ 30 | 31 | github = environ.group(GitHubAppIntegrationConfig) 32 | action = environ.group(GitHubActionConfig) 33 | server = environ.group(WebServerConfig) 34 | runtime = environ.group(RuntimeConfig) 35 | 36 | @classmethod 37 | @lru_cache(maxsize=1) 38 | def from_dotenv( 39 | cls, 40 | *, 41 | app_name: Optional[str] = None, 42 | app_version: Optional[str] = None, 43 | app_url: Optional[str] = None, 44 | ): 45 | """Return an initialized dev config instance. 46 | 47 | Read .env into env vars before that. 48 | """ 49 | envparse.Env.read_envfile( 50 | '.env', # Making it relative to CWD, relative to caller if None 51 | ) 52 | return cls.from_env( 53 | app_name=app_name, 54 | app_version=app_version, 55 | app_url=app_url, 56 | ) 57 | 58 | @classmethod 59 | @lru_cache(maxsize=1) 60 | def from_env( 61 | cls, 62 | *, 63 | app_name: Optional[str] = None, 64 | app_version: Optional[str] = None, 65 | app_url: Optional[str] = None, 66 | ): 67 | """Return an initialized config instance.""" 68 | env_vars = dict(os.environ) 69 | if app_name is not None: 70 | env_vars['OCTOMACHINERY_APP_NAME'] = app_name 71 | if app_version is not None: 72 | env_vars['OCTOMACHINERY_APP_VERSION'] = app_version 73 | if app_url is not None: 74 | env_vars['OCTOMACHINERY_APP_URL'] = app_url 75 | return environ.to_config(cls, environ=env_vars) 76 | -------------------------------------------------------------------------------- /octomachinery/github/entities/app_installation.py: -------------------------------------------------------------------------------- 1 | """GitHub App Installation wrapper.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | import typing 7 | 8 | import attr 9 | 10 | # pylint: disable=relative-beyond-top-level 11 | from ..api.raw_client import RawGitHubAPI 12 | # pylint: disable=relative-beyond-top-level 13 | from ..api.tokens import GitHubOAuthToken 14 | # pylint: disable=relative-beyond-top-level 15 | from ..models import GitHubAppInstallation as GitHubAppInstallationModel 16 | from ..models import GitHubInstallationAccessToken 17 | 18 | 19 | if typing.TYPE_CHECKING: 20 | from ..api.app_client import GitHubApp 21 | 22 | 23 | logger = logging.getLogger(__name__) 24 | 25 | 26 | @attr.dataclass 27 | class GitHubAppInstallation: 28 | """GitHub App Installation API wrapper.""" 29 | 30 | _metadata: GitHubAppInstallationModel 31 | """A GitHub Installation metadata from GitHub webhook.""" 32 | _github_app: GitHubApp 33 | """A GitHub App the Installation is associated with.""" 34 | _token: GitHubInstallationAccessToken = attr.ib(init=False, default=None) 35 | """A GitHub Installation token for GitHub API.""" 36 | 37 | @property 38 | def app(self): 39 | """Bound GitHub App instance.""" 40 | return self._github_app 41 | 42 | async def get_token(self): 43 | """Retrieve installation access token from GitHub API.""" 44 | return GitHubInstallationAccessToken( 45 | **( 46 | await self.app.api_client.post( 47 | self._metadata.access_tokens_url, 48 | data=b'', 49 | preview_api_version='machine-man', 50 | ) 51 | ), 52 | ) 53 | 54 | async def _refresh_api_token(self): 55 | """Extract installation access token value. 56 | 57 | Refreshes it as needed. 58 | """ 59 | if self._token is None or self._token.expired: 60 | self._token = await self.get_token() 61 | 62 | return GitHubOAuthToken(self._token.token) 63 | 64 | @property 65 | def api_client(self): # noqa: D401 66 | """The GitHub App Installation client.""" 67 | return RawGitHubAPI( 68 | # pylint: disable=fixme 69 | token=self._refresh_api_token, # type: ignore[arg-type] # FIXME 70 | # pylint: disable=protected-access 71 | session=self.app._http_session, 72 | # pylint: disable=protected-access 73 | user_agent=self.app._config.user_agent, 74 | ) 75 | -------------------------------------------------------------------------------- /octomachinery/github/api/utils.py: -------------------------------------------------------------------------------- 1 | """Utilitary helpers.""" 2 | 3 | from functools import wraps 4 | from inspect import Parameter, Signature 5 | from types import AsyncGeneratorType 6 | from typing import Any, Dict, Tuple 7 | 8 | from gidgethub.sansio import accept_format 9 | 10 | 11 | def mark_uninitialized_in_repr(cls): 12 | """Patch __repr__ for uninitialized instances.""" 13 | orig_repr = cls.__repr__ 14 | 15 | @wraps(orig_repr) 16 | def new_repr(self): 17 | if not self.is_initialized: 18 | return f'{self.__class__.__name__}()' 19 | return orig_repr(self) 20 | cls.__repr__ = new_repr 21 | return cls 22 | 23 | 24 | def accept_preview_version(wrapped_coroutine): 25 | """Extend keyword-args with `preview_api_version`.""" 26 | @wraps(wrapped_coroutine) 27 | def coroutine_wrapper( 28 | *args: Tuple[Any], **kwargs: Dict[str, Any], 29 | ) -> AsyncGeneratorType: # type: ignore[type-arg] 30 | accept_media = kwargs.pop('accept', None) 31 | preview_api_version = kwargs.pop('preview_api_version', None) 32 | 33 | if preview_api_version is not None: 34 | accept_media = accept_format( 35 | version=f'{preview_api_version}-preview', 36 | ) 37 | if accept_media is not None: 38 | kwargs['accept'] = accept_media 39 | 40 | coroutine_instance = wrapped_coroutine(*args, **kwargs) 41 | is_async_generator = isinstance(coroutine_instance, AsyncGeneratorType) 42 | 43 | if not is_async_generator: 44 | async def async_function_wrapper(): 45 | return await coroutine_instance 46 | return async_function_wrapper() 47 | 48 | async def async_generator_wrapper(): 49 | async for result_item in coroutine_instance: 50 | yield result_item 51 | 52 | return async_generator_wrapper() 53 | 54 | original_wrapped_signature = Signature.from_callable(wrapped_coroutine) 55 | original_callable_params = original_wrapped_signature.parameters 56 | wrapped_callable_params = list(original_callable_params.values()) 57 | 58 | accept_pos = list(original_callable_params.keys()).index('accept') 59 | preview_param = Parameter( 60 | name='preview_api_version', 61 | annotation='Optional[str]', 62 | default=None, 63 | kind=Parameter.KEYWORD_ONLY, 64 | ) 65 | wrapped_callable_params.insert(accept_pos, preview_param) 66 | 67 | coroutine_wrapper.__signature__ = ( # type: ignore[attr-defined] 68 | original_wrapped_signature.replace(parameters=wrapped_callable_params) 69 | ) 70 | 71 | return coroutine_wrapper 72 | -------------------------------------------------------------------------------- /tests/utils/asynctools_test.py: -------------------------------------------------------------------------------- 1 | """Test for asynchronous operations utility functions.""" 2 | 3 | import pytest 4 | 5 | from octomachinery.utils.asynctools import amap, dict_to_kwargs_cb, try_await 6 | 7 | 8 | def sync_power2(val): 9 | """Raise x to the power of 2.""" 10 | return val ** 2 11 | 12 | 13 | @pytest.mark.anyio 14 | async def async_power2(val): 15 | """Raise x to the power of 2 asynchronously.""" 16 | return sync_power2(val) 17 | 18 | 19 | @pytest.mark.parametrize( 20 | 'callback_func', 21 | ( 22 | sync_power2, 23 | async_power2, 24 | ), 25 | ) 26 | @pytest.mark.anyio 27 | @pytest.mark.usefixtures('event_loop') 28 | async def test_amap(callback_func): 29 | """Test that async map works for both sync and async callables.""" 30 | async def async_iter(*args, **kwargs): 31 | for _ in range(*args, **kwargs): 32 | yield _ 33 | 34 | test_range = 5 35 | 36 | actual_result = [ 37 | i async for i in amap(callback_func, async_iter(test_range)) 38 | ] 39 | expected_result = [ 40 | await try_await(callback_func(i)) for i in range(test_range) 41 | ] 42 | assert actual_result == expected_result 43 | 44 | 45 | @pytest.mark.parametrize( 46 | 'callback_func', 47 | ( 48 | sync_power2, 49 | async_power2, 50 | ), 51 | ) 52 | @pytest.mark.anyio 53 | @pytest.mark.usefixtures('event_loop') 54 | async def test_dict_to_kwargs_cb(callback_func): 55 | """Test that input dict is turned into given (a)sync callable args.""" 56 | test_val = 5 57 | test_dict = {'val': test_val} 58 | 59 | actual_result = await dict_to_kwargs_cb(callback_func)(test_dict) 60 | expected_result = await try_await(callback_func(test_val)) 61 | assert actual_result == expected_result 62 | 63 | 64 | @pytest.mark.parametrize( 65 | 'callback_func,callback_arg', 66 | ( 67 | (sync_power2, 3), 68 | (async_power2, 8), 69 | ), 70 | ) 71 | @pytest.mark.anyio 72 | @pytest.mark.usefixtures('event_loop') 73 | async def test_try_await(callback_func, callback_arg): 74 | """Test that result is awaited regardless of (a)sync func type.""" 75 | actual_result = await try_await(callback_func(callback_arg)) 76 | expected_result = callback_arg ** 2 77 | assert actual_result == expected_result 78 | 79 | 80 | @pytest.mark.anyio 81 | @pytest.mark.usefixtures('event_loop') 82 | async def test_try_await_bypass_errors(): 83 | """Test that internal callback exceptions are propagated.""" 84 | async def break_callback(): 85 | raise TypeError('It is broken') 86 | 87 | with pytest.raises(TypeError, match='It is broken'): 88 | await try_await(break_callback()) 89 | -------------------------------------------------------------------------------- /octomachinery/github/config/app.py: -------------------------------------------------------------------------------- 1 | """Config schema for a GitHub App instance details.""" 2 | import environ 3 | 4 | # pylint: disable=relative-beyond-top-level 5 | from ..models.private_key import GitHubPrivateKey 6 | # pylint: disable=relative-beyond-top-level 7 | from ..models.utils import SecretStr 8 | 9 | 10 | def validate_is_not_none_if_app( 11 | self, # pylint: disable=unused-argument 12 | attr, value, 13 | ): 14 | """Forbid None value in a GitHub App context.""" 15 | # pylint: disable=relative-beyond-top-level,import-outside-toplevel 16 | from ...app.runtime.utils import detect_env_mode 17 | 18 | if value is None and detect_env_mode() == 'app': 19 | raise ValueError( 20 | f'GitHub App must provide a proper value for {attr!r}', 21 | ) 22 | 23 | 24 | def validate_fingerprint_if_present(instance, _attribute, value): 25 | r"""Validate that the private key matches the fingerprint pin. 26 | 27 | :raises ValueError: if the fingerprint pin is present \ 28 | but doesn't match the private key 29 | """ 30 | if not value: 31 | return 32 | 33 | if instance.private_key.matches_fingerprint(value): 34 | return 35 | 36 | raise ValueError( 37 | 'The private key provided (with a fingerprint of ' 38 | f'{instance.private_key.fingerprint!s}) does not match ' 39 | f'the pinned fingerprint value of {value!s}', 40 | ) 41 | 42 | 43 | @environ.config 44 | class GitHubAppIntegrationConfig: # pylint: disable=too-few-public-methods 45 | """GitHub App auth related config.""" 46 | 47 | app_id = environ.var( 48 | None, 49 | name='GITHUB_APP_IDENTIFIER', 50 | validator=validate_is_not_none_if_app, 51 | ) 52 | private_key = environ.var( 53 | None, 54 | name='GITHUB_PRIVATE_KEY', 55 | converter=lambda raw_data: 56 | None if raw_data is None else GitHubPrivateKey(raw_data.encode()), 57 | validator=validate_is_not_none_if_app, 58 | ) 59 | private_key_fingerprint = environ.var( 60 | None, 61 | name='GITHUB_PRIVATE_KEY_FINGERPRINT', 62 | validator=validate_fingerprint_if_present, 63 | ) 64 | webhook_secret = environ.var( 65 | None, name='GITHUB_WEBHOOK_SECRET', 66 | converter=lambda s: SecretStr(s) if s is not None else s, 67 | ) 68 | 69 | app_name = environ.var(None, name='OCTOMACHINERY_APP_NAME') 70 | app_version = environ.var(None, name='OCTOMACHINERY_APP_VERSION') 71 | app_url = environ.var(None, name='OCTOMACHINERY_APP_URL') 72 | 73 | @property 74 | def user_agent(self): # noqa: D401 75 | """The User-Agent value to use when hitting GitHub API.""" 76 | return f'{self.app_name}/{self.app_version} (+{self.app_url})' 77 | -------------------------------------------------------------------------------- /octomachinery/app/action/runner.py: -------------------------------------------------------------------------------- 1 | """Octomachinery CLI runner for GitHub Action environments.""" 2 | 3 | import asyncio 4 | import logging 5 | from typing import Iterable, Optional 6 | 7 | from aiohttp.client import ClientSession 8 | 9 | # pylint: disable=relative-beyond-top-level 10 | from ...github.entities.action import GitHubAction 11 | # pylint: disable=relative-beyond-top-level 12 | from ...github.errors import GitHubActionError 13 | # pylint: disable=relative-beyond-top-level 14 | from ...github.models.action_outcomes import ( 15 | ActionFailure, ActionNeutral, ActionSuccess, 16 | ) 17 | # pylint: disable=relative-beyond-top-level 18 | from ..config import BotAppConfig 19 | # pylint: disable=relative-beyond-top-level 20 | from ..routing import WEBHOOK_EVENTS_ROUTER 21 | # pylint: disable=relative-beyond-top-level 22 | from ..routing.abc import OctomachineryRouterBase 23 | # pylint: disable=relative-beyond-top-level 24 | from ..routing.webhooks_dispatcher import route_github_event 25 | 26 | 27 | logger = logging.getLogger(__name__) 28 | 29 | 30 | async def process_github_action(config, event_routers): 31 | """Schedule GitHub Action event for processing.""" 32 | logger.info('Processing GitHub Action event...') 33 | 34 | async with ClientSession() as http_client_session: 35 | github_action = GitHubAction( 36 | metadata=config.action, 37 | http_session=http_client_session, 38 | config=config.github, 39 | event_routers=event_routers, 40 | ) 41 | logger.info('GitHub Action=%r', config.action) 42 | 43 | await route_github_event( 44 | github_event=github_action.event, 45 | github_app=github_action, 46 | ) 47 | return ActionSuccess('GitHub Action has been processed') 48 | 49 | 50 | def run( 51 | *, 52 | config: Optional[BotAppConfig] = None, 53 | event_routers: Optional[Iterable[OctomachineryRouterBase]] = None, 54 | ) -> None: 55 | """Start up a server using CLI args for host and port.""" 56 | if event_routers is None: 57 | event_routers = {WEBHOOK_EVENTS_ROUTER} 58 | 59 | if config is None: 60 | config = BotAppConfig.from_dotenv() 61 | 62 | logging.basicConfig( 63 | level=logging.DEBUG 64 | if config.runtime.debug # pylint: disable=no-member 65 | else logging.INFO, 66 | ) 67 | 68 | try: 69 | processing_outcome = asyncio.run( 70 | process_github_action(config, event_routers), 71 | ) 72 | except GitHubActionError as action_error: 73 | action_error.terminate_action() 74 | except KeyboardInterrupt: 75 | ActionNeutral('Action processing interrupted by user').raise_it() 76 | except Exception: # pylint: disable=broad-except 77 | err_msg = 'Action processing failed unexpectedly' 78 | logger.exception(err_msg) 79 | ActionFailure(err_msg).raise_it() 80 | else: 81 | processing_outcome.raise_it() 82 | -------------------------------------------------------------------------------- /octomachinery/app/runtime/installation_utils.py: -------------------------------------------------------------------------------- 1 | """Utility helpers for App/Action installations.""" 2 | 3 | import typing 4 | from base64 import b64decode 5 | from http import HTTPStatus 6 | from io import StringIO 7 | from pathlib import Path 8 | 9 | import gidgethub 10 | 11 | import yaml 12 | 13 | # pylint: disable=relative-beyond-top-level 14 | from ...runtime.context import RUNTIME_CONTEXT 15 | 16 | 17 | def _get_file_contents_from_fs(file_name: str) -> typing.Optional[str]: 18 | """Read file contents from file system checkout. 19 | 20 | This code path is synchronous. 21 | 22 | It doesn't matter much in GitHub Actions 23 | but can be refactored later. 24 | """ 25 | config_path = Path('.') / file_name 26 | 27 | try: 28 | return config_path.read_text() 29 | except FileNotFoundError: 30 | return None 31 | 32 | 33 | async def _get_file_contents_from_api( 34 | file_name: str, 35 | ref: typing.Optional[str], 36 | ) -> typing.Optional[str]: 37 | """Read file contents using GitHub API.""" 38 | github_api = RUNTIME_CONTEXT.app_installation_client 39 | repo_slug = RUNTIME_CONTEXT.github_event.payload['repository']['full_name'] 40 | 41 | api_query_params = f'?ref={ref}' if ref else '' 42 | try: 43 | config_response = await github_api.getitem( 44 | f'/repos/{repo_slug}/contents' 45 | f'/{file_name}{api_query_params}', 46 | ) 47 | except gidgethub.BadRequest as http_bad_req: 48 | if http_bad_req.status_code == HTTPStatus.NOT_FOUND: 49 | return None 50 | 51 | raise 52 | 53 | config_file_found = ( 54 | config_response.get('encoding') == 'base64' and 55 | 'content' in config_response 56 | ) 57 | if not config_file_found: 58 | return None 59 | 60 | return b64decode(config_response['content']).decode() 61 | 62 | 63 | async def read_file_contents_from_repo( 64 | *, 65 | file_path: str, 66 | ref: typing.Optional[str] = None, 67 | ) -> typing.Optional[str]: 68 | """Get a config object from the current installation. 69 | 70 | Read from file system checkout in case of GitHub Action env. 71 | Grab it via GitHub API otherwise. 72 | 73 | Usage:: 74 | 75 | >>> from octomachinery.app.runtime.installation_utils import ( 76 | ... read_file_contents_from_repo 77 | ... ) 78 | >>> await read_file_contents_from_repo( 79 | ... '/file/path.txt', 80 | ... ref='bdeaf38', 81 | ... ) 82 | """ 83 | if RUNTIME_CONTEXT.IS_GITHUB_ACTION and ref is None: 84 | return _get_file_contents_from_fs(file_path) 85 | 86 | return await _get_file_contents_from_api(file_path, ref) 87 | 88 | 89 | async def get_installation_config( 90 | *, 91 | config_name: str = 'config.yml', 92 | ref: typing.Optional[str] = None, 93 | ) -> typing.Mapping[str, typing.Any]: 94 | """Get a config object from the current installation. 95 | 96 | Read from file system checkout in case of GitHub Action env. 97 | Grab it via GitHub API otherwise. 98 | 99 | Usage:: 100 | 101 | >>> from octomachinery.app.runtime.installation_utils import ( 102 | ... get_installation_config 103 | ... ) 104 | >>> await get_installation_config() 105 | """ 106 | config_path = f'.github/{config_name}' 107 | 108 | config_content = await read_file_contents_from_repo( 109 | file_path=config_path, 110 | ref=ref, 111 | ) 112 | 113 | if config_content is None: 114 | return {} 115 | 116 | return yaml.load(StringIO(config_content), Loader=yaml.SafeLoader) 117 | -------------------------------------------------------------------------------- /tests/app/action/runner_test.py: -------------------------------------------------------------------------------- 1 | """Actions processor test suite.""" 2 | 3 | import json 4 | 5 | import pytest 6 | 7 | from octomachinery.app.action.config import GitHubActionConfig 8 | from octomachinery.app.action.runner import run 9 | from octomachinery.app.config import BotAppConfig 10 | from octomachinery.app.routing import process_event, process_event_actions 11 | from octomachinery.app.runtime.config import RuntimeConfig 12 | from octomachinery.app.server.config import WebServerConfig 13 | from octomachinery.github.config.app import GitHubAppIntegrationConfig 14 | from octomachinery.github.errors import GitHubActionError 15 | from octomachinery.github.models.action_outcomes import ActionNeutral 16 | 17 | 18 | @process_event('unmatched_event', action='happened') 19 | async def unmatched_event_happened(action): # pylint: disable=unused-argument 20 | """Handle an unmatched event.""" 21 | 22 | 23 | @process_event('check_run', action='created') 24 | async def check_run_created(action): # pylint: disable=unused-argument 25 | """Handle a check_run event.""" 26 | raise RuntimeError('Emulate an unhandled error in the handler') 27 | 28 | 29 | @process_event_actions('neutral_event', {'qwerty'}) 30 | async def neutral_event_qwerty(action): # pylint: disable=unused-argument 31 | """Handle a neutral_event.""" 32 | raise GitHubActionError(ActionNeutral('Neutral outcome')) 33 | 34 | 35 | @process_event_actions('neutral_event') 36 | async def neutral_event_dummy(action): # pylint: disable=unused-argument 37 | """Handle any neutral event.""" 38 | 39 | 40 | @pytest.fixture 41 | def event_file(tmp_path_factory, request): 42 | """Generate a sample JSON event file.""" 43 | gh_event = ( 44 | tmp_path_factory.mktemp('github-action-event') / 45 | 'github-workflow-event.json' 46 | ) 47 | with gh_event.open('w') as gh_event_file: 48 | json.dump( 49 | { 50 | 'action': request.param, # 'created', 51 | }, 52 | gh_event_file, 53 | ) 54 | return gh_event 55 | 56 | 57 | @pytest.fixture 58 | def config(monkeypatch, event_file, request): 59 | """Create a dummy GitHub Action config.""" 60 | monkeypatch.setattr( 61 | 'octomachinery.app.runtime.utils.detect_env_mode', 62 | lambda: 'action', 63 | ) 64 | # pylint: disable=fixme 65 | return BotAppConfig( # type: ignore[call-arg] # FIXME 66 | github=GitHubAppIntegrationConfig(), 67 | # pylint: disable=fixme 68 | action=GitHubActionConfig( # type: ignore[call-arg] # FIXME 69 | workflow='Test Workflow', 70 | action='Test Action', 71 | actor='username-or-bot', 72 | repository='org/repo', 73 | event_name=request.param, # 'check_run' 74 | event_path=str(event_file), # '/github/workflow/event.json' 75 | workspace='/github/workspace', 76 | sha='e6d4abcb8a6cd989d41ee', 77 | ref='refs/heads/master', 78 | token='8sdfn12lifds8sdvh832n32f9jew', 79 | ), 80 | server=WebServerConfig(), 81 | runtime=RuntimeConfig(), 82 | ) 83 | 84 | 85 | @pytest.mark.parametrize( 86 | 'config, event_file, expected_return_code', 87 | ( 88 | ('check_run', 'created', 1), 89 | ('unmatched_event', 'closed', 0), 90 | ('neutral_event', 'qwerty', 78), 91 | ), 92 | indirect=('config', 'event_file'), 93 | ) 94 | def test_action_processing_return_code(config, expected_return_code): 95 | """Test an empty action processing run.""" 96 | with pytest.raises( 97 | SystemExit, 98 | match=r'^' 99 | f'{expected_return_code}' 100 | r'$', 101 | ): 102 | run(config=config) 103 | -------------------------------------------------------------------------------- /octomachinery/routing/routers.py: -------------------------------------------------------------------------------- 1 | """Octomachinery event dispatchers collection.""" 2 | 3 | import asyncio 4 | from contextlib import suppress 5 | from typing import Any, Iterator, Set, Union 6 | 7 | from gidgethub.routing import AsyncCallback 8 | from gidgethub.routing import Router as _GidgetHubRouter 9 | 10 | from ..github.models.events import ( 11 | GidgetHubWebhookEvent, GitHubEvent, _GidgetHubEvent, 12 | ) 13 | from ..utils.asynctools import aio_gather 14 | from .abc import OctomachineryRouterBase 15 | 16 | 17 | __all__ = ( 18 | 'GidgetHubRouterBase', 19 | 'ConcurrentRouter', 20 | 'NonBlockingConcurrentRouter', 21 | ) 22 | 23 | 24 | class GidgetHubRouterBase(_GidgetHubRouter, OctomachineryRouterBase): 25 | """GidgetHub-based router exposing callback matching separately.""" 26 | 27 | def emit_routes_for( 28 | self, event_name: str, event_payload: Any, 29 | ) -> Iterator[AsyncCallback]: 30 | """Emit callbacks that match given event and payload. 31 | 32 | :param str event_name: name of the GitHub event 33 | :param str event_payload: details of the GitHub event 34 | 35 | :yields: coroutine event handlers 36 | """ 37 | with suppress(KeyError): 38 | yield from self._shallow_routes[event_name] 39 | 40 | try: 41 | deep_routes = self._deep_routes[event_name] 42 | except KeyError: 43 | return 44 | 45 | for payload_key, payload_values in deep_routes.items(): 46 | if payload_key not in event_payload: 47 | continue 48 | event_value = event_payload[payload_key] 49 | if event_value not in payload_values: 50 | continue 51 | yield from payload_values[event_value] 52 | 53 | async def dispatch( 54 | self, event: Union[GidgetHubWebhookEvent, _GidgetHubEvent], 55 | *args: Any, **kwargs: Any, 56 | ) -> None: 57 | """Invoke handler tasks for the given event sequentially.""" 58 | if isinstance(event, _GidgetHubEvent): 59 | event = GidgetHubWebhookEvent.from_gidgethub(event) 60 | 61 | callback_gen = self.emit_routes_for(event.name, event.payload) 62 | callback_coros = (cb(event, *args, **kwargs) for cb in callback_gen) 63 | 64 | for coro in callback_coros: 65 | await coro 66 | 67 | 68 | class ConcurrentRouter(GidgetHubRouterBase): 69 | """GitHub event router invoking event handlers simultaneously.""" 70 | 71 | async def dispatch( 72 | self, event: GitHubEvent, 73 | *args: Any, **kwargs: Any, 74 | ) -> None: 75 | """Invoke coroutine callbacks for the given event together.""" 76 | callback_gen = self.emit_routes_for(event.name, event.payload) 77 | callback_coros = (cb(event, *args, **kwargs) for cb in callback_gen) 78 | 79 | await aio_gather(*callback_coros) 80 | 81 | 82 | class NonBlockingConcurrentRouter(ConcurrentRouter): 83 | """Non-blocking GitHub event router scheduling handler tasks.""" 84 | 85 | def __init__(self, *args, **kwargs): 86 | """Initialize NonBlockingConcurrentRouter.""" 87 | super().__init__(*args, **kwargs) 88 | # NOTE: For some reason, mypy doesn't accept anything except Any here: 89 | self._event_handler_tasks: Set[Any] = set() 90 | 91 | async def dispatch( 92 | self, event: GitHubEvent, 93 | *args: Any, **kwargs: Any, 94 | ) -> None: 95 | """Schedule coroutine callbacks for the given event together.""" 96 | callback_gen = self.emit_routes_for(event.name, event.payload) 97 | callback_coros = (cb(event, *args, **kwargs) for cb in callback_gen) 98 | handler_tasks = map(asyncio.create_task, callback_coros) 99 | self._event_handler_tasks.update(handler_tasks) 100 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | # NOTE: "universal = 1" causes `bdist_wheel` to create a wheel that with the 3 | # NOTE: tag "py2.py3" which implies (and tricks pip into thinking) that this 4 | # NOTE: wheel contains Python 2 compatible code. This is not true and conflicts 5 | # NOTE: with the "Requires-Python" field in the metadata that says that we only 6 | # NOTE: support Python 3.6+. 7 | # NOTE: We need to keep it at "0" which will produce wheels tagged with "py3" 8 | # NOTE: when built under Python 3. 9 | # Ref: https://github.com/pypa/packaging.python.org/issues/726 10 | universal = 0 11 | 12 | [metadata] 13 | name = octomachinery 14 | url = https://octomachinery.dev 15 | project_urls = 16 | Chat: Matrix = https://matrix.to/#/#octomachinery:matrix.org 17 | Chat: Matrix (PyBA) = https://matrix.to/#/#pyba:matrix.org 18 | Chat: Matrix (@webknjaz) = https://matrix.to/#/@webknjaz:matrix.org 19 | CI: GitHub = https://github.com/sanitizers/octomachinery/actions/workflows/ci-cd.yml?query=event:push 20 | Docs: RTD = https://docs.octomachinery.dev 21 | GitHub: issues = https://github.com/sanitizers/octomachinery/issues 22 | GitHub: repo = https://github.com/sanitizers/octomachinery 23 | description = Invisible engine driving octobot machines. Simple, yet powerful. 24 | long_description = file: README.rst 25 | long_description_content_type = text/x-rst 26 | author = Sviatoslav Sydorenko (@webknjaz) 27 | author_email = wk+octomachinery@sydorenko.org.ua 28 | license = GPLv3+ 29 | license_file = LICENSE 30 | classifiers = 31 | Development Status :: 2 - Pre-Alpha 32 | 33 | Environment :: Console 34 | Environment :: Other Environment 35 | Environment :: Web Environment 36 | 37 | Framework :: AnyIO 38 | Framework :: AsyncIO 39 | 40 | Intended Audience :: Developers 41 | Intended Audience :: Information Technology 42 | Intended Audience :: System Administrators 43 | 44 | License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+) 45 | 46 | Operating System :: OS Independent 47 | Operating System :: POSIX 48 | 49 | Programming Language :: Python 50 | Programming Language :: Python :: 3 51 | Programming Language :: Python :: 3.7 52 | Programming Language :: Python :: 3.8 53 | Programming Language :: Python :: 3.9 54 | Programming Language :: Python :: Implementation 55 | Programming Language :: Python :: Implementation :: CPython 56 | Programming Language :: Python :: Implementation :: PyPy 57 | 58 | Topic :: Internet :: WWW/HTTP 59 | Topic :: Internet :: WWW/HTTP :: HTTP Servers 60 | 61 | Topic :: Software Development 62 | Topic :: Software Development :: Libraries 63 | Topic :: Software Development :: Libraries :: Application Frameworks 64 | Topic :: Software Development :: Libraries :: Python Modules 65 | Topic :: Software Development :: Version Control 66 | Topic :: Software Development :: Version Control :: Git 67 | 68 | Topic :: System :: Networking 69 | 70 | Topic :: Utilities 71 | 72 | Typing :: Typed 73 | keywords = 74 | Bot 75 | Framework 76 | Framework for writing GitHub Apps 77 | GitHub 78 | GitHub Actions 79 | GitHub API 80 | GitHub Apps 81 | GitHub Checks API 82 | 83 | [options] 84 | python_requires = >=3.7 85 | package_dir = 86 | = . 87 | packages = find_namespace: 88 | zip_safe = True 89 | include_package_data = True 90 | 91 | # These are required in actual runtime: 92 | install_requires = 93 | aiohttp 94 | anyio < 2.0.0 95 | click 96 | cryptography 97 | environ-config >= 19.1.0 98 | envparse 99 | gidgethub >= 4.2.0 100 | pyjwt[crypto] 101 | pyyaml 102 | sentry_sdk 103 | setuptools_scm 104 | 105 | [options.packages.find] 106 | where = . 107 | 108 | [options.extras_require] 109 | docs = 110 | sphinx 111 | sphinxcontrib-apidoc 112 | furo 113 | testing = 114 | pytest 115 | pytest-aiohttp 116 | pytest-cov 117 | pytest-xdist 118 | -------------------------------------------------------------------------------- /octomachinery/github/api/raw_client.py: -------------------------------------------------------------------------------- 1 | """A very low-level GitHub API client.""" 2 | 3 | from asyncio import iscoroutinefunction 4 | from typing import Any, Dict, Optional, Tuple, Union 5 | 6 | from gidgethub.abc import JSON_CONTENT_TYPE 7 | from gidgethub.aiohttp import GitHubAPI 8 | 9 | # pylint: disable=relative-beyond-top-level 10 | from .tokens import GitHubJWTToken, GitHubOAuthToken, GitHubToken 11 | from .utils import accept_preview_version, mark_uninitialized_in_repr 12 | 13 | 14 | @mark_uninitialized_in_repr 15 | class RawGitHubAPI(GitHubAPI): 16 | """A low-level GitHub API client with a pre-populated token.""" 17 | 18 | def __init__( 19 | self, 20 | token: GitHubToken, 21 | *, 22 | user_agent: Optional[str] = None, 23 | **kwargs: Any, 24 | ) -> None: 25 | """Initialize the GitHub client with token.""" 26 | self._token = token 27 | kwargs.pop('oauth_token', None) 28 | kwargs.pop('jwt', None) 29 | super().__init__( 30 | requester=user_agent, 31 | **kwargs, 32 | ) 33 | 34 | @property 35 | def is_initialized(self): 36 | """Return GitHub token presence.""" 37 | return self._token is not None 38 | 39 | def __repr__(self): 40 | """Render a class instance representation.""" 41 | cls_name = self.__class__.__name__ 42 | init_args = ( 43 | f'token={self._token!r}, ' 44 | f'session={self._session!r}, ' 45 | f'user_agent={self.requester!r}' 46 | ) 47 | return f'{cls_name}({init_args})' 48 | 49 | # pylint: disable=arguments-differ 50 | # pylint: disable=keyword-arg-before-vararg 51 | # pylint: disable=too-many-arguments 52 | async def _make_request( 53 | self, method: str, url: str, url_vars: Dict[str, str], 54 | data: Any, accept: Union[str, None] = None, 55 | jwt: Optional[str] = None, 56 | oauth_token: Optional[str] = None, 57 | content_type: str = JSON_CONTENT_TYPE, 58 | extra_headers: Optional[Dict[str, str]] = None, 59 | ) -> Tuple[bytes, Optional[str]]: 60 | token = self._token 61 | if iscoroutinefunction(token): 62 | token = await token() 63 | if isinstance(token, GitHubOAuthToken): 64 | oauth_token = str(token) 65 | jwt = None 66 | if isinstance(token, GitHubJWTToken): 67 | jwt = str(token) 68 | oauth_token = None 69 | optional_kwargs = { 70 | # NOTE: GidgetHub v5.3.0 introduced a new `extra_headers` argument 71 | # NOTE: in this private method and the public ones. Its default 72 | # NOTE: value is `None` in all cases so the only case when it's set 73 | # NOTE: is when the end-users call corresponding methods with it. 74 | # NOTE: And that would only be the case with modern versions of 75 | # NOTE: GidgetHub. Here, we rely on this side effect to only pass 76 | # NOTE: this value down the stack when the chances that GidgetHub 77 | # NOTE: is modern enough are close to 100%. 78 | 'extra_headers': extra_headers, 79 | } if extra_headers is not None else {} 80 | return await super()._make_request( 81 | method=method, 82 | url=url, 83 | url_vars=url_vars, 84 | data=data, 85 | accept=accept, 86 | oauth_token=oauth_token, 87 | jwt=jwt, 88 | content_type=content_type, 89 | **optional_kwargs, 90 | ) 91 | 92 | getitem = accept_preview_version(GitHubAPI.getitem) 93 | getiter = accept_preview_version(GitHubAPI.getiter) 94 | post = accept_preview_version(GitHubAPI.post) 95 | patch = accept_preview_version(GitHubAPI.patch) 96 | put = accept_preview_version(GitHubAPI.put) 97 | delete = accept_preview_version(GitHubAPI.delete) 98 | -------------------------------------------------------------------------------- /octomachinery/github/models/__init__.py: -------------------------------------------------------------------------------- 1 | """Models representing objects in GitHub API.""" 2 | 3 | import typing 4 | from datetime import datetime, timezone 5 | 6 | import attr 7 | 8 | from .utils import SecretStr, convert_datetime 9 | 10 | 11 | @attr.dataclass 12 | class GitHubAppInstallation: # pylint: disable=too-few-public-methods 13 | """ 14 | Represents a GitHub App installed into a user or an organization profile. 15 | 16 | It has its own ID for installation which is a unique combo of an app 17 | and a profile (user or org). 18 | """ 19 | 20 | id: int = attr.ib(converter=int) 21 | """Installation ID.""" 22 | app_id: int = attr.ib(converter=int) 23 | """GitHub App ID.""" 24 | app_slug: str = attr.ib(converter=str) 25 | """GitHub App slug.""" 26 | 27 | # FIXME: unignore once this is solved: # pylint: disable=fixme 28 | # https://github.com/python/mypy/issues/6172#issuecomment-515718727 29 | created_at: datetime = attr.ib( 30 | converter=convert_datetime, # type: ignore[misc] 31 | ) 32 | """Date time when the installation has been installed.""" 33 | updated_at: datetime = attr.ib( 34 | converter=convert_datetime, # type: ignore[misc] 35 | ) 36 | """Date time when the installation was last updated.""" 37 | 38 | account: typing.Dict[str, typing.Any] 39 | """Target account (org or user) where this GitHub App is installed into.""" 40 | events: typing.List[str] 41 | """List of webhook events the app will be receiving from the account.""" 42 | permissions: typing.Dict[str, typing.Any] 43 | """Permission levels of access to API endpoints types.""" 44 | repository_selection: str = attr.ib(converter=str) 45 | """Repository selection mode.""" 46 | single_file_name: typing.Optional[str] 47 | """File path the GitHub app controls.""" 48 | 49 | target_id: int = attr.ib(converter=int) 50 | """Target account ID where this GitHub App is installed into.""" 51 | target_type: str = attr.ib( 52 | validator=attr.validators.in_(('Organization', 'User')), 53 | ) 54 | """Target account type where this GitHub App is installed into.""" 55 | 56 | access_tokens_url: str = attr.ib(converter=str) 57 | """API endpoint to retrieve access token from.""" 58 | html_url: str = attr.ib(converter=str) 59 | """URL for controlling the GitHub App Installation.""" 60 | repositories_url: str = attr.ib(converter=str) 61 | """API endpoint listing repositories accissible by this Installation.""" 62 | 63 | suspended_at: typing.Optional[str] 64 | suspended_by: typing.Optional[str] 65 | 66 | has_multiple_single_files: typing.Optional[bool] 67 | single_file_paths: typing.List[str] 68 | 69 | 70 | @attr.dataclass 71 | class GitHubInstallationAccessToken: # pylint: disable=too-few-public-methods 72 | """Struct for installation access token response from GitHub API.""" 73 | 74 | token: SecretStr = attr.ib(converter=SecretStr) 75 | """Access token for GitHub App Installation.""" 76 | expires_at: datetime = attr.ib( 77 | converter=convert_datetime, # type: ignore[misc] 78 | ) 79 | """Token expiration time.""" 80 | permissions: typing.Dict[str, str] 81 | """Permission levels of access to API endpoints types.""" 82 | repository_selection: str = attr.ib(converter=str) 83 | """Repository selection mode.""" 84 | repositories: typing.List[typing.Dict[str, typing.Any]] = attr.ib( 85 | default=[], 86 | converter=list, 87 | ) 88 | """List of accessible repositories.""" 89 | single_file: typing.Optional[str] = attr.ib(default=None) 90 | """File path the GitHub app controls.""" 91 | 92 | has_multiple_single_files: typing.Optional[bool] = attr.ib(default=False) 93 | single_file_paths: typing.List[str] = attr.ib(default=None) 94 | 95 | @property 96 | def expired(self): 97 | """Check whether this token has expired already.""" 98 | return datetime.now(timezone.utc) > self.expires_at 99 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # `git blame` master ignore list. 2 | # 3 | # This file contains a list of git hashes of revisions to be ignored 4 | # by `git blame`. These revisions are considered "unimportant" in 5 | # that they are unlikely to be what you are interested in when blaming. 6 | # They are typically expected to be formatting-only changes. 7 | # 8 | # It can be used for `git blame` using `--ignore-revs-file` or by 9 | # setting `blame.ignoreRevsFile` in the `git config`[1]. 10 | # 11 | # Ignore these commits when reporting with blame. Calling 12 | # 13 | # git blame --ignore-revs-file .git-blame-ignore-revs 14 | # 15 | # will tell `git blame` to ignore changes made by these revisions when 16 | # assigning blame, as if the change never happened. 17 | # 18 | # You can enable this as a default for your local repository by 19 | # running 20 | # 21 | # git config blame.ignoreRevsFile .git-blame-ignore-revs 22 | # 23 | # This will probably be automatically picked by your IDE 24 | # (VSCode+GitLens and JetBrains products are confirmed to do this). 25 | # 26 | # Important: if you are switching to a branch without this file, 27 | # `git blame` will fail with an error. 28 | # 29 | # GitHub also excludes the commits listed below from its "Blame" 30 | # views[2][3]. 31 | # 32 | # [1]: https://git-scm.com/docs/git-blame#Documentation/git-blame.txt-blameignoreRevsFile 33 | # [2]: https://github.blog/changelog/2022-03-24-ignore-commits-in-the-blame-view-beta/ 34 | # [3]: https://docs.github.com/en/repositories/working-with-files/using-files/viewing-a-file#ignore-commits-in-the-blame-view 35 | # 36 | # Guidelines: 37 | # - Only large (generally automated) reformatting or renaming PRs 38 | # should be added to this list. Do not put things here just because 39 | # you feel they are trivial or unimportant. If in doubt, do not put 40 | # it on this list. 41 | # - When adding a single revision, use inline comment to link relevant 42 | # issue/PR. Alternatively, paste the commit title instead. 43 | # Example: 44 | # d4a8b7307acc2dc8a8833ccfa65426ad28b3ffc9 # https://github.com/sanitizers/octomachinery/issues/1 45 | # - When adding multiple revisions (like a bulk of work over many 46 | # commits), organize them in blocks. Precede each such block with a 47 | # comment starting with the word "START", followed by a link to the 48 | # relevant issue or PR. Add a similar comment after the last block 49 | # line but use the word "END", followed by the same link. 50 | # Alternatively, add or augment the link with a text motivation and 51 | # description of work performed in each commit. 52 | # After each individual commit in the block, add an inline comment 53 | # with the commit title line. 54 | # Example: 55 | # # START https://github.com/sanitizers/octomachinery/issues/1 56 | # 6f0bd2d8a1e6cd2e794cd39976e9756e0c85ac66 # Bulk-replace smile emojis with unicorns 57 | # d53974df11dbc22cbea9dc7dcbc9896c25979a27 # Replace double with single quotes 58 | # ... 59 | # # END https://github.com/sanitizers/octomachinery/issues/1 60 | # - Only put full 40-character hashes on this list (not short hashes 61 | # or any other revision reference). 62 | # - Append to the bottom of the file, regardless of the chronological 63 | # order of the revisions. Revisions within blocks should be in 64 | # chronological order from oldest to newest. 65 | # - Because you must use a hash, you need to append to this list in a 66 | # follow-up PR to the actual reformatting PR that you are trying to 67 | # ignore. This approach helps avoid issues with arbitrary rebases 68 | # and squashes while the pull request is in progress. 69 | 70 | 71 | 34dd2240adeb4d82e44a072dead0cc7197bc8f61 # A trailing comma reformatting caused by d3bee014f474d502d330c1c4919046c9714a6e4c ⇪ Bump add-trailing-comma @ pre-commit to v2.5.1 72 | 73 | # START yamllint-driven YAML formatting for consistency 74 | 8365edc0ef8e898aad55dab1a78b7dc2e4f4ee97 # 🎨 Fix yamllint leading space @ comment warnings 75 | 762437c1193a6b54c739edf4e62a999badbf9d75 # 🎨 Add YAML document start marker @ RTD config 76 | a7b255a6d44aa70dbb4062f76c7722c9dc02d0e7 # 🎨 Add YAML document end marker @ RTD config 77 | b1209aaa51e25e8b6ea88aa1682d1475382f31e7 # 🎨 Keep line length short @ GHA config 78 | # END yamllint-driven YAML formatting for consistency 79 | -------------------------------------------------------------------------------- /octomachinery/utils/asynctools.py: -------------------------------------------------------------------------------- 1 | """Asynchronous tools set.""" 2 | 3 | from functools import wraps 4 | from inspect import signature as _inspect_signature 5 | from logging import getLogger as _get_logger 6 | from operator import itemgetter 7 | 8 | from anyio import create_queue 9 | from anyio import create_task_group as all_subtasks_awaited 10 | 11 | 12 | logger = _get_logger(__name__) 13 | 14 | 15 | def auto_cleanup_aio_tasks(async_func): 16 | """Ensure all subtasks finish.""" 17 | @wraps(async_func) 18 | async def async_func_wrapper(*args, **kwargs): 19 | async with all_subtasks_awaited(): 20 | return await async_func(*args, **kwargs) 21 | return async_func_wrapper 22 | 23 | 24 | async def _send_task_res_to_q(res_q, task_id, aio_task): 25 | """Await task and put its result to the queue.""" 26 | try: 27 | task_res = await aio_task 28 | except (BaseException, Exception) as exc: 29 | task_res = exc 30 | raise 31 | finally: 32 | # pylint: disable-next=used-before-assignment # <-- false-positive 33 | await res_q.put((task_id, task_res)) 34 | 35 | 36 | async def _aio_gather_iter_pairs(*aio_tasks): 37 | """Spawn async tasks and yield with pairs of ids with results.""" 38 | aio_tasks_num = len(aio_tasks) 39 | task_res_q = create_queue(aio_tasks_num) 40 | 41 | async with all_subtasks_awaited() as task_group: 42 | for task_id, task in enumerate(aio_tasks): 43 | await task_group.spawn( 44 | _send_task_res_to_q, 45 | task_res_q, 46 | task_id, task, 47 | ) 48 | 49 | for _ in range(aio_tasks_num): 50 | yield await task_res_q.get() 51 | 52 | 53 | async def aio_gather_iter(*aio_tasks): 54 | """Spawn async tasks and yield results.""" 55 | async for _task_id, task_res in _aio_gather_iter_pairs(*aio_tasks): 56 | yield task_res 57 | 58 | 59 | async def aio_gather(*aio_tasks): 60 | """Spawn async tasks and return results in the same order.""" 61 | result_pairs_gen = [_r async for _r in _aio_gather_iter_pairs(*aio_tasks)] 62 | sorted_result_pairs = sorted(result_pairs_gen, key=itemgetter(0)) 63 | all_task_results = map(itemgetter(1), sorted_result_pairs) 64 | return tuple(all_task_results) 65 | 66 | 67 | async def try_await(potentially_awaitable): 68 | """Try awaiting the arg and return it regardless.""" 69 | valid_exc_str = ( 70 | "can't be used in 'await' expression" 71 | ) 72 | 73 | try: 74 | return await potentially_awaitable 75 | except TypeError as type_err: 76 | type_err_msg = str(type_err) 77 | if not ( 78 | type_err_msg.startswith('object ') 79 | and type_err_msg.endswith(valid_exc_str) 80 | ): 81 | raise 82 | 83 | return potentially_awaitable 84 | 85 | 86 | async def amap(callback, async_iterable): 87 | """Map asynchronous generator with a coroutine or a function.""" 88 | async for async_value in async_iterable: 89 | yield await try_await(callback(async_value)) 90 | 91 | 92 | def dict_to_kwargs_cb(callback): 93 | """Return a callback mapping dict to keyword arguments.""" 94 | cb_arg_names = set(_inspect_signature(callback).parameters.keys()) 95 | 96 | async def callback_wrapper(args_dict): 97 | excessive_arg_names = set(args_dict.keys()) - cb_arg_names 98 | filtered_args_dict = { 99 | arg_name: arg_value for arg_name, arg_value in args_dict.items() 100 | if arg_name not in excessive_arg_names 101 | } if excessive_arg_names else args_dict 102 | if excessive_arg_names: 103 | logger.warning( 104 | 'Excessive arguments passed to callback %(callable)s', 105 | {'callable': callback}, 106 | extra={ 107 | 'callable': callback, 108 | 'excessive-arg-names': excessive_arg_names, 109 | 'passed-in-args': args_dict, 110 | 'forwarded-args': filtered_args_dict, 111 | }, 112 | ) 113 | return await try_await(callback(**filtered_args_dict)) 114 | return callback_wrapper 115 | -------------------------------------------------------------------------------- /tests/github/models/private_key_test.py: -------------------------------------------------------------------------------- 1 | """Tests for GitHub private key class.""" 2 | import random 3 | import re 4 | from datetime import date 5 | from pathlib import Path 6 | 7 | import pytest 8 | 9 | from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat 10 | from jwt import decode as parse_jwt 11 | 12 | from octomachinery.github.models.private_key import GitHubPrivateKey 13 | 14 | 15 | @pytest.fixture 16 | def rsa_public_key(rsa_private_key): 17 | """Extract a public key out of private one.""" 18 | return rsa_private_key.public_key() 19 | 20 | 21 | @pytest.fixture 22 | def rsa_public_key_bytes(rsa_public_key) -> bytes: 23 | """Return a PKCS#1 formatted RSA public key encoded as PEM.""" 24 | return rsa_public_key.public_bytes( 25 | encoding=Encoding.PEM, 26 | format=PublicFormat.PKCS1, 27 | ) 28 | 29 | 30 | @pytest.fixture 31 | def github_private_key(rsa_private_key_bytes: bytes) -> GitHubPrivateKey: 32 | """Construct a test instance of ``GitHubPrivateKey``.""" 33 | return GitHubPrivateKey(rsa_private_key_bytes) 34 | 35 | 36 | @pytest.fixture 37 | def rsa_private_key_path( 38 | rsa_private_key_bytes: bytes, 39 | tmp_path_factory, 40 | ) -> Path: 41 | """Save the private key to disk as PEM file and provide its path.""" 42 | tmp_dir = tmp_path_factory.mktemp('github_private_key') 43 | private_key_filename = ( 44 | f'test-github-app.{date.today():%Y-%m-%d}' 45 | '.private-key.pem' 46 | ) 47 | private_key_path = tmp_dir / private_key_filename 48 | private_key_path.write_bytes(rsa_private_key_bytes) 49 | return private_key_path 50 | 51 | 52 | def test_github_private_key__from_file( 53 | github_private_key, 54 | rsa_private_key_path: Path, 55 | ): 56 | """Test that GitHubPrivateKey from file and bytes are the same.""" 57 | key_from_file = GitHubPrivateKey.from_file(rsa_private_key_path) 58 | assert key_from_file == github_private_key 59 | 60 | 61 | def test_github_private_key____repr__(github_private_key): 62 | """Verify what repr protocol only exposes fingerprint.""" 63 | repr_pattern = re.compile( 64 | r"^'\)\s" 65 | r'with\sSHA\-1\sfingerprint\s' 66 | r"'[a-f0-9]{2}(:[a-f0-9]{2}){19}'>$", 67 | ) 68 | assert repr_pattern.match(repr(github_private_key)) 69 | assert repr_pattern.match(f'{github_private_key!r}') 70 | 71 | 72 | def test_github_private_key____str__(github_private_key): 73 | """Verify that the string protocol doesn't expose secrets.""" 74 | escaped_private_key_repr = ( 75 | repr(github_private_key). 76 | replace('(', r'\('). 77 | replace(')', r'\)') 78 | ) 79 | exception_message = ( 80 | " " 81 | 'objects do not implement the string protocol ' 82 | 'for security reasons. ' 83 | f'The repr of this instance is {escaped_private_key_repr!s}.' 84 | ) 85 | with pytest.raises(TypeError, match=exception_message): 86 | str(github_private_key) 87 | with pytest.raises(TypeError, match=exception_message): 88 | f'{github_private_key!s}' # pylint: disable=pointless-statement 89 | 90 | 91 | def test_github_private_key__make_jwt_for( 92 | github_private_key: GitHubPrivateKey, 93 | rsa_public_key_bytes, 94 | ): 95 | """Verify that e2e encoding-decoding of the JWT works.""" 96 | github_app_id = random.randint(0, 9999999) 97 | jwt_string = github_private_key.make_jwt_for(app_id=github_app_id) 98 | payload = parse_jwt( 99 | jwt_string.encode('utf-8'), rsa_public_key_bytes, algorithms='RS256', 100 | ) 101 | assert payload['iss'] == github_app_id 102 | assert payload['exp'] - payload['iat'] == 60 103 | 104 | 105 | def test_github_private_key__make_jwt_for__invalid_timeout(github_private_key): 106 | """Verify that time offset can't exceed 10 mins.""" 107 | github_app_id = random.randint(0, 9999999) 108 | with pytest.raises( 109 | ValueError, 110 | match='The time offset must be less than 10 minutes', 111 | ): 112 | github_private_key.make_jwt_for(app_id=github_app_id, time_offset=601) 113 | -------------------------------------------------------------------------------- /tests/github/models/utils_test.py: -------------------------------------------------------------------------------- 1 | """Tests for GitHub models utility functions.""" 2 | 3 | from datetime import datetime, timezone 4 | from functools import partial 5 | 6 | import pytest 7 | 8 | from octomachinery.github.models.utils import ( 9 | SecretStr, SuperSecretStr, convert_datetime, 10 | ) 11 | 12 | 13 | # pylint: disable=invalid-name 14 | utc_datetime = partial(datetime, tzinfo=timezone.utc) 15 | 16 | 17 | @pytest.mark.parametrize( 18 | 'secret_class,secret_placeholder,visible_first_repr', 19 | ( 20 | (SecretStr, '', True), 21 | (SuperSecretStr, '', False), 22 | ), 23 | ) 24 | def test_secret_sanitizers_first_repr( 25 | secret_class, secret_placeholder, visible_first_repr, 26 | ): 27 | """Check that immediate repr is rendered correctly.""" 28 | secret_data = 'qwerty' 29 | super_secret_string = secret_class(secret_data) 30 | 31 | expected_repr = ( 32 | repr(secret_data) if visible_first_repr 33 | else secret_placeholder 34 | ) 35 | assert repr(super_secret_string) == expected_repr 36 | 37 | 38 | @pytest.mark.parametrize( 39 | 'secret_class,secret_placeholder', 40 | ( 41 | (SecretStr, ''), 42 | (SuperSecretStr, ''), 43 | ), 44 | ) 45 | def test_secret_sanitizers(secret_class, secret_placeholder): 46 | """Check that sanitizer classes hide data when needed.""" 47 | secret_data = 'qwerty' 48 | super_secret_string = secret_class(secret_data) 49 | assert str(super_secret_string) == str(secret_data) 50 | assert super_secret_string == secret_data 51 | 52 | class _DataStruct: # pylint: disable=too-few-public-methods 53 | def __init__(self, s, o=None): 54 | self._s = s 55 | self._o = o 56 | 57 | def __repr__(self): 58 | return ( 59 | f'{self.__class__.__name__}' 60 | f'(s={repr(self._s)}, o={repr(self._o)})' 61 | ) 62 | 63 | open_data = 'gov' 64 | data_struct = _DataStruct(super_secret_string, open_data) 65 | 66 | expected_data_struct_repr = ( 67 | f'_DataStruct(s={secret_placeholder}, o={repr(open_data)})' 68 | ) 69 | assert repr(data_struct) == expected_data_struct_repr 70 | 71 | sub_data_struct = _DataStruct(data_struct, secret_class(None)) 72 | 73 | expected_nested_data_struct_repr = ( 74 | f'_DataStruct(s=_DataStruct(s={secret_placeholder}, ' 75 | f'o={repr(open_data)}), o={secret_placeholder})' 76 | ) 77 | assert repr(sub_data_struct) == expected_nested_data_struct_repr 78 | 79 | 80 | @pytest.mark.parametrize( 81 | 'input_date_string,expected_date_object', 82 | ( 83 | (1556634127, utc_datetime(2019, 4, 30, 14, 22, 7)), 84 | (0, utc_datetime(1970, 1, 1, 0, 0)), 85 | ('2032-01-02T05:28:47Z', utc_datetime(2032, 1, 2, 5, 28, 47)), 86 | ('2032-01-02T05:28:47Z+00:00', utc_datetime(2032, 1, 2, 5, 28, 47)), 87 | ( 88 | '2032-01-02T05:28:47.000Z+00:00', 89 | utc_datetime(2032, 1, 2, 5, 28, 47), 90 | ), 91 | ( 92 | '2032-01-02T05:28:47.000000Z+00:00', 93 | utc_datetime(2032, 1, 2, 5, 28, 47), 94 | ), 95 | ('2032-01-02T05:28:47.000Z', utc_datetime(2032, 1, 2, 5, 28, 47)), 96 | ('2032-01-02T05:28:47.000000Z', utc_datetime(2032, 1, 2, 5, 28, 47)), 97 | ), 98 | ) 99 | def test_convert_datetime(input_date_string, expected_date_object): 100 | """Test that convert_datetime recognizes supported date formats.""" 101 | assert convert_datetime(input_date_string) == expected_date_object 102 | 103 | 104 | @pytest.mark.parametrize( 105 | 'input_date_string', 106 | ( 107 | None, 108 | float('NaN'), 109 | float('Inf'), 110 | -float('Inf'), 111 | object(), 112 | {}, 113 | set(), 114 | [], 115 | frozenset(), 116 | (), 117 | ), 118 | ) 119 | def test_convert_datetime_negative(input_date_string): 120 | """Test that convert_datetime errors out on supported date input.""" 121 | with pytest.raises( 122 | ValueError, 123 | match=r'^The input arg type .* is not supported$', 124 | ): 125 | convert_datetime(input_date_string) 126 | 127 | 128 | def test_convert_datetime_empty_string(): 129 | """Test that convert_datetime errors out on supported date input.""" 130 | with pytest.raises( 131 | ValueError, 132 | match=r'^The input arg .* is unsupported$', 133 | ): 134 | convert_datetime('') 135 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/banner-direct.svg 2 | :target: https://github.com/vshymanskyy/StandWithUkraine/blob/main/docs/README.md 3 | :alt: SWUbanner 4 | 5 | .. image:: https://img.shields.io/pypi/v/octomachinery.svg?logo=Python&logoColor=white 6 | :target: https://pypi.org/project/octomachinery 7 | :alt: octomachinery @ PyPI 8 | 9 | .. image:: https://tidelift.com/badges/package/pypi/octomachinery 10 | :target: https://tidelift.com/subscription/pkg/pypi-octomachinery?utm_source=pypi-octomachinery&utm_medium=readme 11 | :alt: octomachinery is available as part of the Tidelift Subscription 12 | 13 | .. image:: https://github.com/sanitizers/octomachinery/actions/workflows/ci-cd.yml/badge.svg?event=push 14 | :target: https://github.com/sanitizers/octomachinery/actions/workflows/ci-cd.yml?query=event:push 15 | :alt: GitHub Actions CI/CD workflows status 16 | 17 | .. image:: https://img.shields.io/matrix/octomachinery:matrix.org?label=Discuss%20on%20Matrix%20at%20%23octomachinery%3Amatrix.org&logo=matrix&server_fqdn=matrix.org&style=flat 18 | :target: https://matrix.to/#/%23octomachinery:matrix.org 19 | :alt: Matrix Room — #octomachinery:matrix.org 20 | 21 | .. image:: https://img.shields.io/matrix/pyba:matrix.org?label=Discuss%20on%20Matrix%20at%20%23pyba%3Amatrix.org&logo=matrix&server_fqdn=matrix.org&style=flat 22 | :target: https://matrix.to/#/%23pyba:matrix.org 23 | :alt: Matrix Space — #pyba:matrix.org 24 | 25 | .. DO-NOT-REMOVE-docs-badges-END 26 | 27 | .. image:: https://img.shields.io/readthedocs/octomachinery/latest.svg?logo=Read%20The%20Docs&logoColor=white 28 | :target: https://docs.octomachinery.dev/en/latest/?badge=latest 29 | :alt: Documentation Status 30 | 31 | octomachinery: Bots Without Boilerplate 32 | ======================================= 33 | 34 | Invisible engine driving octobot machines. Simple, yet powerful. 35 | 36 | Web-site @ https://octomachinery.dev. Stay tuned! 37 | 38 | .. DO-NOT-REMOVE-docs-intro-START 39 | 40 | **How-to create a GitHub Bot tutorial** is ready for preview 41 | @ `tutorial.octomachinery.dev 42 | `_ 43 | 44 | Elevator pitch 45 | -------------- 46 | 47 | Here's how you 👍 a just-created comment: 48 | 49 | .. code:: python 50 | 51 | from octomachinery.app.server.runner import run as run_app 52 | from octomachinery.routing import process_event_actions 53 | from octomachinery.routing.decorators import process_webhook_payload 54 | from octomachinery.runtime.context import RUNTIME_CONTEXT 55 | 56 | 57 | @process_event_actions('issue_comment', {'created'}) 58 | @process_webhook_payload 59 | async def on_comment( 60 | *, 61 | action, issue, comment, 62 | repository=None, sender=None, 63 | installation=None, 64 | assignee=None, changes=None, 65 | ): 66 | github_api = RUNTIME_CONTEXT.app_installation_client 67 | comment_reactions_api_url = f'{comment["url"]}/reactions' 68 | await github_api.post( 69 | comment_reactions_api_url, 70 | preview_api_version='squirrel-girl', 71 | data={'content': '+1'}, 72 | ) 73 | 74 | 75 | run_app( 76 | name='Thumbs-Up-Bot', 77 | version='1.0.0', 78 | url='https://github.com/apps/thuuuuuuuuuuuuuumbs-uuuuuuuuuuuup', 79 | ) 80 | 81 | Prerequisites 82 | ------------- 83 | 84 | Python 3.7+ 85 | 86 | Contribute octomachinery 87 | ------------------------ 88 | 89 | **Want to add something to upstream?** Feel free to submit a PR or file 90 | an issue if unsure. 91 | Note that PR is more likely to be accepted if it includes tests and 92 | detailed description helping maintainers to understand it better 🎉 93 | 94 | Oh, and be pythonic, please 🐍 95 | 96 | **Don't know how?** Check out `How to Contribute to Open Source 97 | `_ article by GitHub 🚀 98 | 99 | License 100 | ------- 101 | 102 | The source code and the documentation in this project are released under 103 | the `GPL v3 license`_. 104 | 105 | .. _`GPL v3 license`: 106 | https://github.com/sanitizers/octomachinery/blob/master/LICENSE 107 | 108 | For Enterprise 109 | -------------- 110 | 111 | octomachinery is available as part of the Tidelift Subscription. 112 | 113 | The octomachinery maintainers and the maintainers of thousands of other packages 114 | are working with Tidelift to deliver one enterprise subscription that covers 115 | all of the open source you use. 116 | 117 | `Learn more `_. 118 | -------------------------------------------------------------------------------- /octomachinery/github/utils/event_utils.py: -------------------------------------------------------------------------------- 1 | """Utility helpers for CLI.""" 2 | 3 | import contextlib 4 | import itertools 5 | import json 6 | from uuid import UUID, uuid4 7 | 8 | import multidict 9 | 10 | import yaml 11 | 12 | 13 | def _probe_yaml(event_file_fd): 14 | try: 15 | http_headers, event, extra = itertools.islice( 16 | itertools.chain( 17 | yaml.safe_load_all(event_file_fd), 18 | (None,) * 3, 19 | ), 20 | 3, 21 | ) 22 | except yaml.parser.ParserError as yaml_err: 23 | raise ValueError('YAML file is not valid') from yaml_err 24 | finally: 25 | event_file_fd.seek(0) 26 | 27 | if extra is not None: 28 | raise ValueError('YAML file must only contain 1–2 documents') 29 | 30 | if event is None: 31 | event = http_headers 32 | http_headers = () 33 | 34 | if event is None: 35 | raise ValueError('YAML file must contain 1–2 non-empty documents') 36 | 37 | return http_headers, event 38 | 39 | 40 | def _probe_jsonl(event_file_fd): 41 | event = None 42 | 43 | first_line = event_file_fd.readline() 44 | second_line = event_file_fd.readline() 45 | third_line = event_file_fd.readline() 46 | event_file_fd.seek(0) 47 | 48 | if third_line: 49 | raise ValueError('JSONL file must only contain 1–2 JSON lines') 50 | 51 | http_headers = json.loads(first_line) 52 | 53 | with contextlib.suppress(ValueError): 54 | event = json.loads(second_line) 55 | 56 | if event is None: 57 | event = http_headers 58 | http_headers = () 59 | 60 | return http_headers, event 61 | 62 | 63 | def _probe_json(event_file_fd): 64 | event = json.load(event_file_fd) 65 | event_file_fd.seek(0) 66 | 67 | if not isinstance(event, dict): 68 | raise ValueError('JSON file must only contain an object mapping') 69 | 70 | http_headers = () 71 | 72 | return http_headers, event 73 | 74 | 75 | def _parse_fd_content(event_file_fd): 76 | """Guess file content type and read event with HTTP headers.""" 77 | for event_reader in _probe_yaml, _probe_jsonl, _probe_json: 78 | with contextlib.suppress(ValueError): 79 | return event_reader(event_file_fd) 80 | 81 | raise ValueError( 82 | 'The input event VCR file has invalid structure. ' 83 | 'It must be either of YAML, JSONL or JSON.', 84 | ) 85 | 86 | 87 | def _transform_http_headers_list_to_multidict(headers): 88 | if isinstance(headers, dict): 89 | raise ValueError( 90 | 'Headers must be a sequence of mappings because keys can repeat', 91 | ) 92 | return multidict.CIMultiDict(next(iter(h.items()), ()) for h in headers) 93 | 94 | 95 | def parse_event_stub_from_fd(event_file_fd): 96 | """Read event with HTTP headers as CIMultiDict instance.""" 97 | http_headers, event = _parse_fd_content(event_file_fd) 98 | return _transform_http_headers_list_to_multidict(http_headers), event 99 | 100 | 101 | def validate_http_headers(headers): 102 | """Verify that HTTP headers look sane.""" 103 | if headers['content-type'] != 'application/json': 104 | raise ValueError("Content-Type must be 'application/json'") 105 | 106 | if not headers['user-agent'].startswith('GitHub-Hookshot/'): 107 | raise ValueError("User-Agent must start with 'GitHub-Hookshot/'") 108 | 109 | x_gh_delivery_exc = ValueError('X-GitHub-Delivery must be of type UUID4') 110 | try: 111 | x_gh_delivery_uuid = UUID(headers['x-github-delivery']) 112 | except ValueError as val_err: 113 | raise x_gh_delivery_exc from val_err 114 | if x_gh_delivery_uuid.version != 4: 115 | raise x_gh_delivery_exc 116 | 117 | if not isinstance(headers['x-github-event'], str): 118 | raise ValueError('X-GitHub-Event must be a string') 119 | 120 | 121 | def augment_http_headers(headers): 122 | """Add fake HTTP headers for the missing positions.""" 123 | fake_headers = make_http_headers_from_event(headers['x-github-event']) 124 | 125 | if 'content-type' not in headers: 126 | headers['content-type'] = fake_headers['content-type'] 127 | 128 | if 'user-agent' not in headers: 129 | headers['user-agent'] = fake_headers['user-agent'] 130 | 131 | if 'x-github-delivery' not in headers: 132 | headers['x-github-delivery'] = fake_headers['x-github-delivery'] 133 | 134 | return headers 135 | 136 | 137 | def make_http_headers_from_event(event_name): 138 | """Generate fake HTTP headers with the given event name.""" 139 | return multidict.CIMultiDict({ 140 | 'content-type': 'application/json', 141 | 'user-agent': 'GitHub-Hookshot/fallback-value', 142 | 'x-github-delivery': str(uuid4()), 143 | 'x-github-event': event_name, 144 | }) 145 | -------------------------------------------------------------------------------- /octomachinery/app/server/machinery.py: -------------------------------------------------------------------------------- 1 | """Web-server constructors.""" 2 | 3 | import functools 4 | import logging 5 | from typing import Union 6 | 7 | import anyio 8 | from aiohttp import web 9 | from aiohttp.client import ClientSession 10 | 11 | # pylint: disable=relative-beyond-top-level 12 | from ...github.api.app_client import GitHubApp 13 | # pylint: disable=relative-beyond-top-level 14 | from ...utils.asynctools import auto_cleanup_aio_tasks 15 | # pylint: disable=relative-beyond-top-level 16 | from ..routing.webhooks_dispatcher import route_github_webhook_event 17 | 18 | 19 | logger = logging.getLogger(__name__) 20 | 21 | 22 | async def start_tcp_site( 23 | server_config, aiohttp_server_runner: web.ServerRunner, 24 | ) -> web.TCPSite: 25 | """Return initialized and listening TCP site.""" 26 | host, port = server_config.host, server_config.port 27 | aiohttp_tcp_site = web.TCPSite(aiohttp_server_runner, host, port) 28 | await aiohttp_tcp_site.start() 29 | logger.info( 30 | ' Serving on %s '.center(50, '='), 31 | aiohttp_tcp_site.name, 32 | ) 33 | return aiohttp_tcp_site 34 | 35 | 36 | async def get_server_runner(http_handler): 37 | """Initialize server runner.""" 38 | aiohttp_server = web.Server(http_handler) 39 | aiohttp_server_runner = web.ServerRunner( 40 | aiohttp_server, 41 | # handle SIGTERM and SIGINT 42 | # by raising aiohttp.web_runner.GracefulExit exception 43 | handle_signals=True, 44 | ) 45 | await aiohttp_server_runner.setup() 46 | return aiohttp_server_runner 47 | 48 | 49 | async def _prepare_github_app(github_app): 50 | """Set GitHub App in the context.""" 51 | logger.info('Starting the following GitHub App:') 52 | logger.info( 53 | '* app id: %s', 54 | github_app._config.app_id, # pylint: disable=protected-access 55 | ) 56 | logger.info( 57 | '* private key SHA-1 fingerprint: %s', 58 | # pylint: disable=protected-access 59 | github_app._config.private_key.fingerprint, 60 | ) 61 | logger.info( 62 | '* user agent: %s', 63 | github_app._config.user_agent, # pylint: disable=protected-access 64 | ) 65 | await github_app.log_installs_list() 66 | 67 | 68 | async def _launch_web_server_and_wait_until_it_stops( 69 | web_server_config, 70 | github_app: GitHubApp, 71 | webhook_secret: Union[str, None] = None, 72 | ) -> None: 73 | """Start a web server. 74 | 75 | And then block until SIGINT comes in. 76 | """ 77 | aiohttp_server_runner = await setup_server_runner( 78 | github_app, webhook_secret, 79 | ) 80 | aiohttp_tcp_site = await start_tcp_site( 81 | web_server_config, aiohttp_server_runner, 82 | ) 83 | await _stop_site_on_cancel(aiohttp_tcp_site) 84 | 85 | 86 | async def setup_server_runner( 87 | github_app: GitHubApp, 88 | webhook_secret: Union[str, None] = None, 89 | ) -> web.ServerRunner: 90 | """Return a server runner with a webhook dispatcher set up.""" 91 | return await get_server_runner( 92 | functools.partial( 93 | route_github_webhook_event, 94 | github_app=github_app, 95 | webhook_secret=webhook_secret, 96 | ), 97 | ) 98 | 99 | 100 | async def _stop_site_on_cancel(aiohttp_tcp_site): 101 | """Stop the server after SIGINT.""" 102 | try: 103 | await anyio.sleep(float('inf')) 104 | except anyio.get_cancelled_exc_class(): 105 | logger.info(' Stopping the server '.center(50, '=')) 106 | await aiohttp_tcp_site.stop() 107 | 108 | 109 | def log_webhook_secret_status(webhook_secret): 110 | """Log HTTP body signature verification behavior.""" 111 | webhook_secret_repr = ( 112 | f' ({webhook_secret[:1]}...{webhook_secret[-1:]})' 113 | if webhook_secret else '' 114 | ) 115 | logger.info( 116 | 'Webhook secret%s is [%sSET]: %s', 117 | webhook_secret_repr, 118 | '' if webhook_secret else 'NOT ', 119 | 'SIGNATURE VERIFICATION WILL BE ENFORCED' 120 | if webhook_secret else 'SIGNED WEBHOOKS WILL BE REJECTED', 121 | ) 122 | 123 | 124 | @auto_cleanup_aio_tasks 125 | async def run_forever(config, event_routers): 126 | """Spawn an HTTP server in anyio context.""" 127 | logger.debug('The GitHub App env is set to `%s`', config.runtime.env) 128 | log_webhook_secret_status(config.github.webhook_secret) 129 | async with ClientSession() as aiohttp_client_session: 130 | github_app = GitHubApp( 131 | config.github, 132 | http_session=aiohttp_client_session, 133 | event_routers=event_routers, 134 | ) 135 | await _prepare_github_app(github_app) 136 | await _launch_web_server_and_wait_until_it_stops( 137 | config.server, github_app, config.github.webhook_secret, 138 | ) 139 | -------------------------------------------------------------------------------- /octomachinery/github/models/private_key.py: -------------------------------------------------------------------------------- 1 | """Private key container.""" 2 | from hashlib import sha1 as compute_sha1_hash 3 | from pathlib import Path 4 | from time import time 5 | 6 | from cryptography.hazmat.backends import default_backend 7 | from cryptography.hazmat.primitives.serialization import ( 8 | Encoding, PublicFormat, load_pem_private_key, 9 | ) 10 | 11 | from ._compat import compute_jwt 12 | 13 | 14 | def extract_private_key_sha1_fingerprint(rsa_private_key): 15 | r"""Retrieve the private key SHA-1 fingerprint. 16 | 17 | :param rsa_private_key: private key object 18 | :type rsa_private_key: cryptography.hazmat.primitives.asymmetric.\ 19 | rsa.RSAPrivateKey 20 | 21 | :returns: colon-separated SHA-1 fingerprint 22 | :rtype: str 23 | """ 24 | rsa_public_key = rsa_private_key.public_key() 25 | b_rsa_public_key = rsa_public_key.public_bytes( 26 | Encoding.DER, 27 | PublicFormat.SubjectPublicKeyInfo, 28 | ) 29 | rsa_public_key_sha1_fingerprint = compute_sha1_hash( 30 | b_rsa_public_key, 31 | ).hexdigest() 32 | 33 | def emit_chunks(sequence, step): 34 | start_pos = 0 35 | seq_length = len(sequence) 36 | while start_pos < seq_length: 37 | end_pos = start_pos + step 38 | yield sequence[start_pos: end_pos] 39 | start_pos = end_pos 40 | 41 | return ':'.join( 42 | emit_chunks(rsa_public_key_sha1_fingerprint, 2), 43 | ) 44 | 45 | 46 | class GitHubPrivateKey: 47 | """Private key entity with a pre-calculated SHA-1 fingerprint. 48 | 49 | :param bytes b_raw_data: the contents of a PEM file 50 | """ 51 | 52 | def __init__(self, b_raw_data: bytes): 53 | """Initialize GitHubPrivateKey instance.""" 54 | self._rsa_private_key = load_pem_private_key( 55 | b_raw_data, 56 | password=None, 57 | backend=default_backend(), 58 | ) 59 | self._col_separated_rsa_public_key_sha1_fingerprint = ( 60 | extract_private_key_sha1_fingerprint(self._rsa_private_key) 61 | ) 62 | 63 | @property 64 | def fingerprint(self) -> str: 65 | """Colon-separated SHA-1 fingerprint string value. 66 | 67 | :returns: colon-separated SHA-1 fingerprint 68 | :rtype: str 69 | """ 70 | return self._col_separated_rsa_public_key_sha1_fingerprint 71 | 72 | def __str__(self): 73 | """Avoid leaking private key contents via string protocol. 74 | 75 | :raises TypeError: always 76 | """ 77 | raise TypeError( 78 | f'{type(self)} objects do not implement the string protocol ' 79 | 'for security reasons. ' 80 | f'The repr of this instance is {self!r}.', 81 | ) 82 | 83 | def __repr__(self): 84 | r"""Construct a GitHubPrivateKey object representation. 85 | 86 | :returns: GitHubPrivateKey object representation \ 87 | with its SHA-1 fingerprint 88 | :rtype: str 89 | """ 90 | return ( 91 | "') " 92 | f"with SHA-1 fingerprint '{self.fingerprint}'>" 93 | ) 94 | 95 | def __eq__(self, other_private_key): 96 | r"""Compare equality of our private key with other. 97 | 98 | :returns: the result of comparison with another \ 99 | ``GitHubPrivateKey`` instance 100 | :rtype: bool 101 | """ 102 | return self.matches_fingerprint(other_private_key.fingerprint) 103 | 104 | def matches_fingerprint(self, other_hash): 105 | """Compare our SHA-1 fingerprint with ``other_hash``. 106 | 107 | :returns: the result of own fingerprint comparison with ``other_hash`` 108 | :rtype: bool 109 | """ 110 | return self.fingerprint == other_hash 111 | 112 | @classmethod 113 | def from_file(cls, path): 114 | r"""Construct a ``GitHubPrivateKey`` instance. 115 | 116 | :returns: the ``GitHubPrivateKey`` instance \ 117 | constructed of the target file contents 118 | :rtype: GitHubPrivateKey 119 | """ 120 | return cls(Path(path).expanduser().read_bytes()) 121 | 122 | def make_jwt_for(self, *, app_id: int, time_offset: int = 60) -> str: 123 | r"""Generate app's JSON Web Token. 124 | 125 | :param int app_id: numeric ID of a GitHub App 126 | :param int time_offset: duration of the JWT's validity, in seconds, \ 127 | defaults to 60 128 | 129 | :returns: JWT string for a GitHub App valid for the given time 130 | :rtype: str 131 | 132 | :raises ValueError: if time_offset exceeds 600 seconds (10 minutes) 133 | """ 134 | ten_min = 60 * 10 135 | if time_offset > ten_min: 136 | raise ValueError('The time offset must be less than 10 minutes') 137 | 138 | now = int(time()) 139 | payload = { 140 | 'iat': now, 141 | 'exp': now + time_offset, 142 | 'iss': app_id, 143 | } 144 | 145 | return compute_jwt( 146 | payload, 147 | key=self._rsa_private_key, 148 | algorithm='RS256', 149 | ) 150 | -------------------------------------------------------------------------------- /octomachinery/app/routing/webhooks_dispatcher.py: -------------------------------------------------------------------------------- 1 | """GitHub webhook events dispatching logic.""" 2 | 3 | import asyncio 4 | import logging 5 | import typing 6 | from functools import wraps 7 | from http import HTTPStatus 8 | 9 | from aiohttp import web 10 | from gidgethub import BadRequest, ValidationFailure 11 | from gidgethub.sansio import validate_event as validate_webhook_payload 12 | 13 | # pylint: disable=relative-beyond-top-level,import-error 14 | from ...github.models.events import GidgetHubWebhookEvent 15 | # pylint: disable=relative-beyond-top-level,import-error 16 | from ...routing.webhooks_dispatcher import route_github_event 17 | 18 | 19 | __all__ = ('route_github_webhook_event',) 20 | 21 | 22 | logger = logging.getLogger(__name__) 23 | 24 | 25 | EVENT_LOG_TMPL = ( 26 | 'Got a{}valid X-GitHub-Event=%s ' 27 | 'with X-GitHub-Delivery=%s ' 28 | 'and X-Hub-Signature=%s' 29 | ) 30 | 31 | EVENT_INVALID_CHUNK = 'n in' 32 | EVENT_VALID_CHUNK = ' ' 33 | 34 | EVENT_LOG_VALID_MSG = EVENT_LOG_TMPL.format(EVENT_VALID_CHUNK) 35 | EVENT_LOG_INVALID_MSG = EVENT_LOG_TMPL.format(EVENT_INVALID_CHUNK) 36 | 37 | 38 | async def get_trusted_http_payload(request, webhook_secret): 39 | """Get a verified HTTP request body from request.""" 40 | http_req_headers = request.headers 41 | is_secret_provided = webhook_secret is not None 42 | is_payload_signed = 'x-hub-signature' in http_req_headers 43 | 44 | if is_payload_signed and not is_secret_provided: 45 | raise ValidationFailure('secret not provided') 46 | 47 | if not is_payload_signed and is_secret_provided: 48 | raise ValidationFailure('signature is missing') 49 | 50 | raw_http_req_body = await request.read() 51 | 52 | if is_payload_signed and is_secret_provided: 53 | validate_webhook_payload( 54 | payload=raw_http_req_body, 55 | signature=http_req_headers['x-hub-signature'], 56 | secret=webhook_secret, 57 | ) 58 | 59 | return raw_http_req_body 60 | 61 | 62 | async def get_event_from_request(request, webhook_secret): 63 | """Retrieve Event out of HTTP request if it's valid.""" 64 | webhook_event_signature = request.headers.get( 65 | 'X-Hub-Signature', '', 66 | ) 67 | try: 68 | http_req_body = await get_trusted_http_payload( 69 | request, webhook_secret, 70 | ) 71 | except ValidationFailure as no_signature_exc: 72 | logger.error( 73 | EVENT_LOG_INVALID_MSG, 74 | request.headers.get('X-GitHub-Event'), 75 | request.headers.get('X-GitHub-Delivery'), 76 | webhook_event_signature, 77 | ) 78 | logger.debug( 79 | 'Webhook HTTP query signature validation failed because: %s', 80 | no_signature_exc, 81 | ) 82 | raise web.HTTPForbidden from no_signature_exc 83 | 84 | event = GidgetHubWebhookEvent.from_http_request( 85 | http_req_headers=request.headers, 86 | http_req_body=http_req_body, 87 | ) 88 | logger.info( 89 | EVENT_LOG_VALID_MSG, 90 | event.name, # pylint: disable=no-member 91 | event.delivery_id, 92 | webhook_event_signature, 93 | ) 94 | return event 95 | 96 | 97 | def validate_allowed_http_methods(*allowed_methods: str): 98 | """Block disallowed HTTP methods.""" 99 | _allowed_methods: typing.Set[str] 100 | if not allowed_methods: 101 | _allowed_methods = {'POST'} 102 | else: 103 | _allowed_methods = set(allowed_methods) 104 | 105 | def decorator(wrapped_function): 106 | @wraps(wrapped_function) 107 | async def wrapper(request, *, github_app, webhook_secret=None): 108 | if request.method not in _allowed_methods: 109 | raise web.HTTPMethodNotAllowed( 110 | method=request.method, 111 | allowed_methods=_allowed_methods, 112 | ) from BadRequest(HTTPStatus.METHOD_NOT_ALLOWED) 113 | return await wrapped_function( 114 | request, 115 | github_app=github_app, 116 | webhook_secret=webhook_secret, 117 | ) 118 | return wrapper 119 | return decorator 120 | 121 | 122 | def webhook_request_to_event(wrapped_function): 123 | """Pass event extracted from request into the wrapped function.""" 124 | @wraps(wrapped_function) 125 | async def wrapper(request, *, github_app, webhook_secret=None): 126 | event = await get_event_from_request(request, webhook_secret) 127 | return await wrapped_function( 128 | github_event=event, github_app=github_app, 129 | ) 130 | return wrapper 131 | 132 | 133 | @validate_allowed_http_methods('POST') 134 | @webhook_request_to_event 135 | async def route_github_webhook_event(*, github_event, github_app): 136 | """Dispatch incoming webhook events to corresponding handlers.""" 137 | asyncio.create_task( 138 | route_github_event( 139 | github_event=github_event, 140 | github_app=github_app, 141 | ), 142 | ) 143 | event_ack_msg = ( 144 | 'GitHub event received and scheduled for processing. ' 145 | f'It is {github_event!r}' 146 | ) 147 | return web.Response(text=f'OK: {event_ack_msg!s}') 148 | -------------------------------------------------------------------------------- /tests/app/server/machinery_test.py: -------------------------------------------------------------------------------- 1 | """Test app server machinery.""" 2 | 3 | import uuid 4 | from typing import Tuple 5 | 6 | from aiohttp.client import ClientSession 7 | from aiohttp.test_utils import get_unused_port_socket 8 | from aiohttp.web import SockSite 9 | 10 | import pytest 11 | 12 | from octomachinery.app.config import BotAppConfig 13 | from octomachinery.app.routing import WEBHOOK_EVENTS_ROUTER 14 | from octomachinery.app.server.machinery import setup_server_runner 15 | from octomachinery.github.api.app_client import GitHubApp 16 | 17 | 18 | IPV4_LOCALHOST = '127.0.0.1' 19 | 20 | 21 | @pytest.fixture 22 | def ephemeral_port_tcp_sock(): 23 | """Initialize an ephemeral TCP socket.""" 24 | return get_unused_port_socket(IPV4_LOCALHOST) 25 | 26 | 27 | @pytest.fixture 28 | def ephemeral_port_tcp_sock_addr(ephemeral_port_tcp_sock): 29 | """Return final host and port addr of the ephemeral TCP socket.""" 30 | return ephemeral_port_tcp_sock.getsockname()[:2] 31 | 32 | 33 | @pytest.fixture 34 | def github_app_id() -> int: 35 | """Return a GitHub App ID.""" 36 | return 0 37 | 38 | 39 | @pytest.fixture 40 | def octomachinery_config( 41 | github_app_id: int, rsa_private_key_bytes: bytes, 42 | ephemeral_port_tcp_sock_addr: Tuple[str, int], 43 | ) -> None: 44 | """Initialize a GitHub App bot config.""" 45 | host, port = ephemeral_port_tcp_sock_addr 46 | # https://github.com/hynek/environ-config/blob/master/CHANGELOG.rst#1910-2019-09-02 47 | # pylint: disable=no-member 48 | return BotAppConfig.from_environ({ # type: ignore[attr-defined] 49 | 'GITHUB_APP_IDENTIFIER': str(github_app_id), 50 | 'GITHUB_PRIVATE_KEY': rsa_private_key_bytes.decode(), 51 | 'HOST': host, 52 | 'PORT': port, 53 | }) 54 | 55 | 56 | @pytest.fixture 57 | def octomachinery_config_github_app(octomachinery_config): 58 | """Return a GitHub App bot config section.""" 59 | return octomachinery_config.github 60 | 61 | 62 | @pytest.fixture 63 | def octomachinery_config_server(octomachinery_config): 64 | """Return a GitHub App server config section.""" 65 | return octomachinery_config.server 66 | 67 | 68 | @pytest.fixture 69 | async def aiohttp_client_session(event_loop) -> ClientSession: 70 | """Initialize an aiohttp HTTP client session.""" 71 | async with ClientSession() as http_session: 72 | yield http_session 73 | 74 | 75 | @pytest.fixture 76 | def octomachinery_event_routers(): 77 | """Construct a set of routers for use in the GitHub App.""" 78 | return frozenset({WEBHOOK_EVENTS_ROUTER}) 79 | 80 | 81 | @pytest.fixture 82 | def github_app( 83 | octomachinery_config_github_app, aiohttp_client_session, 84 | octomachinery_event_routers, 85 | ): 86 | """Initizalize a GitHub App instance.""" 87 | return GitHubApp( 88 | octomachinery_config_github_app, 89 | aiohttp_client_session, 90 | octomachinery_event_routers, 91 | ) 92 | 93 | 94 | @pytest.fixture 95 | async def octomachinery_app_server_runner(github_app): 96 | """Set up an HTTP handler for webhooks.""" 97 | return await setup_server_runner(github_app) 98 | 99 | 100 | @pytest.fixture 101 | async def octomachinery_app_tcp( 102 | ephemeral_port_tcp_sock, 103 | octomachinery_app_server_runner, 104 | ): 105 | """Run octomachinery web server and tear-down after testing.""" 106 | tcp_site = SockSite( 107 | octomachinery_app_server_runner, ephemeral_port_tcp_sock, 108 | ) 109 | await tcp_site.start() 110 | try: 111 | yield tcp_site 112 | finally: 113 | await tcp_site.stop() 114 | 115 | 116 | @pytest.fixture 117 | async def send_webhook_event( 118 | octomachinery_app_tcp, aiohttp_client_session, 119 | ): 120 | """Return a webhook sender coroutine.""" 121 | def _send_event(webhook_payload=None): 122 | post_body = {} if webhook_payload is None else webhook_payload 123 | 124 | webhook_endpoint_url = octomachinery_app_tcp.name 125 | return aiohttp_client_session.post( 126 | webhook_endpoint_url, json=post_body, 127 | headers={ 128 | 'X-GitHub-Delivery': str(uuid.uuid4()), 129 | 'X-GitHub-Event': 'ping', 130 | }, 131 | ) 132 | return _send_event 133 | 134 | 135 | @pytest.mark.anyio 136 | async def test_ping_response(send_webhook_event, github_app_id): 137 | """Test that ping webhook event requests receive a HTTP response.""" 138 | async with send_webhook_event( 139 | { 140 | 'hook': {'app_id': github_app_id}, 141 | 'hook_id': 0, 142 | 'zen': 'Hey zen!', 143 | }, 144 | ) as gh_app_http_resp: 145 | resp_body: str = (await gh_app_http_resp.read()).decode() 146 | 147 | resp_content_type = gh_app_http_resp.headers['Content-Type'] 148 | expected_response_start = ( 149 | 'OK: GitHub event received and scheduled for processing. ' 150 | "It is GidgetHubWebhookEvent(name='ping', payload={'hook': {" 151 | f"'app_id': {github_app_id}" 152 | "}, 'hook_id': 0, 'zen': 'Hey zen!'}, delivery_id=UUID('" 153 | ) 154 | 155 | assert resp_content_type == 'text/plain; charset=utf-8' 156 | assert resp_body.startswith(expected_response_start) 157 | -------------------------------------------------------------------------------- /tests/circular_imports_test.py: -------------------------------------------------------------------------------- 1 | """Tests for circular imports in all local packages and modules. 2 | 3 | This ensures all internal packages can be imported right away without 4 | any need to import some other module before doing so. 5 | 6 | This module is based on an idea that pytest uses for self-testing: 7 | * https://github.com/pytest-dev/pytest/blob/d18c75b/testing/test_meta.py 8 | * https://twitter.com/codewithanthony/status/1229445110510735361 9 | """ 10 | import os 11 | import pkgutil 12 | import subprocess 13 | import sys 14 | from itertools import chain 15 | from pathlib import Path 16 | 17 | import pytest 18 | 19 | import octomachinery 20 | 21 | 22 | def _find_all_importables(pkg): 23 | """Find all importables in the project. 24 | 25 | Return them in order. 26 | """ 27 | return sorted( 28 | set( 29 | chain.from_iterable( 30 | _discover_path_importables(Path(p), pkg.__name__) 31 | for p in pkg.__path__ 32 | ), 33 | ), 34 | ) 35 | 36 | 37 | def _discover_path_importables(pkg_pth, pkg_name): 38 | """Yield all importables under a given path and package.""" 39 | for dir_path, _d, file_names in os.walk(pkg_pth): 40 | pkg_dir_path = Path(dir_path) 41 | 42 | if pkg_dir_path.parts[-1] == '__pycache__': 43 | continue 44 | 45 | if all(Path(_).suffix != '.py' for _ in file_names): 46 | continue 47 | 48 | rel_pt = pkg_dir_path.relative_to(pkg_pth) 49 | pkg_pref = '.'.join((pkg_name,) + rel_pt.parts) 50 | yield from ( 51 | pkg_path 52 | for _, pkg_path, _ in pkgutil.walk_packages( 53 | (str(pkg_dir_path),), prefix=f'{pkg_pref}.', 54 | ) 55 | ) 56 | 57 | 58 | @pytest.mark.parametrize( 59 | 'import_path', 60 | _find_all_importables(octomachinery), 61 | ) 62 | def test_no_warnings(import_path): 63 | """Verify that exploding importables doesn't explode. 64 | 65 | This is seeking for any import errors including ones caused 66 | by circular imports. 67 | """ 68 | imp_cmd = ( 69 | sys.executable, 70 | '-W', 'error', 71 | 72 | # NOTE: These are necessary for `tox -e old-deps`: 73 | '-W', "ignore:Using or importing the ABCs from 'collections' instead " 74 | "of from 'collections.abc' is deprecated since " 75 | 'Python 3.3, and in 3.10 it will stop working:' 76 | 'DeprecationWarning:jwt.api_jwt', 77 | '-W', "ignore:Using or importing the ABCs from 'collections' instead " 78 | "of from 'collections.abc' is deprecated since " 79 | 'Python 3.3, and in 3.9 it will stop working:' 80 | 'DeprecationWarning:jwt.api_jwt', 81 | # NOTE: This looks like the line above but has a typo 82 | # NOTE: (a whitespace is missing): 83 | '-W', "ignore:Using or importing the ABCs from 'collections' instead " 84 | "of from 'collections.abc' is deprecated since " 85 | 'Python 3.3,and in 3.9 it will stop working:' 86 | 'DeprecationWarning:jwt.api_jwt', 87 | 88 | # NOTE: Triggered by the `octomachinery.utils.versiontools` 89 | # NOTE: command via `tox -e old-deps`: 90 | '-W', 'ignore:pkg_resources is deprecated as an API:' 91 | 'DeprecationWarning:pkg_resources', 92 | 93 | # NOTE: Triggered by the `octomachinery.utils.versiontools` 94 | # NOTE: command via `tox -e old-deps`: 95 | '-W', 'ignore:The distutils package is deprecated and slated for ' 96 | 'removal in Python 3.12. Use setuptools or check PEP 632 for ' 97 | 'potential alternatives:' 98 | 'DeprecationWarning:', 99 | 100 | # NOTE: Triggered by the `octomachinery.utils.versiontools` 101 | # NOTE: command via `tox -e old-deps`: 102 | '-W', "ignore:module 'sre_constants' is deprecated:" 103 | 'DeprecationWarning:', 104 | 105 | # NOTE: Triggered by the `octomachinery.utils.versiontools` 106 | # NOTE: command via `tox -e old-deps`: 107 | '-W', 'ignore:_SixMetaPathImporter.exec_module() not found; ' 108 | 'falling back to load_module():ImportWarning:', 109 | 110 | # NOTE: Triggered by the `octomachinery.utils.versiontools` 111 | # NOTE: command via `tox -e old-deps`: 112 | '-W', 'ignore:_SixMetaPathImporter.find_spec() not found; ' 113 | 'falling back to find_module():ImportWarning:', 114 | 115 | # NOTE: Triggered by the `octomachinery.utils.versiontools` 116 | # NOTE: command via `tox -e old-deps`: 117 | '-W', 'ignore:VendorImporter.exec_module() not found; ' 118 | 'falling back to load_module():ImportWarning:', 119 | 120 | # NOTE: Triggered by the `octomachinery.utils.versiontools` 121 | # NOTE: command via `tox -e old-deps`: 122 | '-W', 'ignore:VendorImporter.find_spec() not found; ' 123 | 'falling back to find_module():ImportWarning:', 124 | 125 | # NOTE: Triggered by the `octomachinery.routing.routers` 126 | # NOTE: command via `tox -e old-deps`: 127 | '-W', "ignore:'cgi' is deprecated and slated for removal " 128 | 'in Python 3.13:' 129 | 'DeprecationWarning:gidgethub.sansio', 130 | 131 | '-W', 'ignore:"@coroutine" decorator is deprecated ' 132 | 'since Python 3.8, use "async def" instead:' 133 | 'DeprecationWarning:aiohttp.helpers', 134 | '-c', f'import {import_path!s}', 135 | ) 136 | 137 | subprocess.check_call(imp_cmd) 138 | -------------------------------------------------------------------------------- /octomachinery/github/api/app_client.py: -------------------------------------------------------------------------------- 1 | """GitHub App API client.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from collections import defaultdict 7 | from typing import TYPE_CHECKING, Any, Dict, Iterable 8 | 9 | from aiohttp.client import ClientSession 10 | from aiohttp.client_exceptions import ClientConnectorError 11 | 12 | import attr 13 | import sentry_sdk 14 | 15 | # pylint: disable=relative-beyond-top-level 16 | from ...routing import WEBHOOK_EVENTS_ROUTER 17 | # pylint: disable=relative-beyond-top-level 18 | from ...utils.asynctools import amap, dict_to_kwargs_cb 19 | # pylint: disable=relative-beyond-top-level 20 | from ..config.app import GitHubAppIntegrationConfig 21 | # pylint: disable=relative-beyond-top-level 22 | from ..entities.app_installation import GitHubAppInstallation 23 | # pylint: disable=relative-beyond-top-level 24 | from ..models import GitHubAppInstallation as GitHubAppInstallationModel 25 | # pylint: disable=relative-beyond-top-level 26 | from ..models.events import GitHubEvent 27 | from .raw_client import RawGitHubAPI 28 | from .tokens import GitHubJWTToken 29 | 30 | 31 | if TYPE_CHECKING: 32 | # pylint: disable=relative-beyond-top-level 33 | from ...routing.abc import OctomachineryRouterBase 34 | 35 | 36 | logger = logging.getLogger(__name__) 37 | 38 | 39 | GH_INSTALL_EVENTS = {'integration_installation', 'installation'} 40 | 41 | 42 | @attr.dataclass 43 | class GitHubApp: 44 | """GitHub API wrapper.""" 45 | 46 | _config: GitHubAppIntegrationConfig 47 | _http_session: ClientSession 48 | _event_routers: Iterable[OctomachineryRouterBase] = attr.ib( 49 | default={WEBHOOK_EVENTS_ROUTER}, 50 | converter=frozenset, 51 | ) 52 | 53 | def __attrs_post_init__(self) -> None: 54 | """Initialize the Sentry SDK library.""" 55 | # NOTE: Under the hood, it will set up the DSN from `SENTRY_DSN` 56 | # NOTE: env var. We don't need to care about it not existing as 57 | # NOTE: Sentry SDK helpers don't fail loudly and if not 58 | # NOTE: configured, it'll be ignored. 59 | # FIXME: # pylint: disable=fixme 60 | sentry_sdk.init() # pylint: disable=abstract-class-instantiated 61 | 62 | async def dispatch_event(self, github_event: GitHubEvent) -> Iterable[Any]: 63 | """Dispatch ``github_event`` into the embedded routers.""" 64 | return await github_event.dispatch_via( 65 | *self._event_routers, # pylint: disable=not-an-iterable 66 | ) 67 | 68 | async def log_installs_list(self) -> None: 69 | """Store all installations data before starting.""" 70 | try: 71 | installations = await self.get_installations() 72 | except ClientConnectorError as client_error: 73 | logger.info('It looks like the GitHub API is offline...') 74 | logger.error( 75 | 'The following error has happened while trying to grab ' 76 | 'installations list: %s', 77 | client_error, 78 | ) 79 | return 80 | 81 | logger.info('This GitHub App is installed into:') 82 | # pylint: disable=protected-access 83 | for install_id, install_val in installations.items(): 84 | logger.info( 85 | '* Installation id %s (installed to %s)', 86 | install_id, 87 | install_val._metadata.account['login'], 88 | ) 89 | 90 | @property 91 | def gh_jwt(self): 92 | """Generate app's JSON Web Token, valid for 60 seconds.""" 93 | token = self._config.private_key.make_jwt_for( 94 | app_id=self._config.app_id, 95 | ) 96 | return GitHubJWTToken(token) 97 | 98 | @property 99 | def api_client(self): # noqa: D401 100 | """The GitHub App client with an async CM interface.""" 101 | return RawGitHubAPI( 102 | token=self.gh_jwt, 103 | session=self._http_session, 104 | user_agent=self._config.user_agent, 105 | ) 106 | 107 | async def get_installation(self, event): 108 | """Retrieve an installation creds from store.""" 109 | if 'installation' not in event.payload: 110 | raise LookupError('This event occurred outside of an installation') 111 | 112 | install_id = event.payload['installation']['id'] 113 | return await self.get_installation_by_id(install_id) 114 | 115 | async def get_installation_by_id(self, install_id): 116 | """Retrieve an installation with access tokens via API.""" 117 | return GitHubAppInstallation( 118 | await dict_to_kwargs_cb(GitHubAppInstallationModel)( 119 | await self.api_client.getitem( 120 | '/app/installations/{installation_id}', 121 | url_vars={'installation_id': install_id}, 122 | preview_api_version='machine-man', 123 | ), 124 | ), 125 | self, 126 | ) 127 | 128 | async def get_installations(self): 129 | """Retrieve all installations with access tokens via API.""" 130 | installations: Dict[ 131 | int, GitHubAppInstallation, 132 | ] = defaultdict(dict) # type: ignore[arg-type] 133 | async for install in amap( 134 | dict_to_kwargs_cb(GitHubAppInstallationModel), 135 | self.api_client.getiter( 136 | '/app/installations', 137 | preview_api_version='machine-man', 138 | ), 139 | ): 140 | installations[install.id] = GitHubAppInstallation( 141 | install, self, 142 | ) 143 | return installations 144 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | 3 | # Print the total number of errors: 4 | count = true 5 | 6 | # Don't even try to analyze these: 7 | extend-exclude = 8 | # Circle CI configs 9 | .circleci, 10 | # No need to traverse egg info dir 11 | *.egg-info, 12 | # GitHub configs 13 | .github, 14 | # Cache files of MyPy 15 | .mypy_cache, 16 | # Cache files of pytest 17 | .pytest_cache, 18 | # Temp dir of pytest-testmon 19 | .tmontmp, 20 | # Countless third-party libs in venvs 21 | .tox, 22 | # Occasional virtualenv dir 23 | .venv, 24 | # VS Code 25 | .vscode, 26 | # Temporary build dir 27 | build, 28 | # This contains sdists and wheels that we don't want to check 29 | dist, 30 | # Metadata of `pip wheel` cmd is autogenerated 31 | pip-wheel-metadata, 32 | 33 | # IMPORTANT: avoid using ignore option, always use extend-ignore instead 34 | # Completely and unconditionally ignore the following errors: 35 | extend-ignore = 36 | # Legitimate cases, no need to "fix" these violations: 37 | # E501: "line too long", its function is replaced by `flake8-length` 38 | E501, 39 | # W505: "doc line too long", its function is replaced by `flake8-length` 40 | W505, 41 | # I: flake8-isort is drunk + we have isort integrated into pre-commit 42 | # I, 43 | # WPS300: "Found local folder import" -- nothing bad about this 44 | WPS300, 45 | # WPS305: "Found f string" -- nothing bad about this 46 | WPS305, 47 | # An opposite consistency expectation is currently enforced 48 | # by pylint via: useless-object-inheritance (R0205): 49 | # WPS306: "Found class without a base class: *" -- we have metaclass shims 50 | WPS306, 51 | # WPS326: "Found implicit string concatenation" -- nothing bad about this 52 | WPS326, 53 | # WPS422: "Found future import: *" -- we need these for multipython 54 | WPS422, 55 | 56 | # FIXME: These `flake8-annotations` errors need fixing and removal 57 | # ANN001: Missing type annotation for function argument 'argv' 58 | ANN001, 59 | # ANN002: Missing type annotation for *exceptions 60 | ANN002, 61 | # ANN003: Missing type annotation for **kwargs 62 | ANN003, 63 | # ANN101: Missing type annotation for self in method 64 | ANN101, 65 | # ANN102: Missing type annotation for cls in classmethod 66 | ANN102, 67 | # ANN201: Missing return type annotation for public function 68 | ANN201, 69 | # ANN202: Missing return type annotation for protected function 70 | ANN202, 71 | # ANN204: Missing return type annotation for special method 72 | ANN204, 73 | # ANN205: Missing return type annotation for staticmethod 74 | ANN205, 75 | # ANN206: Missing return type annotation for classmethod 76 | ANN206, 77 | 78 | # FIXME: These `flake8-spellcheck` errors need fixing and removal 79 | # SC100: Possibly misspelt word / comments 80 | SC100, 81 | # SC200: Possibly misspelt word / names 82 | SC200, 83 | 84 | # FIXME: Temporary WPS ignore 85 | WPS, 86 | # FIXME: DAR101 Missing parameter(s) in Docstring: - classifiers 87 | DAR101, 88 | # FIXME: DAR201 Missing "Returns" in Docstring: - return 89 | DAR201, 90 | # FIXME: DAR301 Missing "Yields" in Docstring: - yield 91 | DAR301, 92 | # FIXME: DAR401 Missing exception(s) in Raises section: -r LookupError 93 | DAR401, 94 | # FIXME: PT006: wrong name(s) type in @pytest.mark.parametrize, expected tuple 95 | # FIXME: PT022: no teardown in fixture tmp_git_repo, use return instead of yield 96 | PT006, 97 | PT022, 98 | # FIXME: LN002: doc/comment line is too long 99 | LN002, 100 | # FIXME: Temporary `flake8-logging` ignore 101 | G200, 102 | # FIXME: D401: First line should be in imperative mood; try rephrasing 103 | D401, 104 | 105 | 106 | format = default 107 | 108 | # Let's not overcomplicate the code: 109 | max-complexity = 10 110 | 111 | # Accessibility/large fonts and PEP8 friendly. 112 | # This is being flexibly extended through the `flake8-length`: 113 | max-line-length = 79 114 | 115 | # Allow certain violations in certain files: 116 | # Please keep both sections of this list sorted, as it will be easier for others to find and add entries in the future 117 | per-file-ignores = 118 | # The following ignores have been researched and should be considered permanent 119 | # each should be preceded with an explanation of each of the error codes 120 | # If other ignores are added for a specific file in the section following this, 121 | # these will need to be added to that line as well. 122 | 123 | # Sphinx builds aren't supposed to be run under Python 2: 124 | # docs/conf.py: WPS305 125 | 126 | # The package has imports exposing private things to the public: 127 | # src/pylibsshext/__init__.py: WPS412 128 | 129 | # There are multiple `assert`s (S101) 130 | # and subprocesses (import – S404; call – S603) in tests; 131 | # also, using fixtures looks like shadowing the outer scope (WPS442); 132 | # furthermore, we should be able to import and test private attributes 133 | # (WPS450) and modules (WPS436), and finally it's impossible to 134 | # have <= members in tests (WPS202), including many local vars (WPS210): 135 | tests/**.py: S101, S404, S603, WPS202, WPS210, WPS436, WPS442, WPS450 136 | 137 | 138 | # Count the number of occurrences of each error/warning code and print a report: 139 | statistics = true 140 | 141 | # ## Plugin-provided settings: ## 142 | 143 | # flake8-eradicate 144 | # E800: 145 | eradicate-whitelist-extend = isort:\s+\w+ 146 | 147 | # flake8-pytest-style 148 | # PT001: 149 | pytest-fixture-no-parentheses = true 150 | # PT006: 151 | pytest-parametrize-names-type = tuple 152 | # PT007: 153 | pytest-parametrize-values-type = tuple 154 | pytest-parametrize-values-row-type = tuple 155 | # PT023: 156 | pytest-mark-no-parentheses = true 157 | 158 | # flake8-rst-docstrings 159 | rst-directives = 160 | spelling 161 | rst-roles = 162 | # Built-in Sphinx roles: 163 | class, 164 | data, 165 | file, 166 | exc, 167 | meth, 168 | mod, 169 | term, 170 | py:class, 171 | py:data, 172 | py:exc, 173 | py:meth, 174 | py:term, 175 | # Sphinx's internal role: 176 | event, 177 | 178 | # wemake-python-styleguide 179 | show-source = true 180 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | ci: 4 | autoupdate_schedule: quarterly # low frequency to reduce maintenance noise 5 | 6 | repos: 7 | 8 | 9 | - repo: https://github.com/asottile/add-trailing-comma.git 10 | rev: v3.1.0 11 | hooks: 12 | - id: add-trailing-comma 13 | 14 | - repo: https://github.com/PyCQA/isort.git 15 | rev: 5.13.2 16 | hooks: 17 | - id: isort 18 | args: 19 | - --honor-noqa 20 | 21 | - repo: https://github.com/Lucas-C/pre-commit-hooks.git 22 | rev: v1.5.5 23 | hooks: 24 | - id: remove-tabs 25 | 26 | - repo: https://github.com/python-jsonschema/check-jsonschema.git 27 | rev: 0.29.1 28 | hooks: 29 | - id: check-github-workflows 30 | files: ^\.github/workflows/[^/]+$ 31 | types: 32 | - yaml 33 | - id: check-jsonschema 34 | name: Check GitHub Workflows set timeout-minutes 35 | args: 36 | - --builtin-schema 37 | - github-workflows-require-timeout 38 | files: ^\.github/workflows/[^/]+$ 39 | types: 40 | - yaml 41 | - id: check-readthedocs 42 | 43 | - repo: https://github.com/Lucas-C/pre-commit-hooks-markup.git 44 | rev: v1.0.1 45 | hooks: 46 | - id: rst-linter 47 | files: README.rst 48 | 49 | - repo: https://github.com/pre-commit/pre-commit-hooks.git 50 | rev: v4.6.0 51 | hooks: 52 | # Side-effects: 53 | - id: trailing-whitespace 54 | - id: check-merge-conflict 55 | - id: double-quote-string-fixer 56 | - id: end-of-file-fixer 57 | - id: requirements-txt-fixer 58 | 59 | # Non-modifying checks: 60 | - id: name-tests-test 61 | - id: check-added-large-files 62 | - id: check-byte-order-marker 63 | - id: check-case-conflict 64 | # disabled due to pre-commit/pre-commit-hooks#159 65 | # - id: check-docstring-first 66 | - id: check-json 67 | - id: check-symlinks 68 | - id: check-yaml 69 | - id: detect-private-key 70 | 71 | # Heavy checks: 72 | - id: check-ast 73 | - id: debug-statements 74 | 75 | - repo: https://github.com/PyCQA/pydocstyle.git 76 | rev: 6.3.0 77 | hooks: 78 | - id: pydocstyle 79 | 80 | - repo: https://github.com/codespell-project/codespell.git 81 | rev: v2.3.0 82 | hooks: 83 | - id: codespell 84 | 85 | - repo: https://github.com/adrienverge/yamllint.git 86 | rev: v1.35.1 87 | hooks: 88 | - id: yamllint 89 | types: 90 | - file 91 | - yaml 92 | args: 93 | - --strict 94 | 95 | - repo: https://github.com/PyCQA/flake8.git 96 | rev: 7.1.1 97 | hooks: 98 | - id: flake8 99 | alias: flake8-no-wps 100 | name: flake8 WPS-excluded 101 | additional_dependencies: 102 | - darglint ~= 1.8.1 103 | - flake8-2020 ~= 1.7.0 104 | - flake8-annotations ~= 2.9.1; python_version >= "3.7" 105 | - flake8-annotations ~= 2.7.0; python_version < "3.7" 106 | - flake8-docstrings ~= 1.6.0 107 | - flake8-length ~= 0.3.0 108 | - flake8-logging-format ~= 0.7.5 109 | - flake8-pytest-style ~= 1.6.0 110 | - flake8-spellcheck ~= 0.28.0; python_version >= "3.8" 111 | - flake8-spellcheck ~= 0.26.0; python_version < "3.8" 112 | language_version: python3 113 | 114 | - repo: https://github.com/PyCQA/flake8.git 115 | rev: 7.1.1 116 | hooks: 117 | - id: flake8 118 | alias: flake8-only-wps 119 | name: flake8 WPS-only 120 | args: 121 | - --select 122 | - WPS 123 | additional_dependencies: 124 | - wemake-python-styleguide ~= 0.19.2 125 | language_version: python3.11 # flake8-commas doesn't work w/ Python 3.12 126 | 127 | - repo: https://github.com/pre-commit/mirrors-mypy.git 128 | rev: v1.11.2 129 | hooks: 130 | - id: mypy 131 | alias: mypy-py313 132 | name: MyPy, for Python 3.13 133 | additional_dependencies: 134 | - . 135 | - attrs 136 | - types-PyYAML 137 | args: 138 | - --namespace-packages 139 | - --pretty 140 | - --python-version=3.12 141 | - --show-column-numbers 142 | - --show-error-codes 143 | - --show-error-context 144 | - --strict-optional 145 | - -p 146 | - octomachinery 147 | - -p 148 | - tests 149 | pass_filenames: false 150 | - id: mypy 151 | alias: mypy-py311 152 | name: MyPy, for Python 3.11 153 | additional_dependencies: 154 | - . 155 | - attrs 156 | - types-PyYAML 157 | args: 158 | - --namespace-packages 159 | - --pretty 160 | - --python-version=3.11 161 | - --show-column-numbers 162 | - --show-error-codes 163 | - --show-error-context 164 | - --strict-optional 165 | - -p 166 | - octomachinery 167 | - -p 168 | - tests 169 | pass_filenames: false 170 | - id: mypy 171 | alias: mypy-py39 172 | name: MyPy, for Python 3.9 173 | additional_dependencies: 174 | - . 175 | - attrs 176 | - types-PyYAML 177 | args: 178 | - --namespace-packages 179 | - --pretty 180 | - --python-version=3.9 181 | - --show-column-numbers 182 | - --show-error-codes 183 | - --show-error-context 184 | - --strict-optional 185 | - -p 186 | - octomachinery 187 | - -p 188 | - tests 189 | pass_filenames: false 190 | - id: mypy 191 | alias: mypy-py37 192 | name: MyPy, for Python 3.7 193 | additional_dependencies: 194 | - . 195 | - attrs 196 | - types-PyYAML 197 | args: 198 | - --namespace-packages 199 | - --pretty 200 | - --python-version=3.8 201 | - --show-column-numbers 202 | - --show-error-codes 203 | - --show-error-context 204 | - --strict-optional 205 | - -p 206 | - octomachinery 207 | - -p 208 | - tests 209 | pass_filenames: false 210 | 211 | - repo: https://github.com/PyCQA/pylint.git 212 | rev: v3.2.6 213 | hooks: 214 | - id: pylint 215 | additional_dependencies: 216 | # runtime deps: 217 | - aiohttp 218 | - anyio < 2.0.0 219 | - click 220 | - cryptography 221 | - environ-config >= 19.1.0 222 | - envparse 223 | - gidgethub >= 4.2.0 224 | - pyjwt[crypto] 225 | - pyyaml 226 | - sentry_sdk 227 | - setuptools_scm 228 | # test deps: 229 | - pylint-pytest ~= 2.0.0a0 230 | - pytest 231 | - pytest-aiohttp 232 | - pytest-cov 233 | - pytest-xdist 234 | 235 | ... 236 | -------------------------------------------------------------------------------- /octomachinery/routing/webhooks_dispatcher.py: -------------------------------------------------------------------------------- 1 | """GitHub webhook events dispatching logic.""" 2 | 3 | from __future__ import annotations 4 | 5 | import contextlib 6 | import logging 7 | from typing import Any, Iterable 8 | 9 | from anyio import get_cancelled_exc_class 10 | from anyio import sleep as async_sleep 11 | 12 | import sentry_sdk 13 | 14 | # pylint: disable=relative-beyond-top-level,import-error 15 | from ..github.api.app_client import GitHubApp 16 | # pylint: disable=relative-beyond-top-level,import-error 17 | from ..github.entities.action import GitHubAction 18 | # pylint: disable=relative-beyond-top-level,import-error 19 | from ..github.errors import GitHubActionError 20 | # pylint: disable=relative-beyond-top-level,import-error 21 | from ..github.models.events import GitHubEvent 22 | # pylint: disable=relative-beyond-top-level,import-error 23 | from ..runtime.context import RUNTIME_CONTEXT 24 | 25 | 26 | __all__ = ('route_github_event',) 27 | 28 | 29 | logger = logging.getLogger(__name__) 30 | 31 | 32 | # pylint: disable=fixme 33 | async def route_github_event( # type: ignore[return] # FIXME 34 | *, 35 | github_event: GitHubEvent, 36 | github_app: GitHubApp, 37 | ) -> Iterable[Any]: 38 | """Dispatch GitHub event to corresponding handlers. 39 | 40 | Set up ``RUNTIME_CONTEXT`` before doing that. This is so 41 | the concrete event handlers have access to the API client 42 | and flags in runtime. 43 | """ 44 | is_gh_action = isinstance(github_app, GitHubAction) 45 | # pylint: disable=assigning-non-slot 46 | RUNTIME_CONTEXT.IS_GITHUB_ACTION = is_gh_action 47 | # pylint: disable=assigning-non-slot 48 | RUNTIME_CONTEXT.IS_GITHUB_APP = not is_gh_action 49 | 50 | # pylint: disable=assigning-non-slot 51 | RUNTIME_CONTEXT.github_app = github_app 52 | 53 | # pylint: disable=assigning-non-slot 54 | RUNTIME_CONTEXT.github_event = github_event 55 | 56 | # pylint: disable=assigning-non-slot 57 | RUNTIME_CONTEXT.app_installation = None 58 | if is_gh_action: 59 | # pylint: disable=assigning-non-slot 60 | RUNTIME_CONTEXT.app_installation_client = github_app.api_client 61 | else: 62 | with contextlib.suppress(LookupError): 63 | # pylint: disable=pointless-string-statement 64 | """Provision an installation API client if possible. 65 | 66 | Some events (like `ping`) are 67 | happening application/GitHub-wide and are not bound to 68 | a specific installation. The webhook payloads of such events 69 | don't contain any reference to an installation. 70 | Some events don't even refer to a GitHub App 71 | (e.g. `security_advisory`). 72 | """ 73 | github_install = await github_app.get_installation(github_event) 74 | # pylint: disable=assigning-non-slot 75 | RUNTIME_CONTEXT.app_installation = github_install 76 | # pylint: disable=assigning-non-slot 77 | RUNTIME_CONTEXT.app_installation_client = github_install.api_client 78 | 79 | # Give GitHub a sec to deal w/ eventual consistency. 80 | # This is only needed for events that arrive over HTTP. 81 | # If the dispatcher is invoked from GitHub Actions, 82 | # by the time it's invoked the action must be already consistently 83 | # distributed within GitHub's systems because spawning VMs takes time 84 | # and actions are executed in workflows that rely on those VMs. 85 | await async_sleep(1) 86 | 87 | try: 88 | return await github_app.dispatch_event(github_event) 89 | except GitHubActionError: 90 | # Bypass GitHub Actions errors as they are supposed to be a 91 | # mechanism for communicating outcomes and are expected. 92 | raise 93 | except get_cancelled_exc_class(): 94 | raise 95 | except Exception as exc: # pylint: disable=broad-except 96 | # NOTE: It's probably better to wrap each event handler with 97 | # NOTE: try/except and call `capture_exception()` there instead. 98 | # NOTE: We'll also need to figure out the magic of associating 99 | # NOTE: breadcrumbs with event handlers. 100 | sentry_sdk.capture_exception(exc) 101 | 102 | # NOTE: Framework-wise, these exceptions are meaningless because they 103 | # NOTE: can be anything random that the webhook author (octomachinery 104 | # NOTE: end-user) forgot to handle. There's nothing we can do about 105 | # NOTE: them except put in the log so that the end-user would be able 106 | # NOTE: to properly debug their problem by inspecting the logs. 107 | # NOTE: P.S. This is also where we'd inject Sentry 108 | if isinstance(exc.__context__, get_cancelled_exc_class()): 109 | # The CancelledError context is irrelevant to the 110 | # user-defined webhook event handler workflow so we're 111 | # dropping it from the logs: 112 | exc.__context__ = None 113 | 114 | logger.exception( 115 | 'An unhandled exception happened while running webhook ' 116 | 'event handlers for "%s"...', 117 | github_event.name, 118 | ) 119 | delivery_id_msg = ( 120 | '' if is_gh_action 121 | else ' (Delivery ID: ' 122 | # FIXME: # pylint: disable=fixme 123 | f'{github_event.delivery_id!s})' # type: ignore[attr-defined] 124 | ) 125 | logger.debug( 126 | 'The payload of "%s" event%s is: %r', 127 | github_event.name, delivery_id_msg, github_event.payload, 128 | ) 129 | 130 | if is_gh_action: 131 | # NOTE: In GitHub Actions env, the app is supposed to run as 132 | # NOTE: a foreground single event process rather than a 133 | # NOTE: server for multiple events. It's okay to propagate 134 | # NOTE: unhandled errors so that they are spit out to the 135 | # NOTE: console. 136 | raise 137 | except BaseException: # SystemExit + KeyboardInterrupt + GeneratorExit 138 | raise 139 | -------------------------------------------------------------------------------- /docs/howto-guides.rst: -------------------------------------------------------------------------------- 1 | How-to guides 2 | ============= 3 | 4 | 5 | Running a one-off task against GitHub API 6 | ----------------------------------------- 7 | 8 | Sometimes you need to run a series of queries against GitHub API. 9 | To do this, initialize a token (it's taken from the ``GITHUB_TOKEN`` env 10 | var in the example below), construct a GitHub API wrapper and you are 11 | good to go. 12 | 13 | :py:class:`~octomachinery.github.api.raw_client.RawGitHubAPI` is a 14 | wrapper around interface provided by 15 | :py:class:`~gidgethub.abc.GitHubAPI`, you can find the usage interface 16 | on its documentation page. Don't forget to specify a ``user_agent`` 17 | string — it's mandatory! 18 | 19 | API calls return native Python :py:class:`dict` or iterable objects. 20 | 21 | .. code:: python 22 | 23 | import asyncio 24 | import os 25 | 26 | from octomachinery.github.api.tokens import GitHubOAuthToken 27 | from octomachinery.github.api.raw_client import RawGitHubAPI 28 | 29 | 30 | async def main(): 31 | access_token = GitHubOAuthToken(os.environ["GITHUB_TOKEN"]) 32 | github_api = RawGitHubAPI(access_token, user_agent='webknjaz') 33 | await github_api.post( 34 | '/repos/mariatta/strange-relationship/issues', 35 | data={ 36 | 'title': 'We got a problem', 37 | 'body': 'Use more emoji!', 38 | }, 39 | ) 40 | 41 | 42 | asyncio.run(main()) 43 | 44 | 45 | Authenticating as a bot (GitHub App) 46 | ------------------------------------ 47 | 48 | To act as a bot, you should use a special kind of integration with 49 | GitHub — Apps. They are reusable entities in the GitHub Platform 50 | available to be installed into multiple accounts and organizations. 51 | 52 | Classic GitHub App requires a web-sever part to be deployed somewhere on 53 | the Internet in order to receive events GitHub Platform would send 54 | there. 55 | 56 | Yet, sometimes, you just want to act as a bot without any of that 57 | deployment hustle. You may want to have a ``[bot]`` label next to 58 | comments you'll post via API. You may want to manage rate limits better. 59 | This will allow you to run one-off tasks like batch changes/migrations. 60 | 61 | You'll still need to register a GitHub App and install it into the 62 | target user account or organization. You'll also have to specify APIs 63 | you'd like to access using this App. 64 | 65 | Then, you'll need to get your App's ID and a private key. 66 | 67 | Now, first, specify the App ID, the key path and the target account. 68 | After that, create a 69 | :py:class:`~octomachinery.github.config.app.GitHubAppIntegrationConfig` 70 | instance also specifying app name, version and some URL (these will be 71 | used to generate a ``User-Agent`` HTTP header for API queries). 72 | Then, create a 73 | :py:class:`~octomachinery.github.api.app_client.GitHubApp` instance from 74 | that config. Retrieve a list of places where the App is installed, 75 | filter out the target Installation and get an API client for it. 76 | Finally, use 77 | :py:class:`~octomachinery.github.api.raw_client.RawGitHubAPI` as usual. 78 | 79 | .. code:: python 80 | 81 | import asyncio 82 | import pathlib 83 | 84 | from aiohttp.client import ClientSession 85 | 86 | from octomachinery.github.api.app_client import GitHubApp 87 | from octomachinery.github.config.app import GitHubAppIntegrationConfig 88 | 89 | 90 | target_github_account_or_org = 'webknjaz' # where the app is installed to 91 | 92 | github_app_id = 12345 93 | github_app_private_key_path = pathlib.Path( 94 | '~/Downloads/star-wars.2011-05-04.private-key.pem', 95 | ).expanduser().resolve() 96 | 97 | github_app_config = GitHubAppIntegrationConfig( 98 | app_id=github_app_id, 99 | private_key=github_app_private_key_path.read_text(), 100 | 101 | app_name='MyGitHubClient', 102 | app_version='1.0', 103 | app_url='https://awesome-app.dev', 104 | ) 105 | 106 | 107 | async def get_github_client(github_app, account): 108 | github_app_installations = await github_app.get_installations() 109 | target_github_app_installation = next( # find the one 110 | ( 111 | i for n, i in github_app_installations.items() 112 | if i._metadata.account['login'] == account 113 | ), 114 | None, 115 | ) 116 | return target_github_app_installation.api_client 117 | 118 | 119 | async def main(): 120 | async with ClientSession() as http_session: 121 | github_app = GitHubApp(github_app_config, http_session) 122 | github_api = await get_github_client( 123 | github_app, target_github_account_or_org, 124 | ) 125 | user = await github_api.getitem( 126 | '/users/{account_name}', 127 | url_vars={'account_name': target_github_account_or_org}, 128 | ) 129 | print(f'User found: {user["login"]}') 130 | print(f'Rate limit stats: {github_api.rate_limit!s}') 131 | 132 | 133 | asyncio.run(main()) 134 | 135 | 136 | Making API queries against preview endpoints 137 | -------------------------------------------- 138 | 139 | Endpoints with stable interfaces in GitHub API are easy to hit. But some 140 | are marked as preview API. For those, GitHub requires special Accept 141 | headers to be passed along with a normal HTTP request. The exact strings 142 | are documented at https://developers.github.com under specific endpoint 143 | sections in their description. 144 | 145 | Given that you've already got an instance of 146 | :py:class:`~octomachinery.github.api.raw_client.RawGitHubAPI` 147 | initialized, what's left is to pass ``preview_api_version`` argument 148 | with the appropriate preview API code name when making query to the API 149 | endpoint requiring that. 150 | 151 | .. code:: python 152 | 153 | github_api: RawGitHubAPI 154 | 155 | repo_slug = 'sanitizers/octomachinery' 156 | issue_number = 15 157 | 158 | await github_api.post( 159 | f'/repos/{repo_slug}/issues/{issue_number}/reactions', 160 | preview_api_version='squirrel-girl', 161 | data={'content': 'heart'}, 162 | ) 163 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = python 3 | minversion = 3.21.0 4 | requires = 5 | setuptools >= 40.9.0 6 | pip >= 19.0.3 7 | # tox-venv >= 0.4.0 8 | isolated_build = true 9 | 10 | 11 | [testenv] 12 | package = wheel 13 | wheel_build_env = .pkg 14 | basepython = python3 15 | isolated_build = true 16 | usedevelop = false 17 | extras = 18 | testing 19 | commands = 20 | {envpython} -m \ 21 | pytest \ 22 | {tty:--color=yes} \ 23 | {posargs:--cov-report=term-missing:skip-covered} 24 | 25 | 26 | [testenv:old-deps] 27 | description = 28 | Run tests with the lowest possible dependency versions as opposed to 29 | using the latest ones 30 | commands_pre = 31 | # NOTE: Not using `deps = ` for this because installing the dist 32 | # NOTE: happens later and overrides it. 33 | # NOTE: With this step we're setting certain packages to the lowest 34 | # NOTE: versions — either limited by the runtime deps or just known 35 | # NOTE: to work. 36 | # NOTE: For example, PyJWT versions below 1.4.2 cause TypeError when 37 | # NOTE: the PEM key is passed as bytes. 38 | # Ref: https://github.com/sanitizers/octomachinery/issues/46: 39 | {envpython} -m pip install \ 40 | 'cryptography == 2.4.2' \ 41 | 'gidgethub == 4.2.0' \ 42 | 'pyjwt == 1.4.2; python_version < "3.10"' \ 43 | 'pyjwt == 1.7.0; python_version >= "3.10"' \ 44 | 'pytest-aiohttp == 0.1.0' \ 45 | 'setuptools == 35.0.2; python_version < "3.10"' \ 46 | 'setuptools == 41; python_version >= "3.10"' \ 47 | 'setuptools-scm == 1.15.4' 48 | setenv = 49 | PYTEST_ADDOPTS = -p no:warnings 50 | 51 | 52 | [testenv:check-docs] 53 | basepython = python3 54 | isolated_build = true 55 | # `usedevelop = true` overrides `skip_install` instruction, it's unwanted 56 | usedevelop = false 57 | ## don't install octomachinery itself in this env 58 | #skip_install = true 59 | #usedevelop = true 60 | extras = 61 | docs 62 | # testing 63 | #deps = 64 | # pip >= 18 65 | changedir = docs 66 | commands = 67 | {envpython} -m sphinx \ 68 | -b linkcheck \ 69 | -j auto \ 70 | {tty:--color} \ 71 | -n \ 72 | -d {toxinidir}/build/.doctrees \ 73 | . \ 74 | {toxinidir}/build/html 75 | 76 | 77 | [testenv:build-docs] 78 | basepython = python3 79 | isolated_build = true 80 | # `usedevelop = true` overrides `skip_install` instruction, it's unwanted 81 | usedevelop = false 82 | ## don't install octomachinery itself in this env 83 | #skip_install = true 84 | #usedevelop = true 85 | extras = 86 | docs 87 | # testing 88 | #deps = 89 | # pip >= 18 90 | changedir = docs 91 | commands = 92 | # FIXME: Add -W option below once all other warnings are gone 93 | {envpython} -m sphinx \ 94 | -j auto \ 95 | -b html \ 96 | {tty:--color} \ 97 | -n \ 98 | -d "{toxinidir}/build/.doctrees" \ 99 | . \ 100 | "{toxinidir}/build/html" 101 | 102 | # Print out the output docs dir and a way to serve html: 103 | -{envpython} -c \ 104 | 'import pathlib; docs_dir = pathlib.Path(r"{toxinidir}") / "build" / "html"; index_file = docs_dir / "index.html"; '\ 105 | 'print("\n" + "=" * 120 + f"\n\nDocumentation available under `file://\{index_file\}`\n\nTo serve docs, use `python3 -m http.server --directory \{docs_dir\} 0`\n\n" + "=" * 120)' 106 | 107 | 108 | [testenv:build-dists] 109 | description = 110 | Build dists and put them into the dist{/} folder 111 | basepython = python3 112 | isolated_build = true 113 | # `usedevelop = true` overrides `skip_install` instruction, it's unwanted 114 | usedevelop = false 115 | # don't install octomachinery itself in this env 116 | skip_install = true 117 | deps = 118 | build >= 0.3.1, < 0.4.0 119 | commands = 120 | {envpython} -c \ 121 | "import shutil; \ 122 | shutil.rmtree('{toxinidir}{/}dist{/}', ignore_errors=True)" 123 | 124 | {envpython} -m build \ 125 | --outdir '{toxinidir}{/}dist{/}' \ 126 | {posargs:--sdist --wheel} \ 127 | '{toxinidir}' 128 | 129 | 130 | [testenv:metadata-validation] 131 | depends = 132 | build-dists 133 | deps = 134 | twine 135 | description = 136 | Verify that dists under the `dist{/}` 137 | dir have valid metadata 138 | # Ref: https://twitter.com/di_codes/status/1044358639081975813 139 | commands = 140 | {envpython} -m \ 141 | twine check \ 142 | {toxinidir}{/}dist{/}* 143 | 144 | # Install an sdist and a wheel into tmp dirs for further comparison 145 | {envpython} -c \ 146 | "import shutil; \ 147 | shutil.rmtree('{temp_dir}{/}.installed{/}', ignore_errors=True)" 148 | {envpython} -m \ 149 | pip install octomachinery \ 150 | --no-index \ 151 | -f "{toxinidir}{/}dist{/}" \ 152 | --no-deps \ 153 | --only-binary octomachinery \ 154 | --no-compile --no-cache-dir \ 155 | -t "{temp_dir}{/}.installed{/}from-whl{/}" 156 | # Pre-download build deps for installing sdist 157 | {envpython} -m \ 158 | pip download \ 159 | setuptools \ 160 | setuptools_scm \ 161 | setuptools_scm_git_archive \ 162 | wheel \ 163 | -d "{temp_dir}{/}.build-deps{/}" 164 | {envpython} -m \ 165 | pip install octomachinery \ 166 | --no-index \ 167 | -f "{toxinidir}{/}dist{/}" \ 168 | -f "{temp_dir}{/}.build-deps{/}" \ 169 | --no-deps \ 170 | --no-binary octomachinery \ 171 | --no-compile --no-cache-dir \ 172 | -t "{temp_dir}{/}.installed{/}from-sdist{/}" 173 | 174 | # Normalize known content difference 175 | sh -c 'sed -i "s#^\(octomachinery-.*.dist-info/top_level.txt,sha256=\).*#\1#" \ 176 | {temp_dir}{/}.installed{/}from-*{/}octomachinery-*.dist-info{/}RECORD' 177 | sh -c 'rm -rfv \ 178 | {temp_dir}{/}.installed{/}from-*{/}octomachinery-*.dist-info{/}top_level.txt' 179 | 180 | # Compare wheel and sdist installs recursively as a smoke-test 181 | diff -ur \ 182 | "{temp_dir}{/}.installed{/}from-whl" \ 183 | "{temp_dir}{/}.installed{/}from-sdist" 184 | skip_install = true 185 | usedevelop = false 186 | allowlist_externals = 187 | diff 188 | sh 189 | 190 | 191 | [testenv:pre-commit] 192 | isolated_build = true 193 | deps = 194 | pre-commit 195 | commands = 196 | {envpython} -m pre_commit run --show-diff-on-failure {posargs:--all-files} 197 | 198 | # Print out the advise of how to install pre-commit from this env into Git: 199 | -{envpython} -c \ 200 | 'cmd = "{envpython} -m pre_commit install"; scr_width = len(cmd) + 10; sep = "=" * scr_width; cmd_str = " $ " + cmd; '\ 201 | 'print("\n" + sep + "\nTo install pre-commit hooks into the Git repo, run:\n\n" + cmd_str + "\n\n" + sep + "\n")' 202 | skip_install = true 203 | -------------------------------------------------------------------------------- /octomachinery/cli/__main__.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | """Octomachinery CLI entrypoint.""" 3 | 4 | import asyncio 5 | import importlib 6 | import os 7 | import pathlib 8 | from functools import wraps 9 | from io import TextIOWrapper 10 | from typing import ( 11 | Any, Callable, Dict, FrozenSet, Iterable, Iterator, Set, Union, 12 | ) 13 | 14 | from aiohttp.client import ClientSession 15 | 16 | import click 17 | 18 | # pylint: disable=relative-beyond-top-level 19 | from ..app.config import BotAppConfig 20 | # pylint: disable=relative-beyond-top-level 21 | from ..app.routing.abc import OctomachineryRouterBase 22 | # pylint: disable=relative-beyond-top-level 23 | from ..app.routing.webhooks_dispatcher import route_github_event 24 | # pylint: disable=relative-beyond-top-level 25 | from ..github.api.app_client import GitHubApp 26 | # pylint: disable=relative-beyond-top-level 27 | from ..github.entities.action import GitHubAction 28 | # pylint: disable=relative-beyond-top-level 29 | # pylint: disable=relative-beyond-top-level 30 | from ..github.models.events import GitHubEvent, GitHubWebhookEvent 31 | 32 | 33 | @click.group() 34 | @click.pass_context 35 | def cli(ctx: click.Context) -> None: # pylint: disable=unused-argument 36 | """Click CLI base.""" 37 | 38 | 39 | def run_async(orig_async_func: Callable[..., Any]): 40 | """Run the given async func in event loop.""" 41 | @wraps(orig_async_func) 42 | def func_wrapper(*args: Any, **kwargs: Any): 43 | return asyncio.run(orig_async_func(*args, **kwargs)) 44 | return func_wrapper 45 | 46 | 47 | @cli.command() 48 | @click.option('--event', '-e', prompt=False, type=str) 49 | @click.option( 50 | '--payload-path', '-p', 51 | 'event_payload', 52 | prompt=True, 53 | type=click.File(mode='r'), 54 | ) 55 | @click.option('--token', '-t', prompt=False, type=str) 56 | @click.option('--app', '-a', prompt=False, type=int) 57 | @click.option('--private-key', '-P', prompt=False, type=click.File(mode='r')) 58 | @click.option('--entrypoint-module', '-m', prompt=False, type=str) 59 | @click.option( 60 | '--event-router', '-r', 61 | 'event_routers', 62 | multiple=True, 63 | prompt=False, 64 | type=str, 65 | ) 66 | @click.pass_context 67 | @run_async 68 | async def receive( # pylint: disable=too-many-arguments,too-many-locals 69 | ctx: click.Context, 70 | event: str, event_payload: TextIOWrapper, 71 | token: str, 72 | app: int, private_key: TextIOWrapper, 73 | entrypoint_module: str, 74 | event_routers: Iterable[str], 75 | ) -> None: 76 | """Webhook event receive command.""" 77 | app_missing_private_key = app is not None and not private_key 78 | if app_missing_private_key: 79 | ctx.fail(click.style('App requires a private key', fg='red')) 80 | 81 | creds_present = token or (app and private_key) 82 | if not creds_present: 83 | ctx.fail(click.style('GitHub auth credentials are missing', fg='red')) 84 | 85 | too_many_creds_present = token and (app or private_key) 86 | if too_many_creds_present: 87 | ctx.fail( 88 | click.style( 89 | 'Please choose between a token or ' 90 | 'an app id with a private key', 91 | fg='red', 92 | ), 93 | ) 94 | 95 | make_event = GitHubEvent if app is None else GitHubWebhookEvent 96 | try: 97 | gh_event = make_event.from_fixture_fd(event_payload, event=event) 98 | except ValueError as val_err: 99 | ctx.fail(click.style(str(val_err), fg='red')) 100 | 101 | os.environ.update(get_extra_env_vars(gh_event, token, app, private_key)) 102 | 103 | try: 104 | target_routers = set( 105 | load_event_routers( 106 | entrypoint_module, 107 | # pylint: disable=fixme 108 | event_routers, # type: ignore[arg-type] # FIXME: typing 109 | ), 110 | ) 111 | except AttributeError as attr_err: 112 | ctx.fail( 113 | click.style( 114 | f'Could not find an event router: {attr_err!s}', 115 | fg='red', 116 | ), 117 | ) 118 | except ImportError as imp_err: 119 | ctx.fail( 120 | click.style(f'Could not load a module: {imp_err!s}', fg='red'), 121 | ) 122 | 123 | config = BotAppConfig.from_dotenv() 124 | gh_app_kwargs = {'config': config.github} 125 | make_gh_app = GitHubApp 126 | if app is None: 127 | make_gh_app = GitHubAction 128 | gh_app_kwargs['metadata'] = config.action 129 | async with ClientSession() as http_client_session: 130 | github_app = make_gh_app( 131 | http_session=http_client_session, 132 | # FIXME: typing # pylint: disable=fixme 133 | event_routers=target_routers or None, # type: ignore[arg-type] 134 | **gh_app_kwargs, 135 | ) 136 | await route_github_event( 137 | github_event=gh_event, github_app=github_app, 138 | ) 139 | 140 | click.echo( 141 | click.style( 142 | f'Finished processing {gh_event.name!s} event!', 143 | fg='green', 144 | ), 145 | ) 146 | 147 | 148 | def load_event_routers( 149 | entrypoint_module: Union[str, None] = None, 150 | event_routers: Union[FrozenSet[str], Set[str]] = frozenset(), 151 | ) -> Iterator[OctomachineryRouterBase]: 152 | """Yield event routers from strings.""" 153 | if entrypoint_module is not None: 154 | importlib.import_module(entrypoint_module) 155 | 156 | for router_path in event_routers: 157 | target_sep = ':' if ':' in router_path else '.' 158 | module_path, _sep, target_router = router_path.rpartition(target_sep) 159 | yield getattr(importlib.import_module(module_path), target_router) 160 | 161 | 162 | def get_extra_env_vars( 163 | gh_event: GitHubEvent, token: str, app: int, 164 | private_key: TextIOWrapper, 165 | ) -> Dict[str, str]: 166 | """Construct additional env vars for App or Action processing.""" 167 | env = {} 168 | 169 | if app is not None: 170 | env['OCTOMACHINERY_APP_MODE'] = 'app' 171 | 172 | env['GITHUB_APP_IDENTIFIER'] = str(app) 173 | env['GITHUB_PRIVATE_KEY'] = private_key.read() 174 | return env 175 | 176 | env['OCTOMACHINERY_APP_MODE'] = 'action' 177 | 178 | env['GITHUB_ACTION'] = 'Fake CLI Action' 179 | env['GITHUB_ACTOR'] = gh_event.payload.get('sender', {}).get('login', '') 180 | env['GITHUB_EVENT_NAME'] = gh_event.name 181 | env['GITHUB_WORKSPACE'] = str(pathlib.Path('.').resolve()) 182 | env['GITHUB_SHA'] = gh_event.payload.get('head_commit', {}).get('id', '') 183 | env['GITHUB_REF'] = gh_event.payload.get('ref', '') 184 | env['GITHUB_REPOSITORY'] = ( 185 | gh_event.payload. 186 | get('repository', {}). 187 | get('full_name', '') 188 | ) 189 | env['GITHUB_TOKEN'] = token 190 | env['GITHUB_WORKFLOW'] = 'Fake CLI Workflow' 191 | env['GITHUB_EVENT_PATH'] = '/dev/null' 192 | 193 | return env 194 | 195 | 196 | def main(): 197 | """CLI entrypoint.""" 198 | kwargs = { 199 | 'prog_name': f'python3 -m {__package__}', 200 | } if __name__ == '__main__' else {} 201 | 202 | return cli( # pylint: disable=unexpected-keyword-arg 203 | auto_envvar_prefix='OCTOMACHINERY_CLI_', 204 | obj={}, 205 | **kwargs, 206 | ) 207 | 208 | 209 | __name__ == '__main__' and main() # pylint: disable=expression-not-assigned 210 | -------------------------------------------------------------------------------- /octomachinery/github/models/checks_api_requests.py: -------------------------------------------------------------------------------- 1 | """Models representing objects in GitHub Checks API.""" 2 | 3 | from functools import partial 4 | from typing import List, Optional 5 | 6 | import attr 7 | 8 | 9 | __all__ = ('NewCheckRequest', 'UpdateCheckRequest', 'to_gh_query') 10 | 11 | 12 | str_attrib = partial( # pylint: disable=invalid-name 13 | attr.ib, 14 | converter=lambda s: str(s) if s is not None else '', 15 | ) 16 | 17 | int_attrib = partial(attr.ib, converter=int) # pylint: disable=invalid-name 18 | 19 | optional_attrib = partial( # pylint: disable=invalid-name 20 | attr.ib, 21 | default=None, 22 | ) 23 | 24 | optional_int_attrib = partial( # pylint: disable=invalid-name 25 | optional_attrib, 26 | validator=attr.validators.optional(lambda *_: int(_[-1])), 27 | ) 28 | 29 | optional_str_attrib = partial( # pylint: disable=invalid-name 30 | optional_attrib, 31 | validator=attr.validators.optional(lambda *_: str(_[-1])), 32 | ) 33 | 34 | optional_list_attrib = partial( # pylint: disable=invalid-name 35 | attr.ib, 36 | default=[], 37 | validator=attr.validators.optional(lambda *_: list(_[-1])), 38 | ) 39 | 40 | 41 | def optional_converter(kwargs_dict, convert_to_cls): 42 | """Instantiate a class instances from dict.""" 43 | if kwargs_dict is not None and not isinstance(kwargs_dict, convert_to_cls): 44 | return convert_to_cls(**kwargs_dict) 45 | return kwargs_dict 46 | 47 | 48 | def optional_list_converter(args_list, convert_to_cls): 49 | """Convert list items to class instances.""" 50 | if args_list is not None and isinstance(args_list, list): 51 | return [ 52 | optional_converter(kwargs_dict, convert_to_cls) 53 | for kwargs_dict in args_list 54 | ] 55 | return args_list 56 | 57 | 58 | @attr.dataclass 59 | class CheckAnnotation: # pylint: disable=too-few-public-methods 60 | """Checks API annotation struct.""" 61 | 62 | path: str = str_attrib() 63 | start_line: int = int_attrib() 64 | end_line: int = int_attrib() 65 | annotation_level: str = str_attrib( 66 | validator=attr.validators.in_( 67 | ( 68 | 'notice', 69 | 'warning', 70 | 'failure', 71 | ), 72 | ), 73 | ) 74 | message: str = str_attrib() 75 | start_column: Optional[int] = optional_int_attrib() 76 | end_column: Optional[int] = optional_int_attrib() 77 | title: Optional[str] = optional_str_attrib() 78 | raw_details: Optional[str] = optional_str_attrib() 79 | 80 | 81 | @attr.dataclass 82 | class CheckImage: # pylint: disable=too-few-public-methods 83 | """Checks API image struct.""" 84 | 85 | alt: str = str_attrib() 86 | image_url: str = str_attrib() 87 | caption: Optional[str] = optional_str_attrib() 88 | 89 | 90 | @attr.dataclass 91 | class CheckActions: 92 | """Checks API actions struct.""" 93 | 94 | label: str = str_attrib() 95 | description: str = str_attrib() 96 | identifier: str = str_attrib() 97 | 98 | @label.validator 99 | def label_up_to_20(self, attribute, value): 100 | """Ensure that label is under 20.""" 101 | if len(value) > 20: 102 | raise ValueError( 103 | f'`{attribute.name}` must not exceed 20 characters.', 104 | ) 105 | 106 | @description.validator 107 | def description_up_to_40(self, attribute, value): 108 | """Ensure that description is under 40.""" 109 | if len(value) > 40: 110 | raise ValueError( 111 | f'`{attribute.name}` must not exceed 40 characters.', 112 | ) 113 | 114 | @identifier.validator 115 | def identifier_up_to_20(self, attribute, value): 116 | """Ensure that identifier is under 20.""" 117 | if len(value) > 20: 118 | raise ValueError( 119 | f'`{attribute.name}` must not exceed 20 characters.', 120 | ) 121 | 122 | 123 | @attr.dataclass 124 | class CheckOutput: # pylint: disable=too-few-public-methods 125 | """Checks API output struct.""" 126 | 127 | title: str = str_attrib() 128 | summary: str = str_attrib() 129 | text: str = str_attrib(default='') 130 | annotations: List[CheckAnnotation] = optional_list_attrib( 131 | converter=partial( 132 | optional_list_converter, 133 | convert_to_cls=CheckAnnotation, 134 | ), 135 | ) 136 | images: List[CheckImage] = optional_list_attrib( 137 | converter=partial(optional_list_converter, convert_to_cls=CheckImage), 138 | ) 139 | 140 | 141 | @attr.dataclass 142 | class BaseCheckRequestMixin: 143 | """Checks API base check request mixin.""" 144 | 145 | name: str = str_attrib() 146 | details_url: Optional[str] = optional_str_attrib() 147 | external_id: Optional[str] = optional_str_attrib() 148 | status: Optional[str] = attr.ib( 149 | default='queued', 150 | validator=attr.validators.optional( # type: ignore[arg-type] 151 | attr.validators.in_( 152 | ( 153 | 'queued', 154 | 'in_progress', 155 | 'completed', 156 | ), 157 | ), 158 | ), 159 | ) 160 | # '2018-05-27T14:30:33Z', datetime.isoformat(): 161 | started_at: Optional[str] = optional_str_attrib() 162 | conclusion: Optional[str] = attr.ib( 163 | # [required] if 'status' is set to 'completed', 164 | # should be missing if it's unset 165 | default=None, 166 | validator=attr.validators.optional( 167 | attr.validators.in_( 168 | ( 169 | 'success', 170 | 'failure', 171 | 'neutral', 172 | 'cancelled', 173 | 'timed_out', 174 | 'action_required', 175 | ), 176 | ), 177 | ), 178 | ) 179 | # [required] if 'conclusion' is set # '2018-05-27T14:30:33Z': 180 | completed_at: Optional[str] = optional_str_attrib() 181 | output: Optional[CheckOutput] = optional_attrib( 182 | converter=partial(optional_converter, convert_to_cls=CheckOutput), 183 | ) 184 | actions: List[CheckActions] = optional_list_attrib( 185 | converter=partial( 186 | optional_list_converter, 187 | convert_to_cls=CheckActions, 188 | ), 189 | ) 190 | 191 | @conclusion.validator 192 | def depends_on_status(self, attribute, value): 193 | """Ensure that conclusion is present if there's status.""" 194 | if self.status == 'completed' and not value: 195 | raise ValueError( 196 | f'`{attribute.name}` must be provided if status is completed', 197 | ) 198 | 199 | @completed_at.validator 200 | def depends_on_conclusion(self, attribute, value): 201 | """Ensure that completed is present if there's conclusion.""" 202 | if self.conclusion is not None and not value: 203 | raise ValueError( 204 | f'`{attribute.name}` must be provided ' 205 | 'if conclusion is present', 206 | ) 207 | 208 | @actions.validator 209 | def actions_up_to_3(self, attribute, value): 210 | """Ensure that the number of actions is below 3.""" 211 | if value is not None and len(value) > 3: 212 | raise ValueError(f'`{attribute.name}` must not exceed 3 items.') 213 | 214 | 215 | @attr.dataclass 216 | class NewCheckRequestMixin: # pylint: disable=too-few-public-methods 217 | """Checks API new check request mixin.""" 218 | 219 | head_branch: str = str_attrib() 220 | head_sha: str = str_attrib() 221 | 222 | 223 | @attr.dataclass 224 | class NewCheckRequest(NewCheckRequestMixin, BaseCheckRequestMixin): 225 | """Checks API new check request.""" 226 | 227 | 228 | @attr.dataclass 229 | class UpdateCheckRequest(BaseCheckRequestMixin): 230 | """Checks API update check request.""" 231 | 232 | 233 | def conditional_to_gh_query(req): 234 | """Traverse Checks API request structure.""" 235 | if hasattr(req, '__attrs_attrs__'): 236 | return to_gh_query(req) 237 | if isinstance(req, list): 238 | return list(map(conditional_to_gh_query, req)) 239 | if isinstance(req, dict): 240 | return { 241 | k: conditional_to_gh_query(v) for k, v in req.items() 242 | if v is not None or (isinstance(v, (list, dict)) and not v) 243 | } 244 | return req 245 | 246 | 247 | def to_gh_query(req): 248 | """Convert Checks API request object into a dict.""" 249 | return { 250 | k: conditional_to_gh_query(v) # recursive if dataclass or list 251 | for k, v in attr.asdict(req).items() 252 | if v is not None or (isinstance(v, (list, dict)) and not v) 253 | } 254 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Configuration of Sphinx documentation generator. 3 | 4 | This file does only contain a selection of the most common options. For a 5 | full list see the documentation: 6 | http://www.sphinx-doc.org/en/master/config 7 | """ 8 | 9 | from email import message_from_string 10 | from itertools import chain 11 | 12 | import pkg_resources 13 | 14 | 15 | # -- Path setup -------------------------------------------------------------- 16 | 17 | # If extensions (or modules to document with autodoc) are in another directory, 18 | # add these directories to sys.path here. If the directory is relative to the 19 | # documentation root, use os.path.abspath to make it absolute, like shown here. 20 | # 21 | # import os 22 | # import sys 23 | # sys.path.insert(0, os.path.abspath('.')) 24 | 25 | 26 | # -- Project information ----------------------------------------------------- 27 | 28 | def get_supported_pythons(classifiers): 29 | """Return min and max supported Python version from meta as tuples.""" 30 | py_ver_classifier = 'Programming Language :: Python :: ' 31 | vers = filter(lambda c: c.startswith(py_ver_classifier), classifiers) 32 | vers = map(lambda c: c[len(py_ver_classifier):], vers) 33 | vers = filter(lambda c: c[0].isdigit() and '.' in c, vers) 34 | vers = map(lambda c: tuple(c.split('.')), vers) 35 | vers = sorted(vers) 36 | del vers[1:-1] 37 | if len(vers) < 2: 38 | vers *= 2 39 | return vers 40 | 41 | 42 | def get_github_data(project_urls): 43 | """Retrieve GitHub user/org and repo name from a bunch of links.""" 44 | partitioned_urls = (p.partition(', ') for p in project_urls) 45 | for _url_type, _sep, url in partitioned_urls: 46 | proto, _gh, uri = url.partition('://github.com/') 47 | if proto not in ('http', 'https'): 48 | continue 49 | return uri.split('/')[:2] 50 | 51 | raise LookupError('There are no project URLs pointing to GitHub') 52 | 53 | 54 | PYTHON_DISTRIBUTION_NAME = 'octomachinery' 55 | 56 | PRJ_DIST = pkg_resources.get_distribution(PYTHON_DISTRIBUTION_NAME) 57 | PRJ_PKG_INFO = PRJ_DIST.get_metadata(PRJ_DIST.PKG_INFO) 58 | PRJ_META = message_from_string(PRJ_PKG_INFO) 59 | PRJ_AUTHOR = PRJ_META['Author'] 60 | PRJ_LICENSE = PRJ_META['License'] 61 | PRJ_SUMMARY = PRJ_META['Summary'] 62 | PRJ_DESCRIPTION = PRJ_META['Description'] 63 | PRJ_PY_VER_RANGE = get_supported_pythons(PRJ_META.get_all('Classifier')) 64 | PRJ_PY_MIN_SUPPORTED, PRJ_PY_MAX_SUPPORTED = map('.'.join, PRJ_PY_VER_RANGE) 65 | PRJ_GITHUB_USER, PRJ_GITHUB_REPO = get_github_data( 66 | chain( 67 | (PRJ_META['Home-page'],), 68 | PRJ_META.get_all('Project-URL'), 69 | ), 70 | ) 71 | 72 | project = PRJ_DIST.project_name # pylint: disable=invalid-name 73 | author = PRJ_AUTHOR # pylint: disable=invalid-name 74 | copyright = f'2019, {author}' # pylint: disable=invalid-name,redefined-builtin 75 | 76 | # The full version, including alpha/beta/rc tags 77 | release = PRJ_DIST.version # pylint: disable=invalid-name 78 | # The short X.Y version 79 | # pylint: disable=invalid-name 80 | version = pkg_resources.parse_version(release).base_version 81 | 82 | rst_epilog = f""" 83 | .. |project| replace:: {project} 84 | .. |min_py_supported| replace:: {PRJ_PY_MIN_SUPPORTED} 85 | .. |max_py_supported| replace:: {PRJ_PY_MAX_SUPPORTED} 86 | """ # pylint: disable=invalid-name 87 | 88 | 89 | # -- General configuration --------------------------------------------------- 90 | 91 | # If your documentation needs a minimal Sphinx version, state it here. 92 | # 93 | needs_sphinx = '1.7.5' 94 | 95 | # Add any Sphinx extension module names here, as strings. They can be 96 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 97 | # ones. 98 | extensions = [ 99 | 'sphinx.ext.autodoc', 100 | 'sphinx.ext.doctest', 101 | 'sphinx.ext.intersphinx', 102 | 'sphinx.ext.todo', 103 | 'sphinx.ext.coverage', 104 | 'sphinx.ext.mathjax', 105 | 'sphinx.ext.ifconfig', 106 | 'sphinx.ext.viewcode', 107 | 'sphinx.ext.githubpages', 108 | 'sphinxcontrib.apidoc', 109 | ] 110 | 111 | # sphinxcontrib.apidoc configuration options 112 | apidoc_extra_args = ['--implicit-namespaces', '../octomachinery'] 113 | apidoc_module_dir = '.' 114 | apidoc_output_dir = 'reference' 115 | apidoc_separate_modules = True 116 | 117 | # Add any paths that contain templates here, relative to this directory. 118 | templates_path = ['_templates'] 119 | 120 | # The suffix(es) of source filenames. 121 | # You can specify multiple suffix as a list of string: 122 | # 123 | # source_suffix = ['.rst', '.md'] 124 | source_suffix = '.rst' 125 | 126 | # The master toctree document. 127 | master_doc = 'index' 128 | 129 | # The language for content autogenerated by Sphinx. Refer to documentation 130 | # for a list of supported languages. 131 | # 132 | # This is also used if you do content translation via gettext catalogs. 133 | # Usually you set "language" from the command line for these cases. 134 | language = None 135 | 136 | # List of patterns, relative to source directory, that match files and 137 | # directories to ignore when looking for source files. 138 | # This pattern also affects html_static_path and html_extra_path . 139 | exclude_patterns = [] 140 | 141 | # The name of the Pygments (syntax highlighting) style to use. 142 | # NOTE: These values are commented out so that Furo fall back to a nice style 143 | # pygments_style = 'sphinx' 144 | # pygments_dark_style = 'monokai' 145 | 146 | 147 | # -- Options for HTML output ------------------------------------------------- 148 | 149 | # The theme to use for HTML and HTML Help pages. See the documentation for 150 | # a list of builtin themes. 151 | # 152 | html_theme = 'furo' 153 | 154 | # Theme options are theme-specific and customize the look and feel of a theme 155 | # further. For a list of options available for each theme, see the 156 | # documentation. 157 | # 158 | # html_theme_options = {} 159 | 160 | # Add any paths that contain custom static files (such as style sheets) here, 161 | # relative to this directory. They are copied after the builtin static files, 162 | # so a file named "default.css" will overwrite the builtin "default.css". 163 | html_static_path = ['_static'] 164 | 165 | # Custom sidebar templates, must be a dictionary that maps document names 166 | # to template names. 167 | # 168 | # The default sidebars (for documents that don't match any pattern) are 169 | # defined by theme itself. Builtin themes are using these templates by 170 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 171 | # 'searchbox.html']``. 172 | # 173 | html_sidebars = { 174 | '**': ( 175 | 'sidebar/brand.html', 176 | 'project-description.html', 177 | 'sidebar/search.html', 178 | 'sidebar/scroll-start.html', 179 | 'sidebar/navigation.html', 180 | 'github-sponsors.html', 181 | 'sidebar/ethical-ads.html', 182 | 'sidebar/scroll-end.html', 183 | ), 184 | } 185 | 186 | 187 | # -- Options for HTMLHelp output --------------------------------------------- 188 | 189 | # Output file base name for HTML help builder. 190 | htmlhelp_basename = f'{project}doc' 191 | 192 | 193 | # -- Options for LaTeX output ------------------------------------------------ 194 | 195 | latex_elements = { 196 | # The paper size ('letterpaper' or 'a4paper'). 197 | # 198 | # 'papersize': 'letterpaper', 199 | 200 | # The font size ('10pt', '11pt' or '12pt'). 201 | # 202 | # 'pointsize': '10pt', 203 | 204 | # Additional stuff for the LaTeX preamble. 205 | # 206 | # 'preamble': '', 207 | 208 | # Latex figure (float) alignment 209 | # 210 | # 'figure_align': 'htbp', 211 | } 212 | 213 | # Grouping the document tree into LaTeX files. List of tuples 214 | # (source start file, target name, title, 215 | # author, documentclass [howto, manual, or own class]). 216 | latex_documents = [ 217 | ( 218 | master_doc, f'{project}.tex', f'{project} Documentation', 219 | author, 'manual', 220 | ), 221 | ] 222 | 223 | 224 | # -- Options for manual page output ------------------------------------------ 225 | 226 | # One entry per manual page. List of tuples 227 | # (source start file, name, description, authors, manual section). 228 | man_pages = [ 229 | ( 230 | master_doc, project, f'{project} Documentation', 231 | [author], 1, 232 | ), 233 | ] 234 | 235 | 236 | # -- Options for Texinfo output ---------------------------------------------- 237 | 238 | # Grouping the document tree into Texinfo files. List of tuples 239 | # (source start file, target name, title, author, 240 | # dir menu entry, description, category) 241 | texinfo_documents = [ 242 | ( 243 | master_doc, project, f'{project} Documentation', 244 | author, project, 'One line description of project.', 245 | 'Miscellaneous', 246 | ), 247 | ] 248 | 249 | 250 | # -- Options for Epub output ------------------------------------------------- 251 | 252 | # Bibliographic Dublin Core info. 253 | epub_title = project 254 | epub_author = author 255 | epub_publisher = author 256 | epub_copyright = copyright 257 | 258 | # The unique identifier of the text. This can be a ISBN number 259 | # or the project homepage. 260 | # 261 | # epub_identifier = '' 262 | 263 | # A unique identification for the text. 264 | # 265 | # epub_uid = '' 266 | 267 | # A list of files that should not be packed into the epub file. 268 | epub_exclude_files = ['search.html'] 269 | 270 | 271 | # -- Extension configuration ------------------------------------------------- 272 | 273 | # -- Options for intersphinx extension --------------------------------------- 274 | 275 | # Example configuration for intersphinx: refer to the Python standard library. 276 | intersphinx_mapping = { 277 | 'gidgethub': ('https://gidgethub.readthedocs.io/en/latest/', None), 278 | 'python': ('https://docs.python.org/', None), 279 | 'tutorial': ('https://tutorial.octomachinery.dev/en/latest/', None), 280 | } 281 | 282 | # -- Options for todo extension ---------------------------------------------- 283 | 284 | # If true, `todo` and `todoList` produce output, else they produce nothing. 285 | todo_include_todos = True 286 | 287 | 288 | def set_up_template_context( 289 | app, pagename, templatename, # pylint: disable=unused-argument 290 | context, 291 | doctree, # pylint: disable=unused-argument 292 | ): 293 | """Add a dist summary to Jinja2 context.""" 294 | context['theme_prj_summary'] = PRJ_SUMMARY 295 | 296 | 297 | def setup(app): 298 | """Patch the sphinx theme set up stage.""" 299 | app.connect('html-page-context', set_up_template_context) 300 | 301 | 302 | # Ref: https://github.com/python-attrs/attrs/pull/571/files\ 303 | # #diff-85987f48f1258d9ee486e3191495582dR82 304 | default_role = 'any' 305 | -------------------------------------------------------------------------------- /octomachinery/github/models/events.py: -------------------------------------------------------------------------------- 1 | """Generic GitHub event containers.""" 2 | 3 | from __future__ import annotations 4 | 5 | import json 6 | import pathlib 7 | import uuid 8 | import warnings 9 | from typing import ( 10 | TYPE_CHECKING, Any, Iterable, Mapping, TextIO, Type, Union, cast, 11 | ) 12 | 13 | from gidgethub.sansio import Event as _GidgetHubEvent 14 | 15 | import attr 16 | 17 | # pylint: disable=relative-beyond-top-level 18 | from ...utils.asynctools import aio_gather 19 | # pylint: disable=relative-beyond-top-level 20 | from ..utils.event_utils import ( 21 | augment_http_headers, parse_event_stub_from_fd, validate_http_headers, 22 | ) 23 | 24 | 25 | if TYPE_CHECKING: 26 | # pylint: disable=relative-beyond-top-level 27 | from ...app.routing.abc import OctomachineryRouterBase 28 | 29 | 30 | __all__ = 'GitHubEvent', 'GitHubWebhookEvent' 31 | 32 | 33 | def _to_uuid4(value: Union[str, uuid.UUID]) -> uuid.UUID: 34 | """Return a UUID from the value.""" 35 | if isinstance(value, uuid.UUID): 36 | return value 37 | 38 | return uuid.UUID(value, version=4) 39 | 40 | 41 | def _to_dict(value: Union[Mapping[str, Any], bytes, str]) -> Mapping[str, Any]: 42 | """Return a dict from the value.""" 43 | if isinstance(value, dict): 44 | return value 45 | 46 | if isinstance(value, bytes): 47 | value = value.decode() 48 | 49 | return json.loads(cast(str, value)) 50 | 51 | 52 | @attr.dataclass(frozen=True) 53 | class GitHubEvent: 54 | """Representation of a generic source-agnostic GitHub event.""" 55 | 56 | name: str 57 | """Event name.""" 58 | payload: Mapping[str, Any] = attr.ib(converter=_to_dict) 59 | """Event payload object.""" 60 | 61 | @payload.validator 62 | def _is_payload_dict( 63 | self, attribute: str, value: Mapping[str, Any], 64 | ) -> None: 65 | """Verify that the attribute value is a dict. 66 | 67 | :raises ValueError: if it's not 68 | """ 69 | if isinstance(value, dict): 70 | return 71 | 72 | raise ValueError( 73 | f'{value!r} is passed as {attribute!s} but it must ' 74 | 'be an instance of dict', 75 | ) 76 | 77 | @classmethod 78 | def from_file( 79 | cls: Type[GitHubEvent], 80 | event_name: str, 81 | event_path: Union[pathlib.Path, str], 82 | ) -> GitHubEvent: 83 | """Construct a GitHubEvent instance from event name and file.""" 84 | # NOTE: This could be async but it probably doesn't matter 85 | # NOTE: since it's called just once during init and GitHub 86 | # NOTE: Action runtime only has one event to process 87 | # NOTE: OTOH it may slow-down tests parallelism 88 | # NOTE: so may deserve to be fixed 89 | with pathlib.Path(event_path).open(encoding='utf-8') as event_source: 90 | return cls(event_name, json.load(event_source)) 91 | 92 | @classmethod 93 | def from_fixture_fd( 94 | cls: Type[GitHubEvent], 95 | event_fixture_fd: TextIO, 96 | *, 97 | event: Union[str, None] = None, 98 | ) -> GitHubEvent: 99 | """Make a GitHubEvent from a fixture fd and an optional name.""" 100 | headers, payload = parse_event_stub_from_fd(event_fixture_fd) 101 | if event and 'x-github-event' in headers: 102 | raise ValueError( 103 | 'Supply only one of an event name ' 104 | 'or an event header in the fixture file', 105 | ) 106 | event_name = event or headers['x-github-event'] 107 | return cls(event_name, payload) 108 | 109 | @classmethod 110 | def from_fixture( 111 | cls: Type[GitHubEvent], 112 | event_fixture_path: Union[pathlib.Path, str], 113 | *, 114 | event: Union[str, None] = None, 115 | ) -> GitHubEvent: 116 | """Make a GitHubEvent from a fixture and an optional name.""" 117 | with pathlib.Path( 118 | event_fixture_path, 119 | ).open(encoding='utf-8') as event_source: 120 | return cls.from_fixture_fd(event_source, event=event) 121 | 122 | @classmethod 123 | def from_gidgethub(cls, event: _GidgetHubEvent) -> GitHubEvent: 124 | """Construct GitHubEvent from from GidgetHub Event.""" 125 | return cls( 126 | name=event.event, 127 | payload=event.data, 128 | ) 129 | 130 | def to_gidgethub(self) -> _GidgetHubEvent: 131 | """Produce GidgetHub Event from self.""" 132 | return _GidgetHubEvent( 133 | data=self.payload, 134 | event=self.name, 135 | delivery_id=str(uuid.uuid4()), 136 | ) 137 | 138 | async def dispatch_via( 139 | self, 140 | *routers: OctomachineryRouterBase, 141 | ctx: Union[Mapping[str, Any], None] = None, 142 | ) -> Iterable[Any]: 143 | """Invoke this event handlers from different routers.""" 144 | if not routers: 145 | raise ValueError('At least one router must be supplied') 146 | 147 | if ctx is None: 148 | ctx = {} 149 | 150 | return await aio_gather( 151 | *( 152 | r.dispatch(self, **ctx) 153 | for r in routers 154 | ), 155 | ) 156 | 157 | 158 | @attr.dataclass(frozen=True) 159 | class GitHubWebhookEvent(GitHubEvent): 160 | """Representation of a GitHub event arriving by HTTP.""" 161 | 162 | delivery_id: uuid.UUID = attr.ib(converter=_to_uuid4) 163 | """A unique UUID4 identifier of the event delivery on GH side.""" 164 | 165 | @delivery_id.validator 166 | def _is_delivery_id(self, attribute: str, value: uuid.UUID) -> None: 167 | """Verify that the attribute value is UUID v4. 168 | 169 | :raises ValueError: if it's not 170 | """ 171 | if isinstance(value, uuid.UUID) and value.version == 4: 172 | return 173 | 174 | raise ValueError( 175 | f'{value!r} is passed as {attribute!s} but it must ' 176 | 'be an instance of UUID v4', 177 | ) 178 | 179 | @classmethod 180 | def from_file( 181 | cls: Type[GitHubWebhookEvent], 182 | event_name: str, 183 | event_path: Union[pathlib.Path, str], 184 | ) -> GitHubWebhookEvent: 185 | """Explode when constructing from file.""" 186 | raise RuntimeError( 187 | 'Webhook event is not supposed to be constructed from a file', 188 | ) 189 | 190 | @classmethod 191 | def from_fixture_fd( 192 | cls: Type[GitHubWebhookEvent], 193 | event_fixture_fd: TextIO, 194 | *, 195 | event: Union[str, None] = None, 196 | ) -> GitHubWebhookEvent: 197 | """Make GitHubWebhookEvent from fixture fd and optional name.""" 198 | headers, payload = parse_event_stub_from_fd(event_fixture_fd) 199 | if event and 'x-github-event' in headers: 200 | raise ValueError( 201 | 'Supply only one of an event name ' 202 | 'or an event header in the fixture file', 203 | ) 204 | headers['x-github-event'] = event or headers['x-github-event'] 205 | headers = augment_http_headers(headers) 206 | validate_http_headers(headers) 207 | 208 | return cls( 209 | name=headers['x-github-event'], 210 | payload=payload, 211 | delivery_id=headers['x-github-delivery'], 212 | ) 213 | 214 | @classmethod 215 | def from_fixture( 216 | cls: Type[GitHubWebhookEvent], 217 | event_fixture_path: Union[pathlib.Path, str], 218 | *, 219 | event: Union[str, None] = None, 220 | ) -> GitHubWebhookEvent: 221 | """Make a GitHubWebhookEvent from fixture and optional name.""" 222 | with pathlib.Path( 223 | event_fixture_path, 224 | ).open(encoding='utf-8') as event_source: 225 | return cls.from_fixture_fd(event_source, event=event) 226 | 227 | @classmethod 228 | def from_http_request( 229 | cls: Type[GitHubWebhookEvent], 230 | http_req_headers: Mapping[str, str], 231 | http_req_body: bytes, 232 | ): 233 | """Make a GitHubWebhookEvent from HTTP req headers and body.""" 234 | return cls( 235 | name=http_req_headers['x-github-event'], 236 | payload=json.loads(http_req_body.decode()), 237 | delivery_id=http_req_headers['x-github-delivery'], 238 | ) 239 | 240 | @classmethod 241 | def from_gidgethub(cls, event: _GidgetHubEvent) -> GitHubWebhookEvent: 242 | """Construct GitHubWebhookEvent from from GidgetHub Event.""" 243 | return cls( 244 | name=event.event, 245 | payload=event.data, 246 | delivery_id=event.delivery_id, 247 | ) 248 | 249 | def to_gidgethub(self) -> _GidgetHubEvent: 250 | """Produce GidgetHub Event from self.""" 251 | return _GidgetHubEvent( 252 | data=self.payload, 253 | event=self.name, # pylint: disable=no-member 254 | delivery_id=self.delivery_id, 255 | ) 256 | 257 | 258 | class GidgetHubEventMixin: 259 | """A temporary shim for GidgetHub event interfaces. 260 | 261 | It's designed to be used during the refactoring period when interfacing 262 | with the new event representation layer :py:class:`~GitHubEvent`. 263 | """ 264 | 265 | @property 266 | def data(self): 267 | """Event payload dict alias.""" 268 | warnings.warn( 269 | "Relying on GidgetHub's event class interfaces will be deprecated " 270 | "in the future releases. Please use 'payload' attribute to access " 271 | 'the event name instead.', 272 | category=PendingDeprecationWarning, 273 | stacklevel=2, 274 | ) 275 | # pylint: disable=fixme 276 | return self.payload # type: ignore[attr-defined] # FIXME 277 | 278 | @property 279 | def event(self): 280 | """Event name alias.""" 281 | warnings.warn( 282 | "Relying on GidgetHub's event class interfaces will be deprecated " 283 | "in the future releases. Please use 'name' attribute to access " 284 | 'the event name instead.', 285 | category=PendingDeprecationWarning, 286 | stacklevel=2, 287 | ) 288 | # pylint: disable=fixme 289 | return self.name # type: ignore[attr-defined] # FIXME 290 | 291 | 292 | class GidgetHubActionEvent(GidgetHubEventMixin, GitHubEvent): 293 | """GitHub Action event wrapper exposing GidgetHub attrs.""" 294 | 295 | 296 | class GidgetHubWebhookEvent(GidgetHubEventMixin, GitHubWebhookEvent): 297 | """GitHub HTTP event wrapper exposing GidgetHub attrs.""" 298 | -------------------------------------------------------------------------------- /tests/github/utils/event_utils_test.py: -------------------------------------------------------------------------------- 1 | """Tests for CLI utility functions.""" 2 | 3 | from io import StringIO 4 | from textwrap import dedent 5 | from uuid import uuid1, uuid4 6 | 7 | import multidict 8 | 9 | import pytest 10 | 11 | from octomachinery.github.utils.event_utils import ( 12 | _probe_json, _transform_http_headers_list_to_multidict, 13 | augment_http_headers, make_http_headers_from_event, 14 | parse_event_stub_from_fd, validate_http_headers, 15 | ) 16 | 17 | 18 | UNCHANGED_UUID4_STR = str(uuid4()) 19 | 20 | 21 | @pytest.mark.parametrize( 22 | 'vcr_contents, vcr_headers', 23 | ( 24 | pytest.param( 25 | dedent( 26 | """ 27 | --- 28 | # This needs to be a sequence of mappings; 29 | # it cannot be just a mapping because headers 30 | # may occur multiple times 31 | - Content-Type: application/json 32 | - X-GitHub-Delivery: 2791443c-641a-40fa-836d-031a26f0d45f 33 | - X-GitHub-Event: ping 34 | --- 35 | { 36 | "hook": {"app_id": 0}, 37 | "hook_id": 0, 38 | "zen": "Hey zen!" 39 | } 40 | """, 41 | ), 42 | multidict.CIMultiDict({ 43 | 'Content-Type': 'application/json', 44 | 'X-GitHub-Delivery': '2791443c-641a-40fa-836d-031a26f0d45f', 45 | 'X-GitHub-Event': 'ping', 46 | }), 47 | id='YAML', 48 | ), 49 | pytest.param( 50 | # pylint: disable=line-too-long 51 | """ 52 | [{"Content-Type": "application/json"}, {"X-GitHub-Delivery": "2791443c-641a-40fa-836d-031a26f0d45f"}, {"X-GitHub-Event": "ping"}] 53 | {"hook": {"app_id": 0}, "hook_id": 0, "zen": "Hey zen!"} 54 | """.strip(), # noqa: E501 55 | multidict.CIMultiDict({ 56 | 'Content-Type': 'application/json', 57 | 'X-GitHub-Delivery': '2791443c-641a-40fa-836d-031a26f0d45f', 58 | 'X-GitHub-Event': 'ping', 59 | }), 60 | id='JSONL', 61 | ), 62 | pytest.param( 63 | '{\n"hook": {\n"app_id": 0}, "hook_id": 0, "zen": "Hey zen!"}', 64 | multidict.CIMultiDict(), 65 | id='JSON', 66 | ), 67 | pytest.param( 68 | """ 69 | {"hook": {"app_id": 0}, "hook_id": 0, "zen": "Hey zen!"} 70 | { 71 | """.strip(), 72 | multidict.CIMultiDict(), 73 | id='JSONL with the broken 2nd line', 74 | ), 75 | ), 76 | ) 77 | def test_parse_event_stub_from_fd(vcr_contents, vcr_headers): 78 | """Check that all of YAML, JSONL and JSON VCR modes are loadable.""" 79 | vcr_event = { 80 | 'hook': { 81 | 'app_id': 0, 82 | }, 83 | 'hook_id': 0, 84 | 'zen': 'Hey zen!', 85 | } 86 | with StringIO(vcr_contents) as event_file_fd: 87 | actual_parsed_vcr = parse_event_stub_from_fd(event_file_fd) 88 | 89 | expected_parsed_vcr = vcr_headers, vcr_event 90 | assert actual_parsed_vcr == expected_parsed_vcr 91 | 92 | 93 | @pytest.mark.parametrize( 94 | 'vcr_contents', 95 | ( 96 | pytest.param( 97 | dedent( 98 | """ 99 | --- 100 | - X-GitHub-Event: ping 101 | --- 102 | {} 103 | --- 104 | - extra document 105 | """, 106 | ), 107 | id='broken 3-document YAML (must be 2 documents)', 108 | ), 109 | pytest.param( 110 | dedent( 111 | """ 112 | --- 113 | """, 114 | ), 115 | id='broken empty-document YAML', 116 | ), 117 | pytest.param( 118 | """ 119 | [{"X-GitHub-Event": "ping"}] 120 | {"hook": {"app_id": 0}, "hook_id": 0, "zen": "Hey zen!"} 121 | {} 122 | """, 123 | id='broken 3-line JSONL (must be 2 lines)', 124 | ), 125 | pytest.param( 126 | '1{"hook": {"app_id": 0}, "hook_id": 0, "zen": "Hey zen!"}', 127 | id='broken JSON syntax (leading garbage)', 128 | ), 129 | ), 130 | ) 131 | def test_parse_event_stub_from_fd__invalid(vcr_contents): 132 | """Verify that feeding unconventional VCRs raises ValueError.""" 133 | expected_error_message = ( 134 | r'^The input event VCR file has invalid structure\. ' 135 | r'It must be either of YAML, JSONL or JSON\.$' 136 | ) 137 | with StringIO(vcr_contents) as file_descr, pytest.raises( 138 | ValueError, 139 | match=expected_error_message, 140 | ): 141 | parse_event_stub_from_fd(file_descr) 142 | 143 | 144 | @pytest.mark.parametrize( 145 | 'http_headers', 146 | ( 147 | pytest.param( 148 | multidict.CIMultiDict({ 149 | 'Content-Type': 'application/json', 150 | 'User-Agent': 'GitHub-Hookshot/dict-test', 151 | 'X-GitHub-Delivery': str(uuid4()), 152 | 'X-GitHub-Event': 'issue', 153 | }), 154 | id='multidict', 155 | ), 156 | pytest.param( 157 | { 158 | 'content-type': 'application/json', 159 | 'user-agent': 'GitHub-Hookshot/dict-test', 160 | 'x-github-delivery': str(uuid4()), 161 | 'x-github-event': 'pull_request', 162 | }, 163 | id='dict', 164 | ), 165 | pytest.param( 166 | make_http_headers_from_event('ping'), 167 | id='make_http_headers_from_event constructor', 168 | ), 169 | ), 170 | ) 171 | def test_validate_http_headers(http_headers): 172 | """Verify that valid headers collections don't raise exceptions.""" 173 | assert validate_http_headers(http_headers) is None # no exceptions raised 174 | 175 | 176 | @pytest.mark.parametrize( 177 | 'http_headers, error_message', 178 | ( 179 | pytest.param( 180 | { 181 | 'content-type': 'multipart/form-data', 182 | }, 183 | r"^Content\-Type must be 'application\/json'$", 184 | id='Content-Type', 185 | ), 186 | pytest.param( 187 | { 188 | 'content-type': 'application/json', 189 | 'user-agent': 'Fake-GitHub-Hookshot/dict-test', 190 | }, 191 | r"^User\-Agent must start with 'GitHub-Hookshot\/'$", 192 | id='User-Agent', 193 | ), 194 | pytest.param( 195 | { 196 | 'content-type': 'application/json', 197 | 'user-agent': 'GitHub-Hookshot/dict-test', 198 | 'x-github-delivery': 'garbage', 199 | }, 200 | r'^X\-GitHub\-Delivery must be of type UUID4$', 201 | id='garbage X-GitHub-Delivery', 202 | ), 203 | pytest.param( 204 | { 205 | 'content-type': 'application/json', 206 | 'user-agent': 'GitHub-Hookshot/dict-test', 207 | 'x-github-delivery': str(uuid1()), 208 | }, 209 | r'^X\-GitHub\-Delivery must be of type UUID4$', 210 | id='UUID1 X-GitHub-Delivery', 211 | ), 212 | pytest.param( 213 | { 214 | 'content-type': 'application/json', 215 | 'user-agent': 'GitHub-Hookshot/dict-test', 216 | 'x-github-delivery': str(uuid4()), 217 | 'x-github-event': None, 218 | }, 219 | r'^X\-GitHub\-Event must be a string$', 220 | id='X-GitHub-Event', 221 | ), 222 | ), 223 | ) 224 | def test_validate_http_headers__invalid(http_headers, error_message): 225 | """Check that invalid headers cause ValueError.""" 226 | with pytest.raises(ValueError, match=error_message): 227 | validate_http_headers(http_headers) 228 | 229 | 230 | @pytest.mark.parametrize( 231 | 'incomplete_http_headers, expected_headers', 232 | ( 233 | pytest.param( 234 | { 235 | 'content-type': 'application/json', 236 | 'user-agent': 'GitHub-Hookshot/dict-test', 237 | 'x-github-delivery': UNCHANGED_UUID4_STR, 238 | 'x-github-event': 'pull_request', 239 | 'x-header': 'x_value', 240 | }, 241 | { 242 | 'content-type': 'application/json', 243 | 'user-agent': 'GitHub-Hookshot/dict-test', 244 | 'x-github-delivery': UNCHANGED_UUID4_STR, 245 | 'x-github-event': 'pull_request', 246 | 'x-header': 'x_value', 247 | }, 248 | id='unchanged', 249 | ), 250 | pytest.param( 251 | { 252 | 'user-agent': 'GitHub-Hookshot/dict-test', 253 | 'x-github-delivery': str(uuid4()), 254 | 'x-github-event': 'pull_request', 255 | 'x-header': 'x_value', 256 | }, 257 | { 258 | 'content-type': 'application/json', 259 | 'user-agent': 'GitHub-Hookshot/dict-test', 260 | 'x-github-event': 'pull_request', 261 | 'x-header': 'x_value', 262 | }, 263 | id='Content-Type', 264 | ), 265 | pytest.param( 266 | { 267 | 'content-type': 'application/json', 268 | 'x-github-delivery': str(uuid4()), 269 | 'x-github-event': 'pull_request', 270 | 'x-header': 'x_value', 271 | }, 272 | { 273 | 'content-type': 'application/json', 274 | 'user-agent': 'GitHub-Hookshot/fallback-value', 275 | 'x-github-event': 'pull_request', 276 | 'x-header': 'x_value', 277 | }, 278 | id='User-Agent', 279 | ), 280 | pytest.param( 281 | { 282 | 'content-type': 'application/json', 283 | 'user-agent': 'GitHub-Hookshot/dict-test', 284 | 'x-github-event': 'pull_request', 285 | 'x-header': 'x_value', 286 | }, 287 | { 288 | 'content-type': 'application/json', 289 | 'user-agent': 'GitHub-Hookshot/dict-test', 290 | 'x-github-event': 'pull_request', 291 | 'x-header': 'x_value', 292 | }, 293 | id='X-GitHub-Delivery', 294 | ), 295 | pytest.param( 296 | { 297 | 'x-github-event': 'ping', 298 | }, 299 | { 300 | 'content-type': 'application/json', 301 | 'user-agent': 'GitHub-Hookshot/fallback-value', 302 | 'x-github-event': 'ping', 303 | }, 304 | id='X-GitHub-Event', 305 | ), 306 | ), 307 | ) 308 | def test_augment_http_headers(incomplete_http_headers, expected_headers): 309 | """Check that mandatory headers are present after augmentation.""" 310 | augmented_headers = augment_http_headers(incomplete_http_headers) 311 | 312 | assert validate_http_headers(augmented_headers) is None 313 | 314 | original_event = incomplete_http_headers['x-github-event'] 315 | assert augmented_headers['x-github-event'] == original_event 316 | 317 | for header_name, header_value in expected_headers.items(): 318 | assert augmented_headers[header_name] == header_value 319 | 320 | 321 | def test_make_http_headers_from_event(): 322 | """Smoke-test fake HTTP headers constructor.""" 323 | event_name = 'issue_comment' 324 | http_headers = make_http_headers_from_event(event_name) 325 | 326 | assert http_headers['X-GitHub-Event'] == event_name 327 | assert http_headers['User-Agent'].endswith('/fallback-value') 328 | assert validate_http_headers(http_headers) is None 329 | 330 | 331 | def test__transform_http_headers_list_to_multidict__invalid(): 332 | """Check the headers format validation.""" 333 | error_message = ( 334 | '^Headers must be a sequence of mappings ' 335 | 'because keys can repeat$' 336 | ) 337 | with pytest.raises(ValueError, match=error_message): 338 | _transform_http_headers_list_to_multidict({}) 339 | 340 | 341 | def test__probe_json(): 342 | """Test that JSON probe loads mappings.""" 343 | vcr_contents = ( 344 | '{\n"hook": {\n"app_id": 0},' 345 | ' "hook_id": 0, ' 346 | '"zen": "Hey zen!"}' 347 | ) 348 | vcr_headers = () 349 | vcr_event = { 350 | 'hook': { 351 | 'app_id': 0, 352 | }, 353 | 'hook_id': 0, 354 | 'zen': 'Hey zen!', 355 | } 356 | with StringIO(vcr_contents) as event_file_fd: 357 | actual_parsed_vcr = _probe_json(event_file_fd) 358 | 359 | expected_parsed_vcr = vcr_headers, vcr_event 360 | assert actual_parsed_vcr == expected_parsed_vcr 361 | 362 | 363 | def test__probe_json__invalid(): 364 | """Verify that non-mapping objects crash pure JSON probe.""" 365 | expected_error_message = '^JSON file must only contain an object mapping$' 366 | with StringIO('[]') as file_descr, pytest.raises( 367 | ValueError, 368 | match=expected_error_message, 369 | ): 370 | _probe_json(file_descr) 371 | --------------------------------------------------------------------------------