From cad101f0bf62782335e48e639508a2685df95c89 Mon Sep 17 00:00:00 2001 From: Jimmy Bradshaw Date: Mon, 25 May 2026 22:26:25 +0000 Subject: [PATCH] migrate to using hatch --- .devcontainer/devcontainer-lock.json | 9 + .devcontainer/devcontainer.json | 16 ++ .devcontainer/post-create.sh | 3 + .github/workflows/lint.yml | 24 ++ .github/workflows/release.yml | 23 +- .github/workflows/test.yml | 43 +-- README.md | 122 +++++---- pyproject.toml | 69 +++++ samples/cruel_closer/app.py | 25 -- samples/log_event/app.py | 44 +++ .../requirements.txt | 0 samples/log_event/tests/conftest.py | 35 +++ .../fixtures/issue_comment_created_event.json | 9 + .../tests/fixtures/issues_opened_event.json | 9 + samples/log_event/tests/test_app.py | 28 ++ setup.py | 32 --- src/flask_githubapp/__about__.py | 1 + src/flask_githubapp/__init__.py | 10 +- src/flask_githubapp/core.py | 171 ++++++++---- src/flask_githubapp/version.py | 1 - test_requirements.txt | 4 - test_requirements_flask_2.txt | 7 - tests/conftest.py | 22 +- tests/test_core.py | 256 ++++++++---------- tests/test_integration.py | 166 +++++++----- 25 files changed, 679 insertions(+), 450 deletions(-) create mode 100644 .devcontainer/devcontainer-lock.json create mode 100644 .devcontainer/devcontainer.json create mode 100755 .devcontainer/post-create.sh create mode 100644 .github/workflows/lint.yml create mode 100644 pyproject.toml delete mode 100644 samples/cruel_closer/app.py create mode 100644 samples/log_event/app.py rename samples/{cruel_closer => log_event}/requirements.txt (100%) create mode 100644 samples/log_event/tests/conftest.py create mode 100644 samples/log_event/tests/fixtures/issue_comment_created_event.json create mode 100644 samples/log_event/tests/fixtures/issues_opened_event.json create mode 100644 samples/log_event/tests/test_app.py delete mode 100644 setup.py create mode 100644 src/flask_githubapp/__about__.py delete mode 100644 src/flask_githubapp/version.py delete mode 100644 test_requirements.txt delete mode 100644 test_requirements_flask_2.txt diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json new file mode 100644 index 0000000..495e683 --- /dev/null +++ b/.devcontainer/devcontainer-lock.json @@ -0,0 +1,9 @@ +{ + "features": { + "ghcr.io/devcontainers-extra/features/hatch:2": { + "version": "2.0.18", + "resolved": "ghcr.io/devcontainers-extra/features/hatch@sha256:79fc7fb2935a76732e6a72f81d674b7e6416570bba8b27c3c359bd28e9d03cd3", + "integrity": "sha256:79fc7fb2935a76732e6a72f81d674b7e6416570bba8b27c3c359bd28e9d03cd3" + } + } +} diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..c7fc9d1 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,16 @@ +{ + "name": "flask-githubapp", + "image": "mcr.microsoft.com/devcontainers/python:3", + "customizations": { + "vscode": { + "extensions": [ + "ms-python.python", + "github.vscode-github-actions" + ] + } + }, + "features": { + "ghcr.io/devcontainers-extra/features/hatch:2": {} + }, + "postCreateCommand": ["./.devcontainer/post-create.sh"] +} diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh new file mode 100755 index 0000000..ae39a63 --- /dev/null +++ b/.devcontainer/post-create.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +pip install -e .[test] diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..247c646 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,24 @@ +name: Lint + +on: + push: + branches: + - main + pull_request: + types: [opened, synchronize] + branches: + - main + +jobs: + test: + runs-on: ubuntu-latest + container: + image: mcr.microsoft.com/devcontainers/python + steps: + - uses: actions/checkout@v6 + + - name: Install hatch + run: pipx install hatch + + - name: Lint + run: hatch fmt --check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b16da17..9b9b21e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,13 +1,12 @@ -# This workflow will publish to PyPI on release. -# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions +name: Publish to PyPI -name: PyPI release - -on: workflow_dispatch +on: + release: + types: [published] jobs: build: - name: Build distribution + name: Build distribution 📦 runs-on: ubuntu-latest steps: @@ -31,18 +30,18 @@ jobs: with: name: python-package-distributions path: dist/ - publish: - name: Publish to PyPI + + publish-to-pypi: + name: >- + Publish Python 🐍 distribution 📦 to PyPI needs: - build runs-on: ubuntu-latest - environment: name: pypi - url: https://pypi.org/p/Flask-GitHubApp - + url: https://pypi.org/p/flask-githubapp permissions: - id-token: write + id-token: write # IMPORTANT: mandatory for trusted publishing steps: - name: Download all the dists diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0361443..25d39a6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,37 +1,24 @@ -# This workflow will run packages tests using a variety of Python versions. -# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions - -name: Python package test +name: Test on: + push: + branches: + - main pull_request: types: [opened, synchronize] - push: - branches: [master] + branches: + - main jobs: test: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.10", "3.x"] - python-requirements: ["test_requirements.txt", "test_requirements_flask_2.txt"] - exclude: - # Python 3.14+ no longer works with flask 2 - - python-version: "3.x" - python-requirements: "test_requirements_flask_2.txt" - + container: + image: mcr.microsoft.com/devcontainers/python steps: - - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r ${{ matrix.python-requirements }} - - name: Test with pytest - run: | - pytest + - uses: actions/checkout@v6 + + - name: Install hatch + run: pipx install hatch + + - name: Test + run: hatch test -ar diff --git a/README.md b/README.md index 70a70da..34309a1 100644 --- a/README.md +++ b/README.md @@ -1,86 +1,102 @@ -# flask-githubapp ![tests](https://github.com/bradshjg/flask-githubapp/actions/workflows/test.yml/badge.svg) +# flask-githubapp Flask extension for rapid Github app development in Python, in the spirit of [probot](https://probot.github.io/) -GitHub Apps help automate GitHub workflows. Examples include preventing merging of pull requests with "WIP" in the title or closing stale issues and pull requests. - ## Getting Started ### Installation -To install Flask-GitHubApp: `pip install flask-githubapp` -Or better yet, add it to your app's requirements.txt file! ;) - -> `flask-githubapp` requires Python 3.8+ and Flask 2+ +> [!NOTE] +> `flask-githubapp` requires a supported Python version and Flask 2+ -#### Create GitHub App +#### Create a GitHub App Follow GitHub's docs on [creating a github app](https://developer.github.com/apps/building-github-apps/creating-a-github-app/). -> You can, in principle, register any type of payload to be sent to the app! - -Once you do this, please note down the GitHub app Id, the GitHub app secret, and make sure to [create a private key](https://docs.github.com/en/developers/apps/authenticating-with-github-apps#generating-a-private-key) for it! These three elements are __required__ to run your app. - #### Build the Flask App -The GithubApp package has a decorator, `@on`, that will allow you to register events, and actions, to specific functions. -For instance, +The following example shows both styles of exposing event handlers, the functional and class style. + +[`samples/log_event/app.py`](./samples/log_event/app.py) ```python -@github_app.on('issues.opened') -def cruel_closer(): - #do stuff here -``` +import base64 +import logging +import os -Will trigger whenever the app receives a Github payload with the `X-Github-Event` header set to `issues`, and an `action` field in the payload field containing `opened` -Following this logic, you can make your app react in a unique way for every combination of event and action. Refer to the Github documentation for all the details about events and the actions they support, as well as for sample payloads for each. -You can also have something like +from flask import Flask -```python -@github_app.on('issues') -def issue_tracker(): - #do stuff here -``` +from flask_githubapp import GitHubApp, GitHubAppEventHandler -The above function will do `stuff here` for _every_ `issues` event received. This can be useful for specific workflows, to bring developers in early. +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) -Inside the function, you can access the received request via the conveniently named `request` variable. You can access its payload by simply getting it: `request.payload` -You can find a complete example (containing this cruel_closer function), in the samples folder of this repo. It is a fully functioning flask Github App. Try to guess what it does! +def create_app(test_config=None): + app = Flask(__name__) -#### Run it locally + app.config["GITHUBAPP_ID"] = os.environ["GITHUBAPP_ID"] + app.config["GITHUBAPP_KEY"] = base64.b64decode(os.environ["GITHUBAPP_KEY_B64"]) + app.config["GITHUBAPP_SECRET"] = os.environ["GITHUBAPP_SECRET"] -For quick iteration, you can set up your environment as follows: + if test_config: + app.config.update(test_config) -```bash -EXPORT GITHUBAPP_SECRET=False # this will circumvent request verification -EXPORT FLASK_APP=/path/to/your/flask/app.py # the file does not need to be named app.py! But it has to be the python file that instantiates the Flask app. For instance, samples/cruel_closer/app.py -``` + github_app = GitHubApp(app) -This will make your flask application run in debug mode. This means that, as you try sending payloads and tweak functions, fix issues, etc., as soon as you save the python code, the flask application will reload itself and run the new code immediately. -Once that is in place, run your github app + @github_app.on("issue_comment.created") + def log_issue_comment(): + comment_body = github_app.payload["comment"]["body"] + logger.info(comment_body) -```bash -flask run -``` + LogIssue(github_app=github_app) -Now, you can send requests! The port is 5000 by default but that can also be overridden. Check `flask run --help` for more details. Anyway! Now, on to sending test payloads! + return app -```bash -curl -H "X-GitHub-Event: " -H "Content-Type: application/json" -X POST -d @./path/to/payload.json http://localhost:5000 + +class LogIssue(GitHubAppEventHandler): + logger = logging.getLogger(__name__) + + @property + def events(self): + return ["issues.opened"] + + def call(self): + issue_body = self.payload["issue"]["body"] + self.logger.info(issue_body) + +``` + +```python +@github_app.on("issue_comment.created") +def log_issue_comment(): + ... + +class LogIssue(GitHubAppEventHandler): + @property + def events(self): + return ["issues.opened"] +... ``` -#### Install your GitHub App +Are how handlers register themselves for events. The syntax is `[.]` where the event comes from the +`X-GitHub-Event` header and the action from the payload. The action is optional. + +#### Run it locally -**Settings** > **Applications** > **Configure** +For local development, [smee.io](https://smee.io) is the recommended proxy. If you'd like to disable webhook validation, +you can supply `app.config["GITHUBAPP_SECRET"] = False`. -> If you were to install the cruel closer app, any repositories that you give the GitHub app access to will cruelly close all new issues, be careful. +```bash +EXPORT GITHUBAPP_SECRET=False # this will circumvent request verification +EXPORT FLASK_APP=/path/to/your/flask/app.py # the file does not need to be named app.py! But it has to be the python file that instantiates the Flask app. For instance, samples/cruel_closer/app.py +``` -#### Deploy your GitHub App +#### Testing -Bear in mind that you will need to run the app _somewhere_. It is possible, and fairly easy, to host the app in something like Kubernetes, or simply containerised, in a machine somewhere. You will need to be careful to expose the flask app port to the outside world so the app can receive the payloads from Github. The deployed flask app will need to be reachable from the same URL you set as the `webhook url`. However, this is getting a little bit into Docker/Kubernetes territory so we will not go too deep. +See [`samples/log_event`](./samples/log_event/) which inclues pytest configuration and some simple tests. ## Usage @@ -95,6 +111,9 @@ outside a hook context). `installation_token`: The token used to authenticate as the app installation (useful for passing to async tasks). +> [!NOTE] +> These same attributes are also available as method to handlers that inherit `GitHubAppEventHandler` + ## Configuration `GITHUBAPP_ID`: GitHub app ID as an int (required). Default: None @@ -107,10 +126,3 @@ verification. `GITHUBAPP_URL`: URL of GitHub instance (used for GitHub Enterprise) as a string. Default: None `GITHUBAPP_ROUTE`: Path used for GitHub hook requests as a string. Default: '/' - -You can find an example on how to init all these config variables in the [cruel_closer sample app](https://github.com/bradshjg/flask-githubapp/tree/master/samples/cruel_closer) - -#### Cruel Closer - -The cruel_closer sample app will use information of the received payload (which is received every time an issue is opened), will _find_ said issue and **close it** without regard. -That's just r00d! diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a2f89cc --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,69 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "flask-githubapp" +dynamic = ["version"] +description = "Rapid GitHub App development in Python" +readme = "README.md" +requires-python = ">=3.10" +license = "MIT" +keywords = [] +authors = [ + { name = "James Bradshaw", email = "james.g.bradshaw@gmail.com" }, +] +classifiers = [ + "Development Status :: 4 - Beta", + "Programming Language :: Python", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: Implementation :: CPython", +] +dependencies = [ + "flask", + "github3.py", +] + +[project.urls] +Documentation = "https://github.com/bradshjg/flask-githubapp#readme" +Issues = "https://github.com/bradshjg/flask-githubapp/issues" +Source = "https://github.com/bradshjg/flask-githubapp" + +[project.optional-dependencies] +test = [ + "pytest~=8.1", + "pytest-mock~=3.12", + "pytest-randomly~=3.15", + "pytest-rerunfailures~=14.0", + "pytest-xdist[psutil]~=3.5", +] + +[tool.hatch.version] +path = "src/flask_githubapp/__about__.py" + +[[tool.hatch.envs.hatch-test.matrix]] +python = ["3.10", "3.14"] +flask = ["2", "latest"] + +[tool.hatch.envs.hatch-test] +extra-args = ["-vv"] +features = [ + "test", +] + +[tool.hatch.envs.hatch-test.overrides] +matrix.flask.dependencies = [ + { value = "flask~=2.0", if = ["2"] } +] + +[tool.ruff.lint.extend-per-file-ignores] +"tests/**/*" = [ + "SLF001", # allow mocking private interfaces (for better and worse) + "INP001", # tests are an implicit package +] +"samples/**/*" = [ + "INP001", # samples are not packages +] diff --git a/samples/cruel_closer/app.py b/samples/cruel_closer/app.py deleted file mode 100644 index 95a5cbd..0000000 --- a/samples/cruel_closer/app.py +++ /dev/null @@ -1,25 +0,0 @@ -import os - -from flask import Flask -from flask_githubapp import GitHubApp - -app = Flask(__name__) - -app.config['GITHUBAPP_ID'] = int(os.environ['GITHUBAPP_ID']) - -with open(os.environ['GITHUBAPP_KEY_PATH'], 'rb') as key_file: - app.config['GITHUBAPP_KEY'] = key_file.read() - -app.config['GITHUBAPP_SECRET'] = os.environ['GITHUBAPP_SECRET'] - -github_app = GitHubApp(app) - - -@github_app.on('issues.opened') -def cruel_closer(): - owner = github_app.payload['repository']['owner']['login'] - repo = github_app.payload['repository']['name'] - num = github_app.payload['issue']['number'] - issue = github_app.installation_client.issue(owner, repo, num) - issue.create_comment('Could not replicate.') - issue.close() diff --git a/samples/log_event/app.py b/samples/log_event/app.py new file mode 100644 index 0000000..5fe4290 --- /dev/null +++ b/samples/log_event/app.py @@ -0,0 +1,44 @@ +import base64 +import logging +import os + +from flask import Flask + +from flask_githubapp import GitHubApp, GitHubAppEventHandler + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +def create_app(test_config=None): + app = Flask(__name__) + + app.config["GITHUBAPP_ID"] = os.environ["GITHUBAPP_ID"] + app.config["GITHUBAPP_KEY"] = base64.b64decode(os.environ["GITHUBAPP_KEY_B64"]) + app.config["GITHUBAPP_SECRET"] = os.environ["GITHUBAPP_SECRET"] + + if test_config: + app.config.update(test_config) + + github_app = GitHubApp(app) + + @github_app.on("issue_comment.created") + def log_issue_comment(): + comment_body = github_app.payload["comment"]["body"] + logger.info(comment_body) + + LogIssue(github_app=github_app) + + return app + + +class LogIssue(GitHubAppEventHandler): + logger = logging.getLogger(__name__) + + @property + def events(self): + return ["issues.opened"] + + def call(self): + issue_body = self.payload["issue"]["body"] + self.logger.info(issue_body) diff --git a/samples/cruel_closer/requirements.txt b/samples/log_event/requirements.txt similarity index 100% rename from samples/cruel_closer/requirements.txt rename to samples/log_event/requirements.txt diff --git a/samples/log_event/tests/conftest.py b/samples/log_event/tests/conftest.py new file mode 100644 index 0000000..b447902 --- /dev/null +++ b/samples/log_event/tests/conftest.py @@ -0,0 +1,35 @@ +import base64 +import json +import os +import pathlib +from typing import Any + +import pytest +from app import create_app + + +@pytest.fixture(scope="session", autouse=True) +def set_env(): + os.environ["GITHUBAPP_ID"] = "some-app-id" + os.environ["GITHUBAPP_KEY_B64"] = base64.b64encode(b"some-base-64-private-key").decode() + os.environ["GITHUBAPP_SECRET"] = "secret" + + +@pytest.fixture +def app(): + return create_app(test_config={"TESTING": True, "GITHUBAPP_SECRET": False}) + + +@pytest.fixture +def client(app): + return app.test_client() + + +@pytest.fixture +def load_fixture(): + def loader(fixture_name: str) -> dict[str, Any]: + fixtures_dir = pathlib.Path(__file__).parent / "fixtures" + with open(fixtures_dir / f"{fixture_name}.json") as fp: + return json.load(fp) + + return loader diff --git a/samples/log_event/tests/fixtures/issue_comment_created_event.json b/samples/log_event/tests/fixtures/issue_comment_created_event.json new file mode 100644 index 0000000..9e73c43 --- /dev/null +++ b/samples/log_event/tests/fixtures/issue_comment_created_event.json @@ -0,0 +1,9 @@ +{ + "action": "created", + "comment": { + "body": "some comment" + }, + "installation": { + "id": 1 + } +} diff --git a/samples/log_event/tests/fixtures/issues_opened_event.json b/samples/log_event/tests/fixtures/issues_opened_event.json new file mode 100644 index 0000000..149aa0a --- /dev/null +++ b/samples/log_event/tests/fixtures/issues_opened_event.json @@ -0,0 +1,9 @@ +{ + "action": "opened", + "issue": { + "body": "some issue" + }, + "installation": { + "id": 1 + } +} diff --git a/samples/log_event/tests/test_app.py b/samples/log_event/tests/test_app.py new file mode 100644 index 0000000..3df89d6 --- /dev/null +++ b/samples/log_event/tests/test_app.py @@ -0,0 +1,28 @@ +def test_log_issue_comment(client, load_fixture, mocker): + mock_logger = mocker.patch("app.logger") + issue_comment_created_event = load_fixture("issue_comment_created_event") + + resp = client.post( + "/", + json=issue_comment_created_event, + headers={"Content-Type": "application/json", "X-Github-Event": "issue_comment"}, + ) + + assert resp.status_code == 200 + + mock_logger.info.assert_called_once_with("some comment") + + +def test_log_issue_body(client, load_fixture, mocker): + mock_logger = mocker.patch("app.LogIssue.logger") + issues_opened_event = load_fixture("issues_opened_event") + + resp = client.post( + "/", + json=issues_opened_event, + headers={"Content-Type": "application/json", "X-Github-Event": "issues"}, + ) + + assert resp.status_code == 200 + + mock_logger.info.assert_called_once_with("some issue") diff --git a/setup.py b/setup.py deleted file mode 100644 index 4aee90a..0000000 --- a/setup.py +++ /dev/null @@ -1,32 +0,0 @@ -""" -Flask-GitHubApp ---------------- - -Easy GitHub App integration for Flask -""" -import os -from setuptools import setup - -with open(os.path.join(os.path.dirname(__file__), 'src/flask_githubapp/version.py'), 'r') as f: - exec(f.read()) - - -setup( - name='Flask-GitHubApp', - package_dir = {"": "src"}, - version=__version__, - url='https://github.com/bradshjg/flask-githubapp', - license='MIT', - author='Jimmy Bradshaw', - author_email='james.g.bradshaw@gmail.com', - description='Rapid GitHub app development in Python', - long_description=__doc__, - packages=['flask_githubapp'], - zip_safe=False, - include_package_data=True, - platforms='any', - install_requires=[ - 'flask', - 'github3.py' - ], -) diff --git a/src/flask_githubapp/__about__.py b/src/flask_githubapp/__about__.py new file mode 100644 index 0000000..3d18726 --- /dev/null +++ b/src/flask_githubapp/__about__.py @@ -0,0 +1 @@ +__version__ = "0.5.0" diff --git a/src/flask_githubapp/__init__.py b/src/flask_githubapp/__init__.py index 50d8c08..291ed4d 100644 --- a/src/flask_githubapp/__init__.py +++ b/src/flask_githubapp/__init__.py @@ -1,10 +1,6 @@ -from .core import ( - GitHubApp -) +from flask_githubapp.core import GitHubApp, GitHubAppEventHandler -from .version import __version__ - -__all__ = ['GitHubApp'] +__all__ = ["GitHubApp", "GitHubAppEventHandler"] # Set default logging handler to avoid "No handler found" warnings. import logging @@ -16,4 +12,4 @@ rootlogger.addHandler(NullHandler()) if rootlogger.level == logging.NOTSET: - rootlogger.setLevel(logging.WARN) + rootlogger.setLevel(logging.WARNING) diff --git a/src/flask_githubapp/core.py b/src/flask_githubapp/core.py index a7994b5..18d6bed 100644 --- a/src/flask_githubapp/core.py +++ b/src/flask_githubapp/core.py @@ -1,15 +1,18 @@ """Flask extension for rapid GitHub app development""" + +import abc import hmac import logging +import typing -from flask import abort, current_app, jsonify, make_response, request, g +from flask import Response, abort, current_app, g, jsonify, make_response, request from github3 import GitHub, GitHubEnterprise from werkzeug.exceptions import BadRequest LOG = logging.getLogger(__name__) -STATUS_FUNC_CALLED = 'HIT' -STATUS_NO_FUNC_CALLED = 'MISS' +STATUS_FUNC_CALLED = "HIT" +STATUS_NO_FUNC_CALLED = "MISS" class GitHubAppError(Exception): @@ -20,7 +23,7 @@ class GitHubAppValidationError(GitHubAppError): pass -class GitHubApp(object): +class GitHubApp: """The GitHubApp object provides the central interface for interacting GitHub hooks and creating GitHub app clients. @@ -30,6 +33,7 @@ class GitHubApp(object): Keyword Arguments: app {Flask object} -- App instance - created with Flask(__name__) (default: {None}) """ + def __init__(self, app=None): self._hook_mappings = {} if app is not None: @@ -66,79 +70,78 @@ def init_app(self, app): Path used for GitHub hook requests as a string. Default: '/' """ - required_settings = ['GITHUBAPP_ID', 'GITHUBAPP_KEY', 'GITHUBAPP_SECRET'] + required_settings = ["GITHUBAPP_ID", "GITHUBAPP_KEY", "GITHUBAPP_SECRET"] for setting in required_settings: - if not setting in app.config: - raise RuntimeError("Flask-GitHubApp requires the '%s' config var to be set" % setting) + if setting not in app.config: + msg = f"Flask-GitHubApp requires the '{setting}' config var to be set" + raise RuntimeError(msg) - app.add_url_rule(app.config.get('GITHUBAPP_ROUTE', '/'), - view_func=self._flask_view_func, - methods=['POST']) + app.add_url_rule(app.config.get("GITHUBAPP_ROUTE", "/"), view_func=self._flask_view_func, methods=["POST"]) @property def id(self): - return current_app.config['GITHUBAPP_ID'] + return current_app.config["GITHUBAPP_ID"] @property def key(self): - key = current_app.config['GITHUBAPP_KEY'] - if hasattr(key, 'encode'): - key = key.encode('utf-8') + key = current_app.config["GITHUBAPP_KEY"] + if hasattr(key, "encode"): + key = key.encode("utf-8") return key @property def secret(self): - secret = current_app.config['GITHUBAPP_SECRET'] - if hasattr(secret, 'encode'): - secret = secret.encode('utf-8') + secret = current_app.config["GITHUBAPP_SECRET"] + if hasattr(secret, "encode"): + secret = secret.encode("utf-8") return secret @property def _api_url(self): - return current_app.config['GITHUBAPP_URL'] + return current_app.config["GITHUBAPP_URL"] @property def client(self): """Unauthenticated GitHub client""" - if current_app.config.get('GITHUBAPP_URL'): - return GitHubEnterprise(current_app.config['GITHUBAPP_URL']) + if current_app.config.get("GITHUBAPP_URL"): + return GitHubEnterprise(current_app.config["GITHUBAPP_URL"]) return GitHub() @property def payload(self): """GitHub hook payload""" - if request and request.json and 'installation' in request.json: + if request and request.json and "installation" in request.json: return request.json - raise RuntimeError('Payload is only available in the context of a GitHub hook request') + msg = "Payload is only available in the context of a GitHub hook request" + raise RuntimeError(msg) @property def installation_client(self): """GitHub client authenticated as GitHub app installation""" ctx = g if ctx is not None: - if not hasattr(ctx, 'githubapp_installation'): + if not hasattr(ctx, "githubapp_installation"): client = self.client - client.login_as_app_installation(self.key, - self.id, - self.payload['installation']['id']) + client.login_as_app_installation(self.key, self.id, self.payload["installation"]["id"]) ctx.githubapp_installation = client return ctx.githubapp_installation + return None @property def app_client(self): """GitHub client authenticated as GitHub app""" ctx = g if ctx is not None: - if not hasattr(ctx, 'githubapp_app'): + if not hasattr(ctx, "githubapp_app"): client = self.client - client.login_as_app(self.key, - self.id) + client.login_as_app(self.key, self.id) ctx.githubapp_app = client return ctx.githubapp_app + return None @property - def installation_token(self): + def installation_token(self) -> str: return self.installation_client.session.auth.token def on(self, event_action): @@ -159,6 +162,7 @@ def cruel_closer(): event_action {str} -- Name of the event and optional action (separated by a period), e.g. 'issues.opened' or 'pull_request' """ + def decorator(f): if event_action not in self._hook_mappings: self._hook_mappings[event_action] = [f] @@ -173,43 +177,44 @@ def decorator(f): def _validate_request(self): if not request.is_json: - raise GitHubAppValidationError('Invalid HTTP Content-Type header for JSON body ' - '(must be application/json or application/*+json).') + msg = "Invalid HTTP Content-Type header for JSON body (must be application/json or application/*+json)." + raise GitHubAppValidationError(msg) try: - request.json - except BadRequest: - raise GitHubAppValidationError('Invalid HTTP body (must be JSON).') + json = request.json + except BadRequest as e: + msg = "Invalid HTTP body (must be JSON)." + raise GitHubAppValidationError(msg) from e - event = request.headers.get('X-GitHub-Event') + event = request.headers.get("X-GitHub-Event") if event is None: - raise GitHubAppValidationError('Missing X-GitHub-Event HTTP header.') + msg = "Missing X-GitHub-Event HTTP header." + raise GitHubAppValidationError(msg) - action = request.json.get('action') + action = json.get("action") return event, action - def _flask_view_func(self): + def _flask_view_func(self) -> Response: functions_to_call = [] calls = {} try: event, action = self._validate_request() except GitHubAppValidationError as e: - LOG.error(e) - error_response = make_response(jsonify(status='ERROR', description=str(e)), - 400) + LOG.exception("Validation failed") + error_response = make_response(jsonify(status="ERROR", description=str(e)), 400) return abort(error_response) - if current_app.config['GITHUBAPP_SECRET'] is not False: + if current_app.config["GITHUBAPP_SECRET"] is not False: self._verify_webhook() if event in self._hook_mappings: functions_to_call += self._hook_mappings[event] if action: - event_action = '.'.join([event, action]) + event_action = f"{event}.{action}" if event_action in self._hook_mappings: functions_to_call += self._hook_mappings[event_action] @@ -219,26 +224,80 @@ def _flask_view_func(self): status = STATUS_FUNC_CALLED else: status = STATUS_NO_FUNC_CALLED - return jsonify({'status': status, - 'calls': calls}) + return jsonify({"status": status, "calls": calls}) - def _verify_webhook(self): - signature_header ='X-Hub-Signature-256' - signature_header_legacy = 'X-Hub-Signature' + def _verify_webhook(self) -> None: + signature_header = "X-Hub-Signature-256" + signature_header_legacy = "X-Hub-Signature" if request.headers.get(signature_header): - signature = request.headers[signature_header].split('=')[1] - digestmod = 'sha256' + signature = request.headers[signature_header].split("=")[1] + digestmod = "sha256" elif request.headers.get(signature_header_legacy): - signature = request.headers[signature_header_legacy].split('=')[1] - digestmod = 'sha1' + signature = request.headers[signature_header_legacy].split("=")[1] + digestmod = "sha1" else: - LOG.warning('Signature header missing. Configure your GitHub App with a secret or set GITHUBAPP_SECRET' - 'to False to disable verification.') + LOG.warning( + "Signature header missing. Configure your GitHub App with a secret or set GITHUBAPP_SECRET" + "to False to disable verification." + ) return abort(400) mac = hmac.new(self.secret, msg=request.data, digestmod=digestmod) if not hmac.compare_digest(mac.hexdigest(), signature): - LOG.warning('GitHub hook signature verification failed.') + LOG.warning("GitHub hook signature verification failed.") return abort(400) + return None + + +class GitHubAppEventHandler(abc.ABC): + """Abstract base class for event handlers + + Handlers should implement a `call` method, and supply an `events` property. + + Example: + + ```python + class PrintIssueCommentBody(EventHandler): + @property + def events(self): + return ["issue_comment.created"] + + def call(self): + print(self.payload["issue"]["body"]) + + + PrintIssueCommentBody(github_app=github_app) + ``` + """ + + def __init__(self, *, github_app: GitHubApp): + self.github_app = github_app + self.register(self.events) + + @property + @abc.abstractmethod + def events(self) -> list[str]: ... + + def register(self, events: list[str]): + for event in events: + self.github_app.on(event)(self.call) + + @property + def payload(self) -> dict[str, typing.Any]: + return self.github_app.payload + + @property + def installation_client(self) -> GitHub: + return typing.cast("GitHub", self.github_app.installation_client) + + @property + def app_client(self) -> GitHub: + return typing.cast("GitHub", self.github_app.app_client) + + @property + def installation_token(self) -> str: + return self.github_app.installation_token + + def call(self) -> None: ... diff --git a/src/flask_githubapp/version.py b/src/flask_githubapp/version.py deleted file mode 100644 index abeeedb..0000000 --- a/src/flask_githubapp/version.py +++ /dev/null @@ -1 +0,0 @@ -__version__ = '0.4.0' diff --git a/test_requirements.txt b/test_requirements.txt deleted file mode 100644 index 4926b35..0000000 --- a/test_requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -file:. - -pytest -pytest-mock diff --git a/test_requirements_flask_2.txt b/test_requirements_flask_2.txt deleted file mode 100644 index c990368..0000000 --- a/test_requirements_flask_2.txt +++ /dev/null @@ -1,7 +0,0 @@ -file:. - -pytest -pytest-mock - -flask==2.0.0 -werkzeug==2.0.3 diff --git a/tests/conftest.py b/tests/conftest.py index 1950624..f99c53d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,4 @@ import pytest - from flask import Flask from flask_githubapp import GitHubApp @@ -7,26 +6,27 @@ @pytest.fixture def app(): - app = Flask('test_app') - app.config['GITHUBAPP_ID'] = 1 - app.config['GITHUBAPP_KEY'] = 'key' - app.config['GITHUBAPP_SECRET'] = 'secret' + app = Flask("test_app") + app.config["GITHUBAPP_ID"] = 1 + app.config["GITHUBAPP_KEY"] = "key" + app.config["GITHUBAPP_SECRET"] = "secret" return app + @pytest.fixture def github_app(app): github_app = GitHubApp(app) - @github_app.on('issues') + @github_app.on("issues") def test_issue(): - return 'issue event' + return "issue event" - @github_app.on('issues.edited') + @github_app.on("issues.edited") def test_issue_edited(): - return 'issue edited action' + return "issue edited action" - @github_app.on('push') + @github_app.on("push") def test_push(): - return 'push event' + return "push event" return app diff --git a/tests/test_core.py b/tests/test_core.py index 32de120..4d200ca 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -12,14 +12,14 @@ def test_default_config(app): github_app = GitHubApp(app) with app.app_context(): assert github_app.id == 1 - assert github_app.key == b'key' - assert github_app.secret == b'secret' + assert github_app.key == b"key" + assert github_app.secret == b"secret" def test_init_app(app): github_app = GitHubApp() github_app.init_app(app) - assert 'GITHUBAPP_URL' not in app.config + assert "GITHUBAPP_URL" not in app.config def test_github_client(app): @@ -29,8 +29,8 @@ def test_github_client(app): def test_github_enterprise_client(app): - enterprise_url = 'https://enterprise.github.com' - app.config['GITHUBAPP_URL'] = enterprise_url + enterprise_url = "https://enterprise.github.com" + app.config["GITHUBAPP_URL"] = enterprise_url github_app = GitHubApp(app) with app.app_context(): assert isinstance(github_app.client, GitHubEnterprise) @@ -40,88 +40,82 @@ def test_github_enterprise_client(app): def test_github_installation_client(app, mocker): github_app = GitHubApp(app) installation_id = 2 - mocker.patch('flask_githubapp.core.GitHubApp._verify_webhook') - mock_client = mocker.patch('flask_githubapp.core.GitHubApp.client') + mocker.patch("flask_githubapp.core.GitHubApp._verify_webhook") + mock_client = mocker.patch("flask_githubapp.core.GitHubApp.client") with app.test_client() as client: - resp = client.post('/', - data=json.dumps({'installation': {'id': installation_id}}), - headers={ - 'X-GitHub-Event': 'foo', - 'Content-Type': 'application/json' - }) + resp = client.post( + "/", + data=json.dumps({"installation": {"id": installation_id}}), + headers={"X-GitHub-Event": "foo", "Content-Type": "application/json"}, + ) assert resp.status_code == 200 - github_app.installation_client - mock_client.login_as_app_installation.assert_called_once_with(github_app.key, - github_app.id, - installation_id) + _ = github_app.installation_client # trigger login + mock_client.login_as_app_installation.assert_called_once_with(github_app.key, github_app.id, installation_id) def test_github_installation_client_is_lazy(app, mocker): - github_app = GitHubApp(app) + GitHubApp(app) installation_id = 2 - mocker.patch('flask_githubapp.core.GitHubApp._verify_webhook') - mock_client = mocker.patch('flask_githubapp.core.GitHubApp.client') + mocker.patch("flask_githubapp.core.GitHubApp._verify_webhook") + mock_client = mocker.patch("flask_githubapp.core.GitHubApp.client") with app.test_client() as client: - resp = client.post('/', - data=json.dumps({'installation': {'id': installation_id}}), - headers={ - 'X-GitHub-Event': 'foo', - 'Content-Type': 'application/json' - }) + resp = client.post( + "/", + data=json.dumps({"installation": {"id": installation_id}}), + headers={"X-GitHub-Event": "foo", "Content-Type": "application/json"}, + ) assert resp.status_code == 200 mock_client.login_as_app_installation.assert_not_called() def test_github_app_client(app, mocker): github_app = GitHubApp(app) - mocker.patch('flask_githubapp.core.GitHubApp._verify_webhook') - mock_client = mocker.patch('flask_githubapp.core.GitHubApp.client') + mocker.patch("flask_githubapp.core.GitHubApp._verify_webhook") + mock_client = mocker.patch("flask_githubapp.core.GitHubApp.client") with app.app_context(): - github_app.app_client - mock_client.login_as_app.assert_called_once_with(github_app.key, - github_app.id) + _ = github_app.app_client # trigger login + mock_client.login_as_app.assert_called_once_with(github_app.key, github_app.id) def test_hook_mapping(app): github_app = GitHubApp(app) - @github_app.on('foo') + @github_app.on("foo") def bar(): pass - assert github_app._hook_mappings['foo'] == [bar] + assert github_app._hook_mappings["foo"] == [bar] def test_multiple_function_on_same_event(app): github_app = GitHubApp(app) - @github_app.on('foo') + @github_app.on("foo") def bar(): pass - @github_app.on('foo') + @github_app.on("foo") def baz(): pass - assert github_app._hook_mappings['foo'] == [bar, baz] + assert github_app._hook_mappings["foo"] == [bar, baz] def test_events_mapped_to_functions(app, mocker): github_app = GitHubApp(app) function_to_call = MagicMock() - function_to_call.__name__ = 'foo' # used to generate response - function_to_call.return_value = 'foo' # return data must be serializable + function_to_call.__name__ = "foo" # used to generate response + function_to_call.return_value = "foo" # return data must be serializable - github_app._hook_mappings['foo'] = [function_to_call] - mocker.patch('flask_githubapp.core.GitHubApp._verify_webhook') + github_app._hook_mappings["foo"] = [function_to_call] + mocker.patch("flask_githubapp.core.GitHubApp._verify_webhook") with app.test_client() as client: - resp = client.post('/', - data=json.dumps({'installation': {'id': 2}}), - headers={ - 'X-GitHub-Event': 'foo', - 'Content-Type': 'application/json' - }) + resp = client.post( + "/", + data=json.dumps({"installation": {"id": 2}}), + headers={"X-GitHub-Event": "foo", "Content-Type": "application/json"}, + ) assert resp.status_code == 200 function_to_call.assert_called_once_with() @@ -130,19 +124,17 @@ def test_events_with_actions_mapped_to_functions(app, mocker): github_app = GitHubApp(app) function_to_call = MagicMock() - function_to_call.__name__ = 'foo' # used to generate response - function_to_call.return_value = 'foo' # return data must be serializable + function_to_call.__name__ = "foo" # used to generate response + function_to_call.return_value = "foo" # return data must be serializable - github_app._hook_mappings['foo.bar'] = [function_to_call] - mocker.patch('flask_githubapp.core.GitHubApp._verify_webhook') + github_app._hook_mappings["foo.bar"] = [function_to_call] + mocker.patch("flask_githubapp.core.GitHubApp._verify_webhook") with app.test_client() as client: - resp = client.post('/', - data=json.dumps({'installation': {'id': 2}, - 'action': 'bar'}), - headers={ - 'X-GitHub-Event': 'foo', - 'Content-Type': 'application/json' - }) + resp = client.post( + "/", + data=json.dumps({"installation": {"id": 2}, "action": "bar"}), + headers={"X-GitHub-Event": "foo", "Content-Type": "application/json"}, + ) assert resp.status_code == 200 function_to_call.assert_called_once_with() @@ -151,18 +143,17 @@ def test_functions_can_return_no_data(app, mocker): github_app = GitHubApp(app) function_to_call = MagicMock() - function_to_call.__name__ = 'foo' # used to generate response + function_to_call.__name__ = "foo" # used to generate response function_to_call.return_value = None - github_app._hook_mappings['foo'] = [function_to_call] - mocker.patch('flask_githubapp.core.GitHubApp._verify_webhook') + github_app._hook_mappings["foo"] = [function_to_call] + mocker.patch("flask_githubapp.core.GitHubApp._verify_webhook") with app.test_client() as client: - resp = client.post('/', - data=json.dumps({'installation': {'id': 2}}), - headers={ - 'X-GitHub-Event': 'foo', - 'Content-Type': 'application/json' - }) + resp = client.post( + "/", + data=json.dumps({"installation": {"id": 2}}), + headers={"X-GitHub-Event": "foo", "Content-Type": "application/json"}, + ) assert resp.status_code == 200 function_to_call.assert_called_once_with() @@ -171,18 +162,17 @@ def test_function_exception_raise_500_error(app, mocker): github_app = GitHubApp(app) function_to_call = MagicMock() - function_to_call.__name__ = 'foo' # used to generate response - function_to_call.side_effect = Exception('foo exception') + function_to_call.__name__ = "foo" # used to generate response + function_to_call.side_effect = Exception("foo exception") - github_app._hook_mappings['foo'] = [function_to_call] - mocker.patch('flask_githubapp.core.GitHubApp._verify_webhook') + github_app._hook_mappings["foo"] = [function_to_call] + mocker.patch("flask_githubapp.core.GitHubApp._verify_webhook") with app.test_client() as client: - resp = client.post('/', - data=json.dumps({'installation': {'id': 2}}), - headers={ - 'X-GitHub-Event': 'foo', - 'Content-Type': 'application/json' - }) + resp = client.post( + "/", + data=json.dumps({"installation": {"id": 2}}), + headers={"X-GitHub-Event": "foo", "Content-Type": "application/json"}, + ) assert resp.status_code == 500 function_to_call.assert_called_once_with() @@ -191,104 +181,92 @@ def test_no_target_functions(app, mocker): github_app = GitHubApp(app) function_to_miss = MagicMock() - function_to_miss.__name__ = 'foo' # used to generate response + function_to_miss.__name__ = "foo" # used to generate response - github_app._hook_mappings['foo'] = [function_to_miss] - mocker.patch('flask_githubapp.core.GitHubApp._verify_webhook') + github_app._hook_mappings["foo"] = [function_to_miss] + mocker.patch("flask_githubapp.core.GitHubApp._verify_webhook") with app.test_client() as client: - resp = client.post('/', - data=json.dumps({'installation': {'id': 2}}), - headers={ - 'X-GitHub-Event': 'bar', - 'Content-Type': 'application/json' - }) + resp = client.post( + "/", + data=json.dumps({"installation": {"id": 2}}), + headers={"X-GitHub-Event": "bar", "Content-Type": "application/json"}, + ) assert resp.status_code == 200 function_to_miss.assert_not_called() - assert resp.json['status'] == STATUS_NO_FUNC_CALLED - assert resp.json['calls'] == {} + assert resp.json["status"] == STATUS_NO_FUNC_CALLED + assert resp.json["calls"] == {} def test_view_returns_map_of_called_functions_and_returned_data(app, mocker): github_app = GitHubApp(app) def event_function(): - return 'foo' + return "foo" def event_action_function(): - return 'bar' + return "bar" def other_event_function(): - return 'baz' + return "baz" github_app._hook_mappings = { - 'foo': [event_function], - 'foo.bar': [event_action_function], - 'bar': [other_event_function] + "foo": [event_function], + "foo.bar": [event_action_function], + "bar": [other_event_function], } - mocker.patch('flask_githubapp.core.GitHubApp._verify_webhook') + mocker.patch("flask_githubapp.core.GitHubApp._verify_webhook") with app.test_client() as client: - resp = client.post('/', - data=json.dumps({'installation': {'id': 2}, - 'action': 'bar'}), - headers={ - 'X-GitHub-Event': 'foo', - 'Content-Type': 'application/json' - }) + resp = client.post( + "/", + data=json.dumps({"installation": {"id": 2}, "action": "bar"}), + headers={"X-GitHub-Event": "foo", "Content-Type": "application/json"}, + ) assert resp.status_code == 200 assert resp.json == { - 'status': 'HIT', - 'calls': { - 'event_function': 'foo', - 'event_action_function': 'bar', - } + "status": "HIT", + "calls": { + "event_function": "foo", + "event_action_function": "bar", + }, } + def test_invalid_json_header_returns_error(github_app): """HTTP request have a valid json content type""" - github_app.config['GITHUBAPP_SECRET'] = False + github_app.config["GITHUBAPP_SECRET"] = False with github_app.test_client() as client: - resp = client.post('/', - data=json.dumps({'installation': {'id': 2}, - 'action': 'bar'}), - headers={ - 'X-GitHub-Event': 'foo', - 'Content-Type': 'text/plain' - }) + resp = client.post( + "/", + data=json.dumps({"installation": {"id": 2}, "action": "bar"}), + headers={"X-GitHub-Event": "foo", "Content-Type": "text/plain"}, + ) assert resp.status_code == 400 assert resp.json == { - 'status': 'ERROR', - 'description': 'Invalid HTTP Content-Type header for JSON body ' - '(must be application/json or application/*+json).' + "status": "ERROR", + "description": "Invalid HTTP Content-Type header for JSON body " + "(must be application/json or application/*+json).", } + def test_invalid_json_body_returns_error(github_app): """HTTP request have a valid json body""" - github_app.config['GITHUBAPP_SECRET'] = False + github_app.config["GITHUBAPP_SECRET"] = False with github_app.test_client() as client: - resp = client.post('/', - data='invalid json', - headers={ - 'X-GitHub-Event': 'foo', - 'Content-Type': 'application/json' - }) + resp = client.post( + "/", data="invalid json", headers={"X-GitHub-Event": "foo", "Content-Type": "application/json"} + ) assert resp.status_code == 400 - assert resp.json == { - 'status': 'ERROR', - 'description': 'Invalid HTTP body (must be JSON).' - } + assert resp.json == {"status": "ERROR", "description": "Invalid HTTP body (must be JSON)."} + def test_missing_event_header_returns_error(github_app): """HTTP request must havea X-GitHub-Event header""" - github_app.config['GITHUBAPP_SECRET'] = False + github_app.config["GITHUBAPP_SECRET"] = False with github_app.test_client() as client: - resp = client.post('/', - data=json.dumps({'installation': {'id': 2}, - 'action': 'bar'}), - headers={ - 'Content-Type': 'application/json' - }) + resp = client.post( + "/", + data=json.dumps({"installation": {"id": 2}, "action": "bar"}), + headers={"Content-Type": "application/json"}, + ) assert resp.status_code == 400 - assert resp.json == { - 'status': 'ERROR', - 'description': 'Missing X-GitHub-Event HTTP header.' - } + assert resp.json == {"status": "ERROR", "description": "Missing X-GitHub-Event HTTP header."} diff --git a/tests/test_integration.py b/tests/test_integration.py index 9d6a438..1e295e0 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -1,137 +1,157 @@ import os -FIXURES_DIR = os.path.join( - os.path.dirname(os.path.abspath(__file__)), - 'fixtures' -) +FIXURES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fixtures") def test_issues_hook_valid_legacy_signature(github_app): """a valid webhook w/ legacy signature should return a 200 response with a valid response payload""" - with open(os.path.join(FIXURES_DIR, 'issues_hook.json'), 'rb') as hook: + with open(os.path.join(FIXURES_DIR, "issues_hook.json"), "rb") as hook: issues_data = hook.read() with github_app.test_client() as client: - resp = client.post('/', - data=issues_data, - headers={ - 'Content-Type': 'application/json', - 'X-GitHub-Event': 'issues', - 'X-Hub-Signature': 'sha1=ad68425a164a7a06d4849a63163f15656810175b' - }) + resp = client.post( + "/", + data=issues_data, + headers={ + "Content-Type": "application/json", + "X-GitHub-Event": "issues", + "X-Hub-Signature": "sha1=ad68425a164a7a06d4849a63163f15656810175b", + }, + ) assert resp.status_code == 200 - assert resp.json == {'calls': {'test_issue': 'issue event', 'test_issue_edited': 'issue edited action'}, - 'status': 'HIT'} + assert resp.json == { + "calls": {"test_issue": "issue event", "test_issue_edited": "issue edited action"}, + "status": "HIT", + } + def test_issues_hook_valid_signature(github_app): """a valid webhook w/ signature should return a 200 response with a valid response payload""" - with open(os.path.join(FIXURES_DIR, 'issues_hook.json'), 'rb') as hook: + with open(os.path.join(FIXURES_DIR, "issues_hook.json"), "rb") as hook: issues_data = hook.read() with github_app.test_client() as client: - resp = client.post('/', - data=issues_data, - headers={ - 'Content-Type': 'application/json', - 'X-GitHub-Event': 'issues', - 'X-Hub-Signature-256': 'sha256=5700c0515bec05804df657c3ebbf5f9585701a9f0a2a5633ca2d6dbd375a63a2' - }) + resp = client.post( + "/", + data=issues_data, + headers={ + "Content-Type": "application/json", + "X-GitHub-Event": "issues", + "X-Hub-Signature-256": "sha256=5700c0515bec05804df657c3ebbf5f9585701a9f0a2a5633ca2d6dbd375a63a2", + }, + ) assert resp.status_code == 200 - assert resp.json == {'calls': {'test_issue': 'issue event', 'test_issue_edited': 'issue edited action'}, 'status': 'HIT'} + assert resp.json == { + "calls": {"test_issue": "issue event", "test_issue_edited": "issue edited action"}, + "status": "HIT", + } def test_issues_hook_invalid_legacy_signature(github_app): - with open(os.path.join(FIXURES_DIR, 'issues_hook.json'), 'rb') as hook: + with open(os.path.join(FIXURES_DIR, "issues_hook.json"), "rb") as hook: issues_data = hook.read() with github_app.test_client() as client: - resp = client.post('/', - data=issues_data, - headers={ - 'Content-Type': 'application/json', - 'X-GitHub-Event': 'issues', - 'X-Hub-Signature': 'sha1=badhash' - }) + resp = client.post( + "/", + data=issues_data, + headers={"Content-Type": "application/json", "X-GitHub-Event": "issues", "X-Hub-Signature": "sha1=badhash"}, + ) assert resp.status_code == 400 def test_issues_hook_invalid_signature(github_app): - with open(os.path.join(FIXURES_DIR, 'issues_hook.json'), 'rb') as hook: + with open(os.path.join(FIXURES_DIR, "issues_hook.json"), "rb") as hook: issues_data = hook.read() with github_app.test_client() as client: - resp = client.post('/', - data=issues_data, - headers={ - 'Content-Type': 'application/json', - 'X-GitHub-Event': 'issues', - 'X-Hub-Signature': 'sha256=badhash' - }) + resp = client.post( + "/", + data=issues_data, + headers={ + "Content-Type": "application/json", + "X-GitHub-Event": "issues", + "X-Hub-Signature": "sha256=badhash", + }, + ) assert resp.status_code == 400 def test_issues_hook_missing_signature(github_app): """Return 400 response if the signature is missing and verification is enabled""" - with open(os.path.join(FIXURES_DIR, 'issues_hook.json'), 'rb') as hook: + with open(os.path.join(FIXURES_DIR, "issues_hook.json"), "rb") as hook: issues_data = hook.read() with github_app.test_client() as client: - resp = client.post('/', - data=issues_data, - headers={ - 'Content-Type': 'application/json', - 'X-GitHub-Event': 'issues', - }) + resp = client.post( + "/", + data=issues_data, + headers={ + "Content-Type": "application/json", + "X-GitHub-Event": "issues", + }, + ) assert resp.status_code == 400 def test_issues_hook_verification_disabled_missing_signature(github_app): """Return 200 response and valid payload if the signature is missing and verification has been disabled""" - with open(os.path.join(FIXURES_DIR, 'issues_hook.json'), 'rb') as hook: + with open(os.path.join(FIXURES_DIR, "issues_hook.json"), "rb") as hook: issues_data = hook.read() - github_app.config['GITHUBAPP_SECRET'] = False + github_app.config["GITHUBAPP_SECRET"] = False with github_app.test_client() as client: - resp = client.post('/', - data=issues_data, - headers={ - 'Content-Type': 'application/json', - 'X-GitHub-Event': 'issues', - }) + resp = client.post( + "/", + data=issues_data, + headers={ + "Content-Type": "application/json", + "X-GitHub-Event": "issues", + }, + ) assert resp.status_code == 200 - assert resp.json == {'calls': {'test_issue': 'issue event', 'test_issue_edited': 'issue edited action'}, 'status': 'HIT'} + assert resp.json == { + "calls": {"test_issue": "issue event", "test_issue_edited": "issue edited action"}, + "status": "HIT", + } + def test_hook_without_action(github_app): """Return a 200 response and valid payload for hooks without an action key""" - with open(os.path.join(FIXURES_DIR, 'push_hook.json'), 'rb') as hook: + with open(os.path.join(FIXURES_DIR, "push_hook.json"), "rb") as hook: issues_data = hook.read() - github_app.config['GITHUBAPP_SECRET'] = False + github_app.config["GITHUBAPP_SECRET"] = False with github_app.test_client() as client: - resp = client.post('/', - data=issues_data, - headers={ - 'Content-Type': 'application/json', - 'X-GitHub-Event': 'push', - }) + resp = client.post( + "/", + data=issues_data, + headers={ + "Content-Type": "application/json", + "X-GitHub-Event": "push", + }, + ) assert resp.status_code == 200 - assert resp.json == {'calls': {'test_push': 'push event'}, 'status': 'HIT'} + assert resp.json == {"calls": {"test_push": "push event"}, "status": "HIT"} + def test_hook_without_match(github_app): """Return a 200 response and valid payload for hooks with no matches""" - with open(os.path.join(FIXURES_DIR, 'release_hook.json'), 'rb') as hook: + with open(os.path.join(FIXURES_DIR, "release_hook.json"), "rb") as hook: issues_data = hook.read() - github_app.config['GITHUBAPP_SECRET'] = False + github_app.config["GITHUBAPP_SECRET"] = False with github_app.test_client() as client: - resp = client.post('/', - data=issues_data, - headers={ - 'Content-Type': 'application/json', - 'X-GitHub-Event': 'release', - }) + resp = client.post( + "/", + data=issues_data, + headers={ + "Content-Type": "application/json", + "X-GitHub-Event": "release", + }, + ) assert resp.status_code == 200 - assert resp.json == {'calls': {}, 'status': 'MISS'} + assert resp.json == {"calls": {}, "status": "MISS"}