├── samples └── cruel_closer │ ├── requirements.txt │ └── app.py ├── src └── flask_githubapp │ ├── version.py │ ├── __init__.py │ └── core.py ├── test_requirements.txt ├── test_requirements_flask_2.txt ├── tests ├── conftest.py ├── test_integration.py ├── fixtures │ ├── push_hook.json │ ├── release_hook.json │ └── issues_hook.json └── test_core.py ├── setup.py ├── .github └── workflows │ ├── test.yml │ └── release.yml ├── LICENSE ├── CHANGELOG.md ├── .gitignore ├── CODE_OF_CONDUCT.md └── README.md /samples/cruel_closer/requirements.txt: -------------------------------------------------------------------------------- 1 | flask-githubapp -------------------------------------------------------------------------------- /src/flask_githubapp/version.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.4.0' 2 | -------------------------------------------------------------------------------- /test_requirements.txt: -------------------------------------------------------------------------------- 1 | file:. 2 | 3 | pytest 4 | pytest-mock 5 | -------------------------------------------------------------------------------- /test_requirements_flask_2.txt: -------------------------------------------------------------------------------- 1 | file:. 2 | 3 | pytest 4 | pytest-mock 5 | 6 | flask==2.0.0 7 | werkzeug==2.0.3 8 | -------------------------------------------------------------------------------- /src/flask_githubapp/__init__.py: -------------------------------------------------------------------------------- 1 | from .core import ( 2 | GitHubApp 3 | ) 4 | 5 | from .version import __version__ 6 | 7 | __all__ = ['GitHubApp'] 8 | 9 | # Set default logging handler to avoid "No handler found" warnings. 10 | import logging 11 | from logging import NullHandler 12 | 13 | # Set initial level to WARN. Users must manually enable logging for 14 | # flask_githubapp to see our logging. 15 | rootlogger = logging.getLogger(__name__) 16 | rootlogger.addHandler(NullHandler()) 17 | 18 | if rootlogger.level == logging.NOTSET: 19 | rootlogger.setLevel(logging.WARN) 20 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from flask import Flask 4 | 5 | from flask_githubapp import GitHubApp 6 | 7 | 8 | @pytest.fixture 9 | def app(): 10 | app = Flask('test_app') 11 | app.config['GITHUBAPP_ID'] = 1 12 | app.config['GITHUBAPP_KEY'] = 'key' 13 | app.config['GITHUBAPP_SECRET'] = 'secret' 14 | return app 15 | 16 | @pytest.fixture 17 | def github_app(app): 18 | github_app = GitHubApp(app) 19 | 20 | @github_app.on('issues') 21 | def test_issue(): 22 | return 'issue event' 23 | 24 | @github_app.on('issues.edited') 25 | def test_issue_edited(): 26 | return 'issue edited action' 27 | 28 | @github_app.on('push') 29 | def test_push(): 30 | return 'push event' 31 | 32 | return app 33 | -------------------------------------------------------------------------------- /samples/cruel_closer/app.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from flask import Flask 4 | from flask_githubapp import GitHubApp 5 | 6 | app = Flask(__name__) 7 | 8 | app.config['GITHUBAPP_ID'] = int(os.environ['GITHUBAPP_ID']) 9 | 10 | with open(os.environ['GITHUBAPP_KEY_PATH'], 'rb') as key_file: 11 | app.config['GITHUBAPP_KEY'] = key_file.read() 12 | 13 | app.config['GITHUBAPP_SECRET'] = os.environ['GITHUBAPP_SECRET'] 14 | 15 | github_app = GitHubApp(app) 16 | 17 | 18 | @github_app.on('issues.opened') 19 | def cruel_closer(): 20 | owner = github_app.payload['repository']['owner']['login'] 21 | repo = github_app.payload['repository']['name'] 22 | num = github_app.payload['issue']['number'] 23 | issue = github_app.installation_client.issue(owner, repo, num) 24 | issue.create_comment('Could not replicate.') 25 | issue.close() 26 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | Flask-GitHubApp 3 | --------------- 4 | 5 | Easy GitHub App integration for Flask 6 | """ 7 | import os 8 | from setuptools import setup 9 | 10 | with open(os.path.join(os.path.dirname(__file__), 'src/flask_githubapp/version.py'), 'r') as f: 11 | exec(f.read()) 12 | 13 | 14 | setup( 15 | name='Flask-GitHubApp', 16 | package_dir = {"": "src"}, 17 | version=__version__, 18 | url='https://github.com/bradshjg/flask-githubapp', 19 | license='MIT', 20 | author='Jimmy Bradshaw', 21 | author_email='james.g.bradshaw@gmail.com', 22 | description='Rapid GitHub app development in Python', 23 | long_description=__doc__, 24 | packages=['flask_githubapp'], 25 | zip_safe=False, 26 | include_package_data=True, 27 | platforms='any', 28 | install_requires=[ 29 | 'flask', 30 | 'github3.py' 31 | ], 32 | ) 33 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run packages tests using a variety of Python versions. 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Python package test 5 | 6 | on: 7 | pull_request: 8 | types: [opened, synchronize] 9 | push: 10 | branches: [master] 11 | 12 | jobs: 13 | test: 14 | 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | python-version: ["3.8.15", "3.13.7"] 19 | python-requirements: ["test_requirements.txt", "test_requirements_flask_2.txt"] 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | - name: Set up Python ${{ matrix.python-version }} 24 | uses: actions/setup-python@v5 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | pip install -r ${{ matrix.python-requirements }} 31 | - name: Test with pytest 32 | run: | 33 | pytest 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 James Bradshaw 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | 9 | ## [0.4.0] 10 | 11 | ### Added 12 | - Support for Flask 3 (thanks @kuldeepk!) 13 | 14 | ## [0.3.0] 15 | 16 | ### Changed 17 | - `GITHUBAPP_SECRET` can now be set to `False` to skip verifying the signature of 18 | the webhook payload. 19 | 20 | - The `X-Hub-Signature-256` header is now preferred for signature checking. 21 | Previously, `X-Hub-Signature` (and `sha1`) were used to verify the payload. 22 | 23 | ## [0.2.0] 24 | ### Added 25 | - This changelog. 26 | - Several new tests. 27 | 28 | ### Changed 29 | - Response now includes whether any functions were called as the "status" as well 30 | as a map of function names and returned values of any functions called as "calls" 31 | (JSON formatted). Due to this functions must now return JSON serializable data. 32 | The goal is to aid in debugging, as GitHub allows app owners to view hook responses. 33 | 34 | ## 0.1.0 35 | ### Added 36 | - Initial release 37 | 38 | 39 | [Unreleased]: https://github.com/bradshjg/flask-githubapp/compare/0.3.0...HEAD 40 | [0.2.0]: https://github.com/bradshjg/flask-githubapp/compare/0.1.0...0.2.0 41 | [0.3.0]: https://github.com/bradshjg/flask-githubapp/compare/0.2.0...0.3.0 42 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # This workflow will publish to PyPI on release. 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: PyPI release 5 | 6 | on: workflow_dispatch 7 | 8 | jobs: 9 | build: 10 | name: Build distribution 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | with: 16 | persist-credentials: false 17 | - name: Set up Python 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: "3.x" 21 | - name: Install pypa/build 22 | run: >- 23 | python3 -m 24 | pip install 25 | build 26 | --user 27 | - name: Build a binary wheel and a source tarball 28 | run: python3 -m build 29 | - name: Store the distribution packages 30 | uses: actions/upload-artifact@v4 31 | with: 32 | name: python-package-distributions 33 | path: dist/ 34 | publish: 35 | name: Publish to PyPI 36 | needs: 37 | - build 38 | runs-on: ubuntu-latest 39 | 40 | environment: 41 | name: pypi 42 | url: https://pypi.org/p/Flask-GitHubApp 43 | 44 | permissions: 45 | id-token: write 46 | 47 | steps: 48 | - name: Download all the dists 49 | uses: actions/download-artifact@v4 50 | with: 51 | name: python-package-distributions 52 | path: dist/ 53 | - name: Publish distribution 📦 to PyPI 54 | uses: pypa/gh-action-pypi-publish@release/v1 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | 3 | # Created by .ignore support plugin (hsz.mobi) 4 | ### Python template 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | 63 | # Flask stuff: 64 | instance/ 65 | .webassets-cache 66 | 67 | # Scrapy stuff: 68 | .scrapy 69 | 70 | # Sphinx documentation 71 | docs/_build/ 72 | 73 | # PyBuilder 74 | target/ 75 | 76 | # Jupyter Notebook 77 | .ipynb_checkpoints 78 | 79 | # pyenv 80 | .python-version 81 | 82 | # celery beat schedule file 83 | celerybeat-schedule 84 | 85 | # SageMath parsed files 86 | *.sage.py 87 | 88 | # Environments 89 | .env 90 | .venv 91 | env/ 92 | venv/ 93 | ENV/ 94 | env.bak/ 95 | venv.bak/ 96 | 97 | # Spyder project settings 98 | .spyderproject 99 | .spyproject 100 | 101 | # Rope project settings 102 | .ropeproject 103 | 104 | # mkdocs documentation 105 | /site 106 | 107 | # mypy 108 | .mypy_cache/ 109 | 110 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at james.g.bradshaw@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # flask-githubapp ![tests](https://github.com/bradshjg/flask-githubapp/actions/workflows/test.yml/badge.svg) 2 | 3 | Flask extension for rapid Github app development in Python, in the spirit of [probot](https://probot.github.io/) 4 | 5 | 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. 6 | 7 | ## Getting Started 8 | 9 | ### Installation 10 | To install Flask-GitHubApp: 11 | 12 | `pip install flask-githubapp` 13 | 14 | Or better yet, add it to your app's requirements.txt file! ;) 15 | 16 | > `flask-githubapp` requires Python 3.8+ and Flask 2+ 17 | 18 | #### Create GitHub App 19 | 20 | Follow GitHub's docs on [creating a github app](https://developer.github.com/apps/building-github-apps/creating-a-github-app/). 21 | 22 | > You can, in principle, register any type of payload to be sent to the app! 23 | 24 | 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. 25 | 26 | #### Build the Flask App 27 | 28 | The GithubApp package has a decorator, `@on`, that will allow you to register events, and actions, to specific functions. 29 | For instance, 30 | 31 | ```python 32 | @github_app.on('issues.opened') 33 | def cruel_closer(): 34 | #do stuff here 35 | ``` 36 | 37 | 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` 38 | 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. 39 | You can also have something like 40 | 41 | ```python 42 | @github_app.on('issues') 43 | def issue_tracker(): 44 | #do stuff here 45 | ``` 46 | 47 | The above function will do `stuff here` for _every_ `issues` event received. This can be useful for specific workflows, to bring developers in early. 48 | 49 | 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` 50 | 51 | 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! 52 | 53 | #### Run it locally 54 | 55 | For quick iteration, you can set up your environment as follows: 56 | 57 | ```bash 58 | EXPORT GITHUBAPP_SECRET=False # this will circumvent request verification 59 | 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 60 | ``` 61 | 62 | 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. 63 | Once that is in place, run your github app 64 | 65 | ```bash 66 | flask run 67 | ``` 68 | 69 | 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! 70 | 71 | ```bash 72 | curl -H "X-GitHub-Event: " -H "Content-Type: application/json" -X POST -d @./path/to/payload.json http://localhost:5000 73 | ``` 74 | 75 | #### Install your GitHub App 76 | 77 | **Settings** > **Applications** > **Configure** 78 | 79 | > 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. 80 | 81 | #### Deploy your GitHub App 82 | 83 | 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. 84 | 85 | ## Usage 86 | 87 | ### `GitHubApp` Instance Attributes 88 | 89 | `payload`: In the context of a hook request, a Python dict representing the hook payload (raises a `RuntimeError` 90 | outside a hook context). 91 | 92 | `installation_client`: In the context of a hook request, a [github3.py](https://github3py.readthedocs.io/en/master/) client authenticated as the app installation (raises a `RuntimeError` outside a hook context.) 93 | 94 | `app_client`: A [github3.py](https://github3py.readthedocs.io/en/master/) client authenticated as the app. 95 | 96 | `installation_token`: The token used to authenticate as the app installation (useful for passing to async tasks). 97 | 98 | ## Configuration 99 | 100 | `GITHUBAPP_ID`: GitHub app ID as an int (required). Default: None 101 | 102 | `GITHUBAPP_KEY`: Private key used to sign access token requests as bytes or utf-8 encoded string (required). Default: None 103 | 104 | `GITHUBAPP_SECRET`: Secret used to secure webhooks as bytes or utf-8 encoded string (required). Set to `False` to disable 105 | verification. 106 | 107 | `GITHUBAPP_URL`: URL of GitHub instance (used for GitHub Enterprise) as a string. Default: None 108 | 109 | `GITHUBAPP_ROUTE`: Path used for GitHub hook requests as a string. Default: '/' 110 | 111 | 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) 112 | 113 | #### Cruel Closer 114 | 115 | 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. 116 | That's just r00d! 117 | -------------------------------------------------------------------------------- /tests/test_integration.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | FIXURES_DIR = os.path.join( 4 | os.path.dirname(os.path.abspath(__file__)), 5 | 'fixtures' 6 | ) 7 | 8 | 9 | def test_issues_hook_valid_legacy_signature(github_app): 10 | """a valid webhook w/ legacy signature should return a 200 response with a valid response payload""" 11 | with open(os.path.join(FIXURES_DIR, 'issues_hook.json'), 'rb') as hook: 12 | issues_data = hook.read() 13 | 14 | with github_app.test_client() as client: 15 | resp = client.post('/', 16 | data=issues_data, 17 | headers={ 18 | 'Content-Type': 'application/json', 19 | 'X-GitHub-Event': 'issues', 20 | 'X-Hub-Signature': 'sha1=ad68425a164a7a06d4849a63163f15656810175b' 21 | }) 22 | assert resp.status_code == 200 23 | assert resp.json == {'calls': {'test_issue': 'issue event', 'test_issue_edited': 'issue edited action'}, 24 | 'status': 'HIT'} 25 | 26 | def test_issues_hook_valid_signature(github_app): 27 | """a valid webhook w/ signature should return a 200 response with a valid response payload""" 28 | with open(os.path.join(FIXURES_DIR, 'issues_hook.json'), 'rb') as hook: 29 | issues_data = hook.read() 30 | 31 | with github_app.test_client() as client: 32 | resp = client.post('/', 33 | data=issues_data, 34 | headers={ 35 | 'Content-Type': 'application/json', 36 | 'X-GitHub-Event': 'issues', 37 | 'X-Hub-Signature-256': 'sha256=5700c0515bec05804df657c3ebbf5f9585701a9f0a2a5633ca2d6dbd375a63a2' 38 | }) 39 | assert resp.status_code == 200 40 | assert resp.json == {'calls': {'test_issue': 'issue event', 'test_issue_edited': 'issue edited action'}, 'status': 'HIT'} 41 | 42 | 43 | def test_issues_hook_invalid_legacy_signature(github_app): 44 | with open(os.path.join(FIXURES_DIR, 'issues_hook.json'), 'rb') as hook: 45 | issues_data = hook.read() 46 | 47 | with github_app.test_client() as client: 48 | resp = client.post('/', 49 | data=issues_data, 50 | headers={ 51 | 'Content-Type': 'application/json', 52 | 'X-GitHub-Event': 'issues', 53 | 'X-Hub-Signature': 'sha1=badhash' 54 | }) 55 | assert resp.status_code == 400 56 | 57 | 58 | def test_issues_hook_invalid_signature(github_app): 59 | with open(os.path.join(FIXURES_DIR, 'issues_hook.json'), 'rb') as hook: 60 | issues_data = hook.read() 61 | 62 | with github_app.test_client() as client: 63 | resp = client.post('/', 64 | data=issues_data, 65 | headers={ 66 | 'Content-Type': 'application/json', 67 | 'X-GitHub-Event': 'issues', 68 | 'X-Hub-Signature': 'sha256=badhash' 69 | }) 70 | assert resp.status_code == 400 71 | 72 | 73 | def test_issues_hook_missing_signature(github_app): 74 | """Return 400 response if the signature is missing and verification is enabled""" 75 | with open(os.path.join(FIXURES_DIR, 'issues_hook.json'), 'rb') as hook: 76 | issues_data = hook.read() 77 | 78 | with github_app.test_client() as client: 79 | resp = client.post('/', 80 | data=issues_data, 81 | headers={ 82 | 'Content-Type': 'application/json', 83 | 'X-GitHub-Event': 'issues', 84 | }) 85 | assert resp.status_code == 400 86 | 87 | 88 | def test_issues_hook_verification_disabled_missing_signature(github_app): 89 | """Return 200 response and valid payload if the signature is missing and verification has been disabled""" 90 | with open(os.path.join(FIXURES_DIR, 'issues_hook.json'), 'rb') as hook: 91 | issues_data = hook.read() 92 | 93 | github_app.config['GITHUBAPP_SECRET'] = False 94 | 95 | with github_app.test_client() as client: 96 | resp = client.post('/', 97 | data=issues_data, 98 | headers={ 99 | 'Content-Type': 'application/json', 100 | 'X-GitHub-Event': 'issues', 101 | }) 102 | assert resp.status_code == 200 103 | assert resp.json == {'calls': {'test_issue': 'issue event', 'test_issue_edited': 'issue edited action'}, 'status': 'HIT'} 104 | 105 | def test_hook_without_action(github_app): 106 | """Return a 200 response and valid payload for hooks without an action key""" 107 | with open(os.path.join(FIXURES_DIR, 'push_hook.json'), 'rb') as hook: 108 | issues_data = hook.read() 109 | 110 | github_app.config['GITHUBAPP_SECRET'] = False 111 | 112 | with github_app.test_client() as client: 113 | resp = client.post('/', 114 | data=issues_data, 115 | headers={ 116 | 'Content-Type': 'application/json', 117 | 'X-GitHub-Event': 'push', 118 | }) 119 | assert resp.status_code == 200 120 | assert resp.json == {'calls': {'test_push': 'push event'}, 'status': 'HIT'} 121 | 122 | def test_hook_without_match(github_app): 123 | """Return a 200 response and valid payload for hooks with no matches""" 124 | with open(os.path.join(FIXURES_DIR, 'release_hook.json'), 'rb') as hook: 125 | issues_data = hook.read() 126 | 127 | github_app.config['GITHUBAPP_SECRET'] = False 128 | 129 | with github_app.test_client() as client: 130 | resp = client.post('/', 131 | data=issues_data, 132 | headers={ 133 | 'Content-Type': 'application/json', 134 | 'X-GitHub-Event': 'release', 135 | }) 136 | assert resp.status_code == 200 137 | assert resp.json == {'calls': {}, 'status': 'MISS'} 138 | -------------------------------------------------------------------------------- /tests/fixtures/push_hook.json: -------------------------------------------------------------------------------- 1 | { 2 | "ref": "refs/tags/simple-tag", 3 | "before": "6113728f27ae82c7b1a177c8d03f9e96e0adf246", 4 | "after": "0000000000000000000000000000000000000000", 5 | "created": false, 6 | "deleted": true, 7 | "forced": false, 8 | "base_ref": null, 9 | "compare": "https://github.com/Codertocat/Hello-World/compare/6113728f27ae...000000000000", 10 | "commits": [], 11 | "head_commit": null, 12 | "repository": { 13 | "id": 186853002, 14 | "node_id": "MDEwOlJlcG9zaXRvcnkxODY4NTMwMDI=", 15 | "name": "Hello-World", 16 | "full_name": "Codertocat/Hello-World", 17 | "private": false, 18 | "owner": { 19 | "name": "Codertocat", 20 | "email": "21031067+Codertocat@users.noreply.github.com", 21 | "login": "Codertocat", 22 | "id": 21031067, 23 | "node_id": "MDQ6VXNlcjIxMDMxMDY3", 24 | "avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4", 25 | "gravatar_id": "", 26 | "url": "https://api.github.com/users/Codertocat", 27 | "html_url": "https://github.com/Codertocat", 28 | "followers_url": "https://api.github.com/users/Codertocat/followers", 29 | "following_url": "https://api.github.com/users/Codertocat/following{/other_user}", 30 | "gists_url": "https://api.github.com/users/Codertocat/gists{/gist_id}", 31 | "starred_url": "https://api.github.com/users/Codertocat/starred{/owner}{/repo}", 32 | "subscriptions_url": "https://api.github.com/users/Codertocat/subscriptions", 33 | "organizations_url": "https://api.github.com/users/Codertocat/orgs", 34 | "repos_url": "https://api.github.com/users/Codertocat/repos", 35 | "events_url": "https://api.github.com/users/Codertocat/events{/privacy}", 36 | "received_events_url": "https://api.github.com/users/Codertocat/received_events", 37 | "type": "User", 38 | "site_admin": false 39 | }, 40 | "html_url": "https://github.com/Codertocat/Hello-World", 41 | "description": null, 42 | "fork": false, 43 | "url": "https://github.com/Codertocat/Hello-World", 44 | "forks_url": "https://api.github.com/repos/Codertocat/Hello-World/forks", 45 | "keys_url": "https://api.github.com/repos/Codertocat/Hello-World/keys{/key_id}", 46 | "collaborators_url": "https://api.github.com/repos/Codertocat/Hello-World/collaborators{/collaborator}", 47 | "teams_url": "https://api.github.com/repos/Codertocat/Hello-World/teams", 48 | "hooks_url": "https://api.github.com/repos/Codertocat/Hello-World/hooks", 49 | "issue_events_url": "https://api.github.com/repos/Codertocat/Hello-World/issues/events{/number}", 50 | "events_url": "https://api.github.com/repos/Codertocat/Hello-World/events", 51 | "assignees_url": "https://api.github.com/repos/Codertocat/Hello-World/assignees{/user}", 52 | "branches_url": "https://api.github.com/repos/Codertocat/Hello-World/branches{/branch}", 53 | "tags_url": "https://api.github.com/repos/Codertocat/Hello-World/tags", 54 | "blobs_url": "https://api.github.com/repos/Codertocat/Hello-World/git/blobs{/sha}", 55 | "git_tags_url": "https://api.github.com/repos/Codertocat/Hello-World/git/tags{/sha}", 56 | "git_refs_url": "https://api.github.com/repos/Codertocat/Hello-World/git/refs{/sha}", 57 | "trees_url": "https://api.github.com/repos/Codertocat/Hello-World/git/trees{/sha}", 58 | "statuses_url": "https://api.github.com/repos/Codertocat/Hello-World/statuses/{sha}", 59 | "languages_url": "https://api.github.com/repos/Codertocat/Hello-World/languages", 60 | "stargazers_url": "https://api.github.com/repos/Codertocat/Hello-World/stargazers", 61 | "contributors_url": "https://api.github.com/repos/Codertocat/Hello-World/contributors", 62 | "subscribers_url": "https://api.github.com/repos/Codertocat/Hello-World/subscribers", 63 | "subscription_url": "https://api.github.com/repos/Codertocat/Hello-World/subscription", 64 | "commits_url": "https://api.github.com/repos/Codertocat/Hello-World/commits{/sha}", 65 | "git_commits_url": "https://api.github.com/repos/Codertocat/Hello-World/git/commits{/sha}", 66 | "comments_url": "https://api.github.com/repos/Codertocat/Hello-World/comments{/number}", 67 | "issue_comment_url": "https://api.github.com/repos/Codertocat/Hello-World/issues/comments{/number}", 68 | "contents_url": "https://api.github.com/repos/Codertocat/Hello-World/contents/{+path}", 69 | "compare_url": "https://api.github.com/repos/Codertocat/Hello-World/compare/{base}...{head}", 70 | "merges_url": "https://api.github.com/repos/Codertocat/Hello-World/merges", 71 | "archive_url": "https://api.github.com/repos/Codertocat/Hello-World/{archive_format}{/ref}", 72 | "downloads_url": "https://api.github.com/repos/Codertocat/Hello-World/downloads", 73 | "issues_url": "https://api.github.com/repos/Codertocat/Hello-World/issues{/number}", 74 | "pulls_url": "https://api.github.com/repos/Codertocat/Hello-World/pulls{/number}", 75 | "milestones_url": "https://api.github.com/repos/Codertocat/Hello-World/milestones{/number}", 76 | "notifications_url": "https://api.github.com/repos/Codertocat/Hello-World/notifications{?since,all,participating}", 77 | "labels_url": "https://api.github.com/repos/Codertocat/Hello-World/labels{/name}", 78 | "releases_url": "https://api.github.com/repos/Codertocat/Hello-World/releases{/id}", 79 | "deployments_url": "https://api.github.com/repos/Codertocat/Hello-World/deployments", 80 | "created_at": 1557933565, 81 | "updated_at": "2019-05-15T15:20:41Z", 82 | "pushed_at": 1557933657, 83 | "git_url": "git://github.com/Codertocat/Hello-World.git", 84 | "ssh_url": "git@github.com:Codertocat/Hello-World.git", 85 | "clone_url": "https://github.com/Codertocat/Hello-World.git", 86 | "svn_url": "https://github.com/Codertocat/Hello-World", 87 | "homepage": null, 88 | "size": 0, 89 | "stargazers_count": 0, 90 | "watchers_count": 0, 91 | "language": "Ruby", 92 | "has_issues": true, 93 | "has_projects": true, 94 | "has_downloads": true, 95 | "has_wiki": true, 96 | "has_pages": true, 97 | "forks_count": 1, 98 | "mirror_url": null, 99 | "archived": false, 100 | "disabled": false, 101 | "open_issues_count": 2, 102 | "license": null, 103 | "forks": 1, 104 | "open_issues": 2, 105 | "watchers": 0, 106 | "default_branch": "master", 107 | "stargazers": 0, 108 | "master_branch": "master" 109 | }, 110 | "pusher": { 111 | "name": "Codertocat", 112 | "email": "21031067+Codertocat@users.noreply.github.com" 113 | }, 114 | "sender": { 115 | "login": "Codertocat", 116 | "id": 21031067, 117 | "node_id": "MDQ6VXNlcjIxMDMxMDY3", 118 | "avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4", 119 | "gravatar_id": "", 120 | "url": "https://api.github.com/users/Codertocat", 121 | "html_url": "https://github.com/Codertocat", 122 | "followers_url": "https://api.github.com/users/Codertocat/followers", 123 | "following_url": "https://api.github.com/users/Codertocat/following{/other_user}", 124 | "gists_url": "https://api.github.com/users/Codertocat/gists{/gist_id}", 125 | "starred_url": "https://api.github.com/users/Codertocat/starred{/owner}{/repo}", 126 | "subscriptions_url": "https://api.github.com/users/Codertocat/subscriptions", 127 | "organizations_url": "https://api.github.com/users/Codertocat/orgs", 128 | "repos_url": "https://api.github.com/users/Codertocat/repos", 129 | "events_url": "https://api.github.com/users/Codertocat/events{/privacy}", 130 | "received_events_url": "https://api.github.com/users/Codertocat/received_events", 131 | "type": "User", 132 | "site_admin": false 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /tests/fixtures/release_hook.json: -------------------------------------------------------------------------------- 1 | { 2 | "action": "published", 3 | "release": { 4 | "url": "https://api.github.com/repos/Codertocat/Hello-World/releases/17372790", 5 | "assets_url": "https://api.github.com/repos/Codertocat/Hello-World/releases/17372790/assets", 6 | "upload_url": "https://uploads.github.com/repos/Codertocat/Hello-World/releases/17372790/assets{?name,label}", 7 | "html_url": "https://github.com/Codertocat/Hello-World/releases/tag/0.0.1", 8 | "id": 17372790, 9 | "node_id": "MDc6UmVsZWFzZTE3MzcyNzkw", 10 | "tag_name": "0.0.1", 11 | "target_commitish": "master", 12 | "name": null, 13 | "draft": false, 14 | "author": { 15 | "login": "Codertocat", 16 | "id": 21031067, 17 | "node_id": "MDQ6VXNlcjIxMDMxMDY3", 18 | "avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4", 19 | "gravatar_id": "", 20 | "url": "https://api.github.com/users/Codertocat", 21 | "html_url": "https://github.com/Codertocat", 22 | "followers_url": "https://api.github.com/users/Codertocat/followers", 23 | "following_url": "https://api.github.com/users/Codertocat/following{/other_user}", 24 | "gists_url": "https://api.github.com/users/Codertocat/gists{/gist_id}", 25 | "starred_url": "https://api.github.com/users/Codertocat/starred{/owner}{/repo}", 26 | "subscriptions_url": "https://api.github.com/users/Codertocat/subscriptions", 27 | "organizations_url": "https://api.github.com/users/Codertocat/orgs", 28 | "repos_url": "https://api.github.com/users/Codertocat/repos", 29 | "events_url": "https://api.github.com/users/Codertocat/events{/privacy}", 30 | "received_events_url": "https://api.github.com/users/Codertocat/received_events", 31 | "type": "User", 32 | "site_admin": false 33 | }, 34 | "prerelease": false, 35 | "created_at": "2019-05-15T15:19:25Z", 36 | "published_at": "2019-05-15T15:20:53Z", 37 | "assets": [], 38 | "tarball_url": "https://api.github.com/repos/Codertocat/Hello-World/tarball/0.0.1", 39 | "zipball_url": "https://api.github.com/repos/Codertocat/Hello-World/zipball/0.0.1", 40 | "body": null 41 | }, 42 | "repository": { 43 | "id": 186853002, 44 | "node_id": "MDEwOlJlcG9zaXRvcnkxODY4NTMwMDI=", 45 | "name": "Hello-World", 46 | "full_name": "Codertocat/Hello-World", 47 | "private": false, 48 | "owner": { 49 | "login": "Codertocat", 50 | "id": 21031067, 51 | "node_id": "MDQ6VXNlcjIxMDMxMDY3", 52 | "avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4", 53 | "gravatar_id": "", 54 | "url": "https://api.github.com/users/Codertocat", 55 | "html_url": "https://github.com/Codertocat", 56 | "followers_url": "https://api.github.com/users/Codertocat/followers", 57 | "following_url": "https://api.github.com/users/Codertocat/following{/other_user}", 58 | "gists_url": "https://api.github.com/users/Codertocat/gists{/gist_id}", 59 | "starred_url": "https://api.github.com/users/Codertocat/starred{/owner}{/repo}", 60 | "subscriptions_url": "https://api.github.com/users/Codertocat/subscriptions", 61 | "organizations_url": "https://api.github.com/users/Codertocat/orgs", 62 | "repos_url": "https://api.github.com/users/Codertocat/repos", 63 | "events_url": "https://api.github.com/users/Codertocat/events{/privacy}", 64 | "received_events_url": "https://api.github.com/users/Codertocat/received_events", 65 | "type": "User", 66 | "site_admin": false 67 | }, 68 | "html_url": "https://github.com/Codertocat/Hello-World", 69 | "description": null, 70 | "fork": false, 71 | "url": "https://api.github.com/repos/Codertocat/Hello-World", 72 | "forks_url": "https://api.github.com/repos/Codertocat/Hello-World/forks", 73 | "keys_url": "https://api.github.com/repos/Codertocat/Hello-World/keys{/key_id}", 74 | "collaborators_url": "https://api.github.com/repos/Codertocat/Hello-World/collaborators{/collaborator}", 75 | "teams_url": "https://api.github.com/repos/Codertocat/Hello-World/teams", 76 | "hooks_url": "https://api.github.com/repos/Codertocat/Hello-World/hooks", 77 | "issue_events_url": "https://api.github.com/repos/Codertocat/Hello-World/issues/events{/number}", 78 | "events_url": "https://api.github.com/repos/Codertocat/Hello-World/events", 79 | "assignees_url": "https://api.github.com/repos/Codertocat/Hello-World/assignees{/user}", 80 | "branches_url": "https://api.github.com/repos/Codertocat/Hello-World/branches{/branch}", 81 | "tags_url": "https://api.github.com/repos/Codertocat/Hello-World/tags", 82 | "blobs_url": "https://api.github.com/repos/Codertocat/Hello-World/git/blobs{/sha}", 83 | "git_tags_url": "https://api.github.com/repos/Codertocat/Hello-World/git/tags{/sha}", 84 | "git_refs_url": "https://api.github.com/repos/Codertocat/Hello-World/git/refs{/sha}", 85 | "trees_url": "https://api.github.com/repos/Codertocat/Hello-World/git/trees{/sha}", 86 | "statuses_url": "https://api.github.com/repos/Codertocat/Hello-World/statuses/{sha}", 87 | "languages_url": "https://api.github.com/repos/Codertocat/Hello-World/languages", 88 | "stargazers_url": "https://api.github.com/repos/Codertocat/Hello-World/stargazers", 89 | "contributors_url": "https://api.github.com/repos/Codertocat/Hello-World/contributors", 90 | "subscribers_url": "https://api.github.com/repos/Codertocat/Hello-World/subscribers", 91 | "subscription_url": "https://api.github.com/repos/Codertocat/Hello-World/subscription", 92 | "commits_url": "https://api.github.com/repos/Codertocat/Hello-World/commits{/sha}", 93 | "git_commits_url": "https://api.github.com/repos/Codertocat/Hello-World/git/commits{/sha}", 94 | "comments_url": "https://api.github.com/repos/Codertocat/Hello-World/comments{/number}", 95 | "issue_comment_url": "https://api.github.com/repos/Codertocat/Hello-World/issues/comments{/number}", 96 | "contents_url": "https://api.github.com/repos/Codertocat/Hello-World/contents/{+path}", 97 | "compare_url": "https://api.github.com/repos/Codertocat/Hello-World/compare/{base}...{head}", 98 | "merges_url": "https://api.github.com/repos/Codertocat/Hello-World/merges", 99 | "archive_url": "https://api.github.com/repos/Codertocat/Hello-World/{archive_format}{/ref}", 100 | "downloads_url": "https://api.github.com/repos/Codertocat/Hello-World/downloads", 101 | "issues_url": "https://api.github.com/repos/Codertocat/Hello-World/issues{/number}", 102 | "pulls_url": "https://api.github.com/repos/Codertocat/Hello-World/pulls{/number}", 103 | "milestones_url": "https://api.github.com/repos/Codertocat/Hello-World/milestones{/number}", 104 | "notifications_url": "https://api.github.com/repos/Codertocat/Hello-World/notifications{?since,all,participating}", 105 | "labels_url": "https://api.github.com/repos/Codertocat/Hello-World/labels{/name}", 106 | "releases_url": "https://api.github.com/repos/Codertocat/Hello-World/releases{/id}", 107 | "deployments_url": "https://api.github.com/repos/Codertocat/Hello-World/deployments", 108 | "created_at": "2019-05-15T15:19:25Z", 109 | "updated_at": "2019-05-15T15:20:41Z", 110 | "pushed_at": "2019-05-15T15:20:52Z", 111 | "git_url": "git://github.com/Codertocat/Hello-World.git", 112 | "ssh_url": "git@github.com:Codertocat/Hello-World.git", 113 | "clone_url": "https://github.com/Codertocat/Hello-World.git", 114 | "svn_url": "https://github.com/Codertocat/Hello-World", 115 | "homepage": null, 116 | "size": 0, 117 | "stargazers_count": 0, 118 | "watchers_count": 0, 119 | "language": "Ruby", 120 | "has_issues": true, 121 | "has_projects": true, 122 | "has_downloads": true, 123 | "has_wiki": true, 124 | "has_pages": true, 125 | "forks_count": 1, 126 | "mirror_url": null, 127 | "archived": false, 128 | "disabled": false, 129 | "open_issues_count": 2, 130 | "license": null, 131 | "forks": 1, 132 | "open_issues": 2, 133 | "watchers": 0, 134 | "default_branch": "master" 135 | }, 136 | "sender": { 137 | "login": "Codertocat", 138 | "id": 21031067, 139 | "node_id": "MDQ6VXNlcjIxMDMxMDY3", 140 | "avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4", 141 | "gravatar_id": "", 142 | "url": "https://api.github.com/users/Codertocat", 143 | "html_url": "https://github.com/Codertocat", 144 | "followers_url": "https://api.github.com/users/Codertocat/followers", 145 | "following_url": "https://api.github.com/users/Codertocat/following{/other_user}", 146 | "gists_url": "https://api.github.com/users/Codertocat/gists{/gist_id}", 147 | "starred_url": "https://api.github.com/users/Codertocat/starred{/owner}{/repo}", 148 | "subscriptions_url": "https://api.github.com/users/Codertocat/subscriptions", 149 | "organizations_url": "https://api.github.com/users/Codertocat/orgs", 150 | "repos_url": "https://api.github.com/users/Codertocat/repos", 151 | "events_url": "https://api.github.com/users/Codertocat/events{/privacy}", 152 | "received_events_url": "https://api.github.com/users/Codertocat/received_events", 153 | "type": "User", 154 | "site_admin": false 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/flask_githubapp/core.py: -------------------------------------------------------------------------------- 1 | """Flask extension for rapid GitHub app development""" 2 | import hmac 3 | import logging 4 | 5 | from flask import abort, current_app, jsonify, make_response, request, g 6 | from github3 import GitHub, GitHubEnterprise 7 | from werkzeug.exceptions import BadRequest 8 | 9 | LOG = logging.getLogger(__name__) 10 | 11 | STATUS_FUNC_CALLED = 'HIT' 12 | STATUS_NO_FUNC_CALLED = 'MISS' 13 | 14 | 15 | class GitHubAppError(Exception): 16 | pass 17 | 18 | 19 | class GitHubAppValidationError(GitHubAppError): 20 | pass 21 | 22 | 23 | class GitHubApp(object): 24 | """The GitHubApp object provides the central interface for interacting GitHub hooks 25 | and creating GitHub app clients. 26 | 27 | GitHubApp object allows using the "on" decorator to make GitHub hooks to functions 28 | and provides authenticated github3.py clients for interacting with the GitHub API. 29 | 30 | Keyword Arguments: 31 | app {Flask object} -- App instance - created with Flask(__name__) (default: {None}) 32 | """ 33 | def __init__(self, app=None): 34 | self._hook_mappings = {} 35 | if app is not None: 36 | self.init_app(app) 37 | 38 | def init_app(self, app): 39 | """Initializes GitHubApp app by setting configuration variables. 40 | 41 | The GitHubApp instance is given the following configuration variables by calling on Flask's configuration: 42 | 43 | `GITHUBAPP_ID`: 44 | 45 | GitHub app ID as an int (required). 46 | Default: None 47 | 48 | `GITHUBAPP_KEY`: 49 | 50 | Private key used to sign access token requests as bytes or utf-8 encoded string (required). 51 | Default: None 52 | 53 | `GITHUBAPP_SECRET`: 54 | 55 | Secret used to secure webhooks as bytes or utf-8 encoded string (required). set to `False` to disable 56 | verification (not recommended for production). 57 | Default: None 58 | 59 | `GITHUBAPP_URL`: 60 | 61 | URL of GitHub API (used for GitHub Enterprise) as a string. 62 | Default: None 63 | 64 | `GITHUBAPP_ROUTE`: 65 | 66 | Path used for GitHub hook requests as a string. 67 | Default: '/' 68 | """ 69 | required_settings = ['GITHUBAPP_ID', 'GITHUBAPP_KEY', 'GITHUBAPP_SECRET'] 70 | for setting in required_settings: 71 | if not setting in app.config: 72 | raise RuntimeError("Flask-GitHubApp requires the '%s' config var to be set" % setting) 73 | 74 | app.add_url_rule(app.config.get('GITHUBAPP_ROUTE', '/'), 75 | view_func=self._flask_view_func, 76 | methods=['POST']) 77 | 78 | @property 79 | def id(self): 80 | return current_app.config['GITHUBAPP_ID'] 81 | 82 | @property 83 | def key(self): 84 | key = current_app.config['GITHUBAPP_KEY'] 85 | if hasattr(key, 'encode'): 86 | key = key.encode('utf-8') 87 | return key 88 | 89 | @property 90 | def secret(self): 91 | secret = current_app.config['GITHUBAPP_SECRET'] 92 | if hasattr(secret, 'encode'): 93 | secret = secret.encode('utf-8') 94 | return secret 95 | 96 | @property 97 | def _api_url(self): 98 | return current_app.config['GITHUBAPP_URL'] 99 | 100 | @property 101 | def client(self): 102 | """Unauthenticated GitHub client""" 103 | if current_app.config.get('GITHUBAPP_URL'): 104 | return GitHubEnterprise(current_app.config['GITHUBAPP_URL']) 105 | return GitHub() 106 | 107 | @property 108 | def payload(self): 109 | """GitHub hook payload""" 110 | if request and request.json and 'installation' in request.json: 111 | return request.json 112 | 113 | raise RuntimeError('Payload is only available in the context of a GitHub hook request') 114 | 115 | @property 116 | def installation_client(self): 117 | """GitHub client authenticated as GitHub app installation""" 118 | ctx = g 119 | if ctx is not None: 120 | if not hasattr(ctx, 'githubapp_installation'): 121 | client = self.client 122 | client.login_as_app_installation(self.key, 123 | self.id, 124 | self.payload['installation']['id']) 125 | ctx.githubapp_installation = client 126 | return ctx.githubapp_installation 127 | 128 | @property 129 | def app_client(self): 130 | """GitHub client authenticated as GitHub app""" 131 | ctx = g 132 | if ctx is not None: 133 | if not hasattr(ctx, 'githubapp_app'): 134 | client = self.client 135 | client.login_as_app(self.key, 136 | self.id) 137 | ctx.githubapp_app = client 138 | return ctx.githubapp_app 139 | 140 | @property 141 | def installation_token(self): 142 | return self.installation_client.session.auth.token 143 | 144 | def on(self, event_action): 145 | """Decorator routes a GitHub hook to the wrapped function. 146 | 147 | Functions decorated as a hook recipient are registered as the function for the given GitHub event. 148 | 149 | @github_app.on('issues.opened') 150 | def cruel_closer(): 151 | owner = github_app.payload['repository']['owner']['login'] 152 | repo = github_app.payload['repository']['name'] 153 | num = github_app.payload['issue']['id'] 154 | issue = github_app.installation_client.issue(owner, repo, num) 155 | issue.create_comment('Could not replicate.') 156 | issue.close() 157 | 158 | Arguments: 159 | event_action {str} -- Name of the event and optional action (separated by a period), e.g. 'issues.opened' or 160 | 'pull_request' 161 | """ 162 | def decorator(f): 163 | if event_action not in self._hook_mappings: 164 | self._hook_mappings[event_action] = [f] 165 | else: 166 | self._hook_mappings[event_action].append(f) 167 | 168 | # make sure the function can still be called normally (e.g. if a user wants to pass in their 169 | # own Context for whatever reason). 170 | return f 171 | 172 | return decorator 173 | 174 | def _validate_request(self): 175 | if not request.is_json: 176 | raise GitHubAppValidationError('Invalid HTTP Content-Type header for JSON body ' 177 | '(must be application/json or application/*+json).') 178 | 179 | try: 180 | request.json 181 | except BadRequest: 182 | raise GitHubAppValidationError('Invalid HTTP body (must be JSON).') 183 | 184 | event = request.headers.get('X-GitHub-Event') 185 | 186 | if event is None: 187 | raise GitHubAppValidationError('Missing X-GitHub-Event HTTP header.') 188 | 189 | action = request.json.get('action') 190 | 191 | return event, action 192 | 193 | def _flask_view_func(self): 194 | functions_to_call = [] 195 | calls = {} 196 | 197 | try: 198 | event, action = self._validate_request() 199 | except GitHubAppValidationError as e: 200 | LOG.error(e) 201 | error_response = make_response(jsonify(status='ERROR', description=str(e)), 202 | 400) 203 | return abort(error_response) 204 | 205 | if current_app.config['GITHUBAPP_SECRET'] is not False: 206 | self._verify_webhook() 207 | 208 | if event in self._hook_mappings: 209 | functions_to_call += self._hook_mappings[event] 210 | 211 | if action: 212 | event_action = '.'.join([event, action]) 213 | if event_action in self._hook_mappings: 214 | functions_to_call += self._hook_mappings[event_action] 215 | 216 | if functions_to_call: 217 | for function in functions_to_call: 218 | calls[function.__name__] = function() 219 | status = STATUS_FUNC_CALLED 220 | else: 221 | status = STATUS_NO_FUNC_CALLED 222 | return jsonify({'status': status, 223 | 'calls': calls}) 224 | 225 | def _verify_webhook(self): 226 | signature_header ='X-Hub-Signature-256' 227 | signature_header_legacy = 'X-Hub-Signature' 228 | 229 | if request.headers.get(signature_header): 230 | signature = request.headers[signature_header].split('=')[1] 231 | digestmod = 'sha256' 232 | elif request.headers.get(signature_header_legacy): 233 | signature = request.headers[signature_header_legacy].split('=')[1] 234 | digestmod = 'sha1' 235 | else: 236 | LOG.warning('Signature header missing. Configure your GitHub App with a secret or set GITHUBAPP_SECRET' 237 | 'to False to disable verification.') 238 | return abort(400) 239 | 240 | mac = hmac.new(self.secret, msg=request.data, digestmod=digestmod) 241 | 242 | if not hmac.compare_digest(mac.hexdigest(), signature): 243 | LOG.warning('GitHub hook signature verification failed.') 244 | return abort(400) 245 | -------------------------------------------------------------------------------- /tests/test_core.py: -------------------------------------------------------------------------------- 1 | import json 2 | from unittest.mock import MagicMock 3 | 4 | from github3 import GitHub, GitHubEnterprise 5 | 6 | from flask_githubapp import GitHubApp 7 | from flask_githubapp.core import STATUS_NO_FUNC_CALLED 8 | 9 | 10 | def test_default_config(app): 11 | """make sure we're casting things that make sense to cast""" 12 | github_app = GitHubApp(app) 13 | with app.app_context(): 14 | assert github_app.id == 1 15 | assert github_app.key == b'key' 16 | assert github_app.secret == b'secret' 17 | 18 | 19 | def test_init_app(app): 20 | github_app = GitHubApp() 21 | github_app.init_app(app) 22 | assert 'GITHUBAPP_URL' not in app.config 23 | 24 | 25 | def test_github_client(app): 26 | github_app = GitHubApp(app) 27 | with app.app_context(): 28 | assert isinstance(github_app.client, GitHub) 29 | 30 | 31 | def test_github_enterprise_client(app): 32 | enterprise_url = 'https://enterprise.github.com' 33 | app.config['GITHUBAPP_URL'] = enterprise_url 34 | github_app = GitHubApp(app) 35 | with app.app_context(): 36 | assert isinstance(github_app.client, GitHubEnterprise) 37 | assert github_app.client.url == enterprise_url 38 | 39 | 40 | def test_github_installation_client(app, mocker): 41 | github_app = GitHubApp(app) 42 | installation_id = 2 43 | mocker.patch('flask_githubapp.core.GitHubApp._verify_webhook') 44 | mock_client = mocker.patch('flask_githubapp.core.GitHubApp.client') 45 | with app.test_client() as client: 46 | resp = client.post('/', 47 | data=json.dumps({'installation': {'id': installation_id}}), 48 | headers={ 49 | 'X-GitHub-Event': 'foo', 50 | 'Content-Type': 'application/json' 51 | }) 52 | assert resp.status_code == 200 53 | github_app.installation_client 54 | mock_client.login_as_app_installation.assert_called_once_with(github_app.key, 55 | github_app.id, 56 | installation_id) 57 | 58 | 59 | def test_github_installation_client_is_lazy(app, mocker): 60 | github_app = GitHubApp(app) 61 | installation_id = 2 62 | mocker.patch('flask_githubapp.core.GitHubApp._verify_webhook') 63 | mock_client = mocker.patch('flask_githubapp.core.GitHubApp.client') 64 | with app.test_client() as client: 65 | resp = client.post('/', 66 | data=json.dumps({'installation': {'id': installation_id}}), 67 | headers={ 68 | 'X-GitHub-Event': 'foo', 69 | 'Content-Type': 'application/json' 70 | }) 71 | assert resp.status_code == 200 72 | mock_client.login_as_app_installation.assert_not_called() 73 | 74 | 75 | def test_github_app_client(app, mocker): 76 | github_app = GitHubApp(app) 77 | mocker.patch('flask_githubapp.core.GitHubApp._verify_webhook') 78 | mock_client = mocker.patch('flask_githubapp.core.GitHubApp.client') 79 | with app.app_context(): 80 | github_app.app_client 81 | mock_client.login_as_app.assert_called_once_with(github_app.key, 82 | github_app.id) 83 | 84 | 85 | def test_hook_mapping(app): 86 | github_app = GitHubApp(app) 87 | 88 | @github_app.on('foo') 89 | def bar(): 90 | pass 91 | 92 | assert github_app._hook_mappings['foo'] == [bar] 93 | 94 | 95 | def test_multiple_function_on_same_event(app): 96 | github_app = GitHubApp(app) 97 | 98 | @github_app.on('foo') 99 | def bar(): 100 | pass 101 | 102 | @github_app.on('foo') 103 | def baz(): 104 | pass 105 | 106 | assert github_app._hook_mappings['foo'] == [bar, baz] 107 | 108 | 109 | def test_events_mapped_to_functions(app, mocker): 110 | github_app = GitHubApp(app) 111 | 112 | function_to_call = MagicMock() 113 | function_to_call.__name__ = 'foo' # used to generate response 114 | function_to_call.return_value = 'foo' # return data must be serializable 115 | 116 | github_app._hook_mappings['foo'] = [function_to_call] 117 | mocker.patch('flask_githubapp.core.GitHubApp._verify_webhook') 118 | with app.test_client() as client: 119 | resp = client.post('/', 120 | data=json.dumps({'installation': {'id': 2}}), 121 | headers={ 122 | 'X-GitHub-Event': 'foo', 123 | 'Content-Type': 'application/json' 124 | }) 125 | assert resp.status_code == 200 126 | function_to_call.assert_called_once_with() 127 | 128 | 129 | def test_events_with_actions_mapped_to_functions(app, mocker): 130 | github_app = GitHubApp(app) 131 | 132 | function_to_call = MagicMock() 133 | function_to_call.__name__ = 'foo' # used to generate response 134 | function_to_call.return_value = 'foo' # return data must be serializable 135 | 136 | github_app._hook_mappings['foo.bar'] = [function_to_call] 137 | mocker.patch('flask_githubapp.core.GitHubApp._verify_webhook') 138 | with app.test_client() as client: 139 | resp = client.post('/', 140 | data=json.dumps({'installation': {'id': 2}, 141 | 'action': 'bar'}), 142 | headers={ 143 | 'X-GitHub-Event': 'foo', 144 | 'Content-Type': 'application/json' 145 | }) 146 | assert resp.status_code == 200 147 | function_to_call.assert_called_once_with() 148 | 149 | 150 | def test_functions_can_return_no_data(app, mocker): 151 | github_app = GitHubApp(app) 152 | 153 | function_to_call = MagicMock() 154 | function_to_call.__name__ = 'foo' # used to generate response 155 | function_to_call.return_value = None 156 | 157 | github_app._hook_mappings['foo'] = [function_to_call] 158 | mocker.patch('flask_githubapp.core.GitHubApp._verify_webhook') 159 | with app.test_client() as client: 160 | resp = client.post('/', 161 | data=json.dumps({'installation': {'id': 2}}), 162 | headers={ 163 | 'X-GitHub-Event': 'foo', 164 | 'Content-Type': 'application/json' 165 | }) 166 | assert resp.status_code == 200 167 | function_to_call.assert_called_once_with() 168 | 169 | 170 | def test_function_exception_raise_500_error(app, mocker): 171 | github_app = GitHubApp(app) 172 | 173 | function_to_call = MagicMock() 174 | function_to_call.__name__ = 'foo' # used to generate response 175 | function_to_call.side_effect = Exception('foo exception') 176 | 177 | github_app._hook_mappings['foo'] = [function_to_call] 178 | mocker.patch('flask_githubapp.core.GitHubApp._verify_webhook') 179 | with app.test_client() as client: 180 | resp = client.post('/', 181 | data=json.dumps({'installation': {'id': 2}}), 182 | headers={ 183 | 'X-GitHub-Event': 'foo', 184 | 'Content-Type': 'application/json' 185 | }) 186 | assert resp.status_code == 500 187 | function_to_call.assert_called_once_with() 188 | 189 | 190 | def test_no_target_functions(app, mocker): 191 | github_app = GitHubApp(app) 192 | 193 | function_to_miss = MagicMock() 194 | function_to_miss.__name__ = 'foo' # used to generate response 195 | 196 | github_app._hook_mappings['foo'] = [function_to_miss] 197 | mocker.patch('flask_githubapp.core.GitHubApp._verify_webhook') 198 | with app.test_client() as client: 199 | resp = client.post('/', 200 | data=json.dumps({'installation': {'id': 2}}), 201 | headers={ 202 | 'X-GitHub-Event': 'bar', 203 | 'Content-Type': 'application/json' 204 | }) 205 | assert resp.status_code == 200 206 | function_to_miss.assert_not_called() 207 | assert resp.json['status'] == STATUS_NO_FUNC_CALLED 208 | assert resp.json['calls'] == {} 209 | 210 | 211 | def test_view_returns_map_of_called_functions_and_returned_data(app, mocker): 212 | github_app = GitHubApp(app) 213 | 214 | def event_function(): 215 | return 'foo' 216 | 217 | def event_action_function(): 218 | return 'bar' 219 | 220 | def other_event_function(): 221 | return 'baz' 222 | 223 | github_app._hook_mappings = { 224 | 'foo': [event_function], 225 | 'foo.bar': [event_action_function], 226 | 'bar': [other_event_function] 227 | } 228 | mocker.patch('flask_githubapp.core.GitHubApp._verify_webhook') 229 | with app.test_client() as client: 230 | resp = client.post('/', 231 | data=json.dumps({'installation': {'id': 2}, 232 | 'action': 'bar'}), 233 | headers={ 234 | 'X-GitHub-Event': 'foo', 235 | 'Content-Type': 'application/json' 236 | }) 237 | assert resp.status_code == 200 238 | assert resp.json == { 239 | 'status': 'HIT', 240 | 'calls': { 241 | 'event_function': 'foo', 242 | 'event_action_function': 'bar', 243 | } 244 | } 245 | 246 | def test_invalid_json_header_returns_error(github_app): 247 | """HTTP request have a valid json content type""" 248 | github_app.config['GITHUBAPP_SECRET'] = False 249 | with github_app.test_client() as client: 250 | resp = client.post('/', 251 | data=json.dumps({'installation': {'id': 2}, 252 | 'action': 'bar'}), 253 | headers={ 254 | 'X-GitHub-Event': 'foo', 255 | 'Content-Type': 'text/plain' 256 | }) 257 | assert resp.status_code == 400 258 | assert resp.json == { 259 | 'status': 'ERROR', 260 | 'description': 'Invalid HTTP Content-Type header for JSON body ' 261 | '(must be application/json or application/*+json).' 262 | } 263 | 264 | def test_invalid_json_body_returns_error(github_app): 265 | """HTTP request have a valid json body""" 266 | github_app.config['GITHUBAPP_SECRET'] = False 267 | with github_app.test_client() as client: 268 | resp = client.post('/', 269 | data='invalid json', 270 | headers={ 271 | 'X-GitHub-Event': 'foo', 272 | 'Content-Type': 'application/json' 273 | }) 274 | assert resp.status_code == 400 275 | assert resp.json == { 276 | 'status': 'ERROR', 277 | 'description': 'Invalid HTTP body (must be JSON).' 278 | } 279 | 280 | def test_missing_event_header_returns_error(github_app): 281 | """HTTP request must havea X-GitHub-Event header""" 282 | github_app.config['GITHUBAPP_SECRET'] = False 283 | with github_app.test_client() as client: 284 | resp = client.post('/', 285 | data=json.dumps({'installation': {'id': 2}, 286 | 'action': 'bar'}), 287 | headers={ 288 | 'Content-Type': 'application/json' 289 | }) 290 | assert resp.status_code == 400 291 | assert resp.json == { 292 | 'status': 'ERROR', 293 | 'description': 'Missing X-GitHub-Event HTTP header.' 294 | } 295 | -------------------------------------------------------------------------------- /tests/fixtures/issues_hook.json: -------------------------------------------------------------------------------- 1 | { 2 | "action": "edited", 3 | "issue": { 4 | "url": "https://api.github.com/repos/Codertocat/Hello-World/issues/1", 5 | "repository_url": "https://api.github.com/repos/Codertocat/Hello-World", 6 | "labels_url": "https://api.github.com/repos/Codertocat/Hello-World/issues/1/labels{/name}", 7 | "comments_url": "https://api.github.com/repos/Codertocat/Hello-World/issues/1/comments", 8 | "events_url": "https://api.github.com/repos/Codertocat/Hello-World/issues/1/events", 9 | "html_url": "https://github.com/Codertocat/Hello-World/issues/1", 10 | "id": 444500041, 11 | "node_id": "MDU6SXNzdWU0NDQ1MDAwNDE=", 12 | "number": 1, 13 | "title": "Spelling error in the README file", 14 | "user": { 15 | "login": "Codertocat", 16 | "id": 21031067, 17 | "node_id": "MDQ6VXNlcjIxMDMxMDY3", 18 | "avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4", 19 | "gravatar_id": "", 20 | "url": "https://api.github.com/users/Codertocat", 21 | "html_url": "https://github.com/Codertocat", 22 | "followers_url": "https://api.github.com/users/Codertocat/followers", 23 | "following_url": "https://api.github.com/users/Codertocat/following{/other_user}", 24 | "gists_url": "https://api.github.com/users/Codertocat/gists{/gist_id}", 25 | "starred_url": "https://api.github.com/users/Codertocat/starred{/owner}{/repo}", 26 | "subscriptions_url": "https://api.github.com/users/Codertocat/subscriptions", 27 | "organizations_url": "https://api.github.com/users/Codertocat/orgs", 28 | "repos_url": "https://api.github.com/users/Codertocat/repos", 29 | "events_url": "https://api.github.com/users/Codertocat/events{/privacy}", 30 | "received_events_url": "https://api.github.com/users/Codertocat/received_events", 31 | "type": "User", 32 | "site_admin": false 33 | }, 34 | "labels": [ 35 | { 36 | "id": 1362934389, 37 | "node_id": "MDU6TGFiZWwxMzYyOTM0Mzg5", 38 | "url": "https://api.github.com/repos/Codertocat/Hello-World/labels/bug", 39 | "name": "bug", 40 | "color": "d73a4a", 41 | "default": true 42 | } 43 | ], 44 | "state": "open", 45 | "locked": false, 46 | "assignee": { 47 | "login": "Codertocat", 48 | "id": 21031067, 49 | "node_id": "MDQ6VXNlcjIxMDMxMDY3", 50 | "avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4", 51 | "gravatar_id": "", 52 | "url": "https://api.github.com/users/Codertocat", 53 | "html_url": "https://github.com/Codertocat", 54 | "followers_url": "https://api.github.com/users/Codertocat/followers", 55 | "following_url": "https://api.github.com/users/Codertocat/following{/other_user}", 56 | "gists_url": "https://api.github.com/users/Codertocat/gists{/gist_id}", 57 | "starred_url": "https://api.github.com/users/Codertocat/starred{/owner}{/repo}", 58 | "subscriptions_url": "https://api.github.com/users/Codertocat/subscriptions", 59 | "organizations_url": "https://api.github.com/users/Codertocat/orgs", 60 | "repos_url": "https://api.github.com/users/Codertocat/repos", 61 | "events_url": "https://api.github.com/users/Codertocat/events{/privacy}", 62 | "received_events_url": "https://api.github.com/users/Codertocat/received_events", 63 | "type": "User", 64 | "site_admin": false 65 | }, 66 | "assignees": [ 67 | { 68 | "login": "Codertocat", 69 | "id": 21031067, 70 | "node_id": "MDQ6VXNlcjIxMDMxMDY3", 71 | "avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4", 72 | "gravatar_id": "", 73 | "url": "https://api.github.com/users/Codertocat", 74 | "html_url": "https://github.com/Codertocat", 75 | "followers_url": "https://api.github.com/users/Codertocat/followers", 76 | "following_url": "https://api.github.com/users/Codertocat/following{/other_user}", 77 | "gists_url": "https://api.github.com/users/Codertocat/gists{/gist_id}", 78 | "starred_url": "https://api.github.com/users/Codertocat/starred{/owner}{/repo}", 79 | "subscriptions_url": "https://api.github.com/users/Codertocat/subscriptions", 80 | "organizations_url": "https://api.github.com/users/Codertocat/orgs", 81 | "repos_url": "https://api.github.com/users/Codertocat/repos", 82 | "events_url": "https://api.github.com/users/Codertocat/events{/privacy}", 83 | "received_events_url": "https://api.github.com/users/Codertocat/received_events", 84 | "type": "User", 85 | "site_admin": false 86 | } 87 | ], 88 | "milestone": { 89 | "url": "https://api.github.com/repos/Codertocat/Hello-World/milestones/1", 90 | "html_url": "https://github.com/Codertocat/Hello-World/milestone/1", 91 | "labels_url": "https://api.github.com/repos/Codertocat/Hello-World/milestones/1/labels", 92 | "id": 4317517, 93 | "node_id": "MDk6TWlsZXN0b25lNDMxNzUxNw==", 94 | "number": 1, 95 | "title": "v1.0", 96 | "description": "Add new space flight simulator", 97 | "creator": { 98 | "login": "Codertocat", 99 | "id": 21031067, 100 | "node_id": "MDQ6VXNlcjIxMDMxMDY3", 101 | "avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4", 102 | "gravatar_id": "", 103 | "url": "https://api.github.com/users/Codertocat", 104 | "html_url": "https://github.com/Codertocat", 105 | "followers_url": "https://api.github.com/users/Codertocat/followers", 106 | "following_url": "https://api.github.com/users/Codertocat/following{/other_user}", 107 | "gists_url": "https://api.github.com/users/Codertocat/gists{/gist_id}", 108 | "starred_url": "https://api.github.com/users/Codertocat/starred{/owner}{/repo}", 109 | "subscriptions_url": "https://api.github.com/users/Codertocat/subscriptions", 110 | "organizations_url": "https://api.github.com/users/Codertocat/orgs", 111 | "repos_url": "https://api.github.com/users/Codertocat/repos", 112 | "events_url": "https://api.github.com/users/Codertocat/events{/privacy}", 113 | "received_events_url": "https://api.github.com/users/Codertocat/received_events", 114 | "type": "User", 115 | "site_admin": false 116 | }, 117 | "open_issues": 1, 118 | "closed_issues": 0, 119 | "state": "closed", 120 | "created_at": "2019-05-15T15:20:17Z", 121 | "updated_at": "2019-05-15T15:20:18Z", 122 | "due_on": "2019-05-23T07:00:00Z", 123 | "closed_at": "2019-05-15T15:20:18Z" 124 | }, 125 | "comments": 0, 126 | "created_at": "2019-05-15T15:20:18Z", 127 | "updated_at": "2019-05-15T15:20:18Z", 128 | "closed_at": null, 129 | "author_association": "OWNER", 130 | "body": "It looks like you accidently spelled 'commit' with two 't's." 131 | }, 132 | "changes": {}, 133 | "repository": { 134 | "id": 186853002, 135 | "node_id": "MDEwOlJlcG9zaXRvcnkxODY4NTMwMDI=", 136 | "name": "Hello-World", 137 | "full_name": "Codertocat/Hello-World", 138 | "private": false, 139 | "owner": { 140 | "login": "Codertocat", 141 | "id": 21031067, 142 | "node_id": "MDQ6VXNlcjIxMDMxMDY3", 143 | "avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4", 144 | "gravatar_id": "", 145 | "url": "https://api.github.com/users/Codertocat", 146 | "html_url": "https://github.com/Codertocat", 147 | "followers_url": "https://api.github.com/users/Codertocat/followers", 148 | "following_url": "https://api.github.com/users/Codertocat/following{/other_user}", 149 | "gists_url": "https://api.github.com/users/Codertocat/gists{/gist_id}", 150 | "starred_url": "https://api.github.com/users/Codertocat/starred{/owner}{/repo}", 151 | "subscriptions_url": "https://api.github.com/users/Codertocat/subscriptions", 152 | "organizations_url": "https://api.github.com/users/Codertocat/orgs", 153 | "repos_url": "https://api.github.com/users/Codertocat/repos", 154 | "events_url": "https://api.github.com/users/Codertocat/events{/privacy}", 155 | "received_events_url": "https://api.github.com/users/Codertocat/received_events", 156 | "type": "User", 157 | "site_admin": false 158 | }, 159 | "html_url": "https://github.com/Codertocat/Hello-World", 160 | "description": null, 161 | "fork": false, 162 | "url": "https://api.github.com/repos/Codertocat/Hello-World", 163 | "forks_url": "https://api.github.com/repos/Codertocat/Hello-World/forks", 164 | "keys_url": "https://api.github.com/repos/Codertocat/Hello-World/keys{/key_id}", 165 | "collaborators_url": "https://api.github.com/repos/Codertocat/Hello-World/collaborators{/collaborator}", 166 | "teams_url": "https://api.github.com/repos/Codertocat/Hello-World/teams", 167 | "hooks_url": "https://api.github.com/repos/Codertocat/Hello-World/hooks", 168 | "issue_events_url": "https://api.github.com/repos/Codertocat/Hello-World/issues/events{/number}", 169 | "events_url": "https://api.github.com/repos/Codertocat/Hello-World/events", 170 | "assignees_url": "https://api.github.com/repos/Codertocat/Hello-World/assignees{/user}", 171 | "branches_url": "https://api.github.com/repos/Codertocat/Hello-World/branches{/branch}", 172 | "tags_url": "https://api.github.com/repos/Codertocat/Hello-World/tags", 173 | "blobs_url": "https://api.github.com/repos/Codertocat/Hello-World/git/blobs{/sha}", 174 | "git_tags_url": "https://api.github.com/repos/Codertocat/Hello-World/git/tags{/sha}", 175 | "git_refs_url": "https://api.github.com/repos/Codertocat/Hello-World/git/refs{/sha}", 176 | "trees_url": "https://api.github.com/repos/Codertocat/Hello-World/git/trees{/sha}", 177 | "statuses_url": "https://api.github.com/repos/Codertocat/Hello-World/statuses/{sha}", 178 | "languages_url": "https://api.github.com/repos/Codertocat/Hello-World/languages", 179 | "stargazers_url": "https://api.github.com/repos/Codertocat/Hello-World/stargazers", 180 | "contributors_url": "https://api.github.com/repos/Codertocat/Hello-World/contributors", 181 | "subscribers_url": "https://api.github.com/repos/Codertocat/Hello-World/subscribers", 182 | "subscription_url": "https://api.github.com/repos/Codertocat/Hello-World/subscription", 183 | "commits_url": "https://api.github.com/repos/Codertocat/Hello-World/commits{/sha}", 184 | "git_commits_url": "https://api.github.com/repos/Codertocat/Hello-World/git/commits{/sha}", 185 | "comments_url": "https://api.github.com/repos/Codertocat/Hello-World/comments{/number}", 186 | "issue_comment_url": "https://api.github.com/repos/Codertocat/Hello-World/issues/comments{/number}", 187 | "contents_url": "https://api.github.com/repos/Codertocat/Hello-World/contents/{+path}", 188 | "compare_url": "https://api.github.com/repos/Codertocat/Hello-World/compare/{base}...{head}", 189 | "merges_url": "https://api.github.com/repos/Codertocat/Hello-World/merges", 190 | "archive_url": "https://api.github.com/repos/Codertocat/Hello-World/{archive_format}{/ref}", 191 | "downloads_url": "https://api.github.com/repos/Codertocat/Hello-World/downloads", 192 | "issues_url": "https://api.github.com/repos/Codertocat/Hello-World/issues{/number}", 193 | "pulls_url": "https://api.github.com/repos/Codertocat/Hello-World/pulls{/number}", 194 | "milestones_url": "https://api.github.com/repos/Codertocat/Hello-World/milestones{/number}", 195 | "notifications_url": "https://api.github.com/repos/Codertocat/Hello-World/notifications{?since,all,participating}", 196 | "labels_url": "https://api.github.com/repos/Codertocat/Hello-World/labels{/name}", 197 | "releases_url": "https://api.github.com/repos/Codertocat/Hello-World/releases{/id}", 198 | "deployments_url": "https://api.github.com/repos/Codertocat/Hello-World/deployments", 199 | "created_at": "2019-05-15T15:19:25Z", 200 | "updated_at": "2019-05-15T15:19:27Z", 201 | "pushed_at": "2019-05-15T15:20:13Z", 202 | "git_url": "git://github.com/Codertocat/Hello-World.git", 203 | "ssh_url": "git@github.com:Codertocat/Hello-World.git", 204 | "clone_url": "https://github.com/Codertocat/Hello-World.git", 205 | "svn_url": "https://github.com/Codertocat/Hello-World", 206 | "homepage": null, 207 | "size": 0, 208 | "stargazers_count": 0, 209 | "watchers_count": 0, 210 | "language": null, 211 | "has_issues": true, 212 | "has_projects": true, 213 | "has_downloads": true, 214 | "has_wiki": true, 215 | "has_pages": true, 216 | "forks_count": 0, 217 | "mirror_url": null, 218 | "archived": false, 219 | "disabled": false, 220 | "open_issues_count": 1, 221 | "license": null, 222 | "forks": 0, 223 | "open_issues": 1, 224 | "watchers": 0, 225 | "default_branch": "master" 226 | }, 227 | "sender": { 228 | "login": "Codertocat", 229 | "id": 21031067, 230 | "node_id": "MDQ6VXNlcjIxMDMxMDY3", 231 | "avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4", 232 | "gravatar_id": "", 233 | "url": "https://api.github.com/users/Codertocat", 234 | "html_url": "https://github.com/Codertocat", 235 | "followers_url": "https://api.github.com/users/Codertocat/followers", 236 | "following_url": "https://api.github.com/users/Codertocat/following{/other_user}", 237 | "gists_url": "https://api.github.com/users/Codertocat/gists{/gist_id}", 238 | "starred_url": "https://api.github.com/users/Codertocat/starred{/owner}{/repo}", 239 | "subscriptions_url": "https://api.github.com/users/Codertocat/subscriptions", 240 | "organizations_url": "https://api.github.com/users/Codertocat/orgs", 241 | "repos_url": "https://api.github.com/users/Codertocat/repos", 242 | "events_url": "https://api.github.com/users/Codertocat/events{/privacy}", 243 | "received_events_url": "https://api.github.com/users/Codertocat/received_events", 244 | "type": "User", 245 | "site_admin": false 246 | } 247 | } 248 | --------------------------------------------------------------------------------