├── .codecov.yml ├── .github ├── FUNDING.yml └── workflows │ └── ci.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── Makefile ├── README.md ├── icon ├── black-circle.svg ├── hook-only-black.png ├── hook-only-black.svg ├── hook-only-white.png └── hook-only-white.svg ├── pyproject.toml ├── requirements.txt ├── requirements ├── all.txt ├── linting.in ├── linting.txt ├── pyproject.txt ├── testing.in └── testing.txt ├── src ├── __init__.py ├── favicon.ico ├── github_auth.py ├── index.html ├── logic │ ├── __init__.py │ ├── common.py │ ├── issues.py │ ├── models.py │ └── prs.py ├── repo_config.py ├── settings.py └── views.py └── tests ├── __init__.py ├── blocks.py ├── conftest.py ├── dummy_server.py ├── test_github_app_secret_key.pem ├── test_github_auth.py ├── test_index.py ├── test_logic_issues.py ├── test_logic_prs.py ├── test_marketplace.py ├── test_repo_config.py └── test_webhooks.py /.codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | precision: 2 3 | range: [95, 100] 4 | status: 5 | patch: false 6 | project: false 7 | 8 | comment: 9 | layout: 'header, diff, flags, files, footer' 10 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: samuelcolvin 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - '**' 9 | pull_request: {} 10 | 11 | jobs: 12 | lint: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | 18 | - name: set up python 19 | uses: actions/setup-python@v4 20 | with: 21 | python-version: '3.11' 22 | 23 | - run: pip install -r requirements/linting.txt -r requirements/pyproject.txt 24 | 25 | - uses: pre-commit/action@v3.0.0 26 | with: 27 | extra_args: --all-files 28 | 29 | test: 30 | runs-on: ubuntu-latest 31 | 32 | services: 33 | redis: 34 | image: redis:6 35 | ports: 36 | - 6379:6379 37 | options: '--entrypoint redis-server' 38 | 39 | steps: 40 | - uses: actions/checkout@v2 41 | 42 | - name: set up python 43 | uses: actions/setup-python@v4 44 | with: 45 | python-version: '3.11' 46 | 47 | - run: pip install -r requirements/testing.txt -r requirements/pyproject.txt 48 | 49 | - run: make test 50 | 51 | - run: coverage xml 52 | 53 | - uses: codecov/codecov-action@v2 54 | with: 55 | file: ./coverage.xml 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | /.vscode/ 3 | /env/ 4 | /env*/ 5 | *.py[cod] 6 | *.egg-info/ 7 | /sandbox/ 8 | /*.pem 9 | /.coverage 10 | .DS_Store 11 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | fail_fast: true 2 | 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v4.0.1 6 | hooks: 7 | - id: check-yaml 8 | - id: check-toml 9 | - id: end-of-file-fixer 10 | - id: trailing-whitespace 11 | - id: check-added-large-files 12 | 13 | - repo: local 14 | hooks: 15 | - id: lint 16 | name: Lint 17 | entry: make lint 18 | types: [python] 19 | language: system 20 | - id: pyupgrade 21 | name: Pyupgrade 22 | entry: pyupgrade --py311-plus 23 | types: [python] 24 | language: system 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2022 Samuel Colvin 2 | 3 | Permission is hereby granted, free of charge, to any 4 | person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the 6 | Software without restriction, including without 7 | limitation the rights to use, copy, modify, merge, 8 | publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software 10 | is furnished to do so, subject to the following 11 | conditions: 12 | 13 | The above copyright notice and this permission notice 14 | shall be included in all copies or substantial portions 15 | of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 18 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 19 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 20 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 21 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 22 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 23 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 24 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 25 | DEALINGS IN THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := all 2 | paths = src tests 3 | 4 | .PHONY: install 5 | install: 6 | pip install -U pip pre-commit pip-tools 7 | pip install -r requirements/all.txt 8 | pre-commit install 9 | 10 | .PHONY: format 11 | format: 12 | pyupgrade --py311-plus --exit-zero-even-if-changed `find $(paths) -name "*.py" -type f` 13 | isort $(paths) 14 | black $(paths) 15 | 16 | .PHONY: lint 17 | lint: 18 | ruff $(paths) 19 | isort $(paths) --check-only --df 20 | black $(paths) --check --diff 21 | 22 | .PHONY: test 23 | test: 24 | coverage run -m pytest 25 | 26 | .PHONY: testcov 27 | testcov: test 28 | @coverage report 29 | @echo "building coverage html" 30 | @coverage html 31 | 32 | .PHONY: all 33 | all: format lint test 34 | 35 | .PHONY: upgrade-requirements 36 | upgrade-requirements: 37 | @echo "upgrading dependencies" 38 | @pip-compile --strip-extras --resolver=backtracking --quiet --upgrade --output-file=requirements/pyproject.txt pyproject.toml 39 | @pip-compile --strip-extras --resolver=backtracking --quiet --upgrade --output-file=requirements/linting.txt requirements/linting.in 40 | @pip-compile --strip-extras --resolver=backtracking --quiet --upgrade --output-file=requirements/testing.txt requirements/testing.in 41 | @echo "dependencies has been upgraded" 42 | 43 | .PHONY: rebuild-requirements 44 | rebuild-requirements: 45 | rm requirements/pyproject.txt requirements/linting.txt requirements/testing.txt 46 | pip-compile --strip-extras --resolver=backtracking --quiet --rebuild --output-file=requirements/pyproject.txt pyproject.toml 47 | pip-compile --strip-extras --resolver=backtracking --quiet --rebuild --output-file=requirements/linting.txt requirements/linting.in 48 | pip-compile --strip-extras --resolver=backtracking --quiet --rebuild --output-file=requirements/testing.txt requirements/testing.in 49 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hooky 2 | 3 | [![CI](https://github.com/pydantic/hooky/workflows/CI/badge.svg?event=push)](https://github.com/pydantic/hooky/actions?query=event%3Apush+branch%3Amain+workflow%3ACI) 4 | [![Coverage](https://codecov.io/gh/pydantic/hooky/branch/main/graph/badge.svg)](https://codecov.io/gh/pydantic/hooky) 5 | [![license](https://img.shields.io/github/license/pydantic/hooky.svg)](https://github.com/pydantic/hooky/blob/main/LICENSE) 6 | 7 | Receive and respond to GitHub webhooks, built for use with [pydantic](https://github.com/pydantic/pydantic). 8 | 9 | ## Label and Assign 10 | 11 | This tool responds to magic phrases in pull request comments: 12 | 13 | * **"please update"** - requests an update from the PR author, 14 | the PR author is assigned and the "awaiting author revision" label is added 15 | * **"please review"** - requests a review from project reviewers, 16 | the reviewers are assigned and the "ready for review" label is added 17 | 18 | ## Change File Checks 19 | 20 | This tool checks pull requests to enforce a "change file" has been added. 21 | 22 | See [here](https://github.com/pydantic/pydantic/tree/master/changes#pending-changes) for details on the format expected. 23 | 24 | To skip this check the magic phrase **"skip change file check"** can be added to the pull request body. 25 | 26 | Otherwise, the following checks are performed on the pull request: 27 | * A change file matching `changes/-.md` has been added 28 | * The author in the change file matches the PR author 29 | * The ID in the change file either matches the PR ID or that issue is marked as closed in the PR body 30 | 31 | ## Configuration 32 | 33 | Hooky is configured via a TOML file in the root of the repository. 34 | 35 | Either `.hooky.toml` (takes priority) or `pyproject.toml` can be used, either way the configuration should be under the `[tool.hooky]` table. 36 | 37 | The following configuration options are available, here they're filled with the default values: 38 | 39 | ```toml 40 | [tool.hooky] 41 | reviewers = [] # see below for details on behaviour 42 | request_update_trigger = 'please update' 43 | request_review_trigger = 'please review' 44 | awaiting_update_label = 'awaiting author revision' 45 | awaiting_review_label = 'ready for review' 46 | no_change_file = 'skip change file check' 47 | require_change_file = true 48 | ``` 49 | 50 | **Note:** if `reviewers` is empty (the default), all repo collaborators are collected from [`/repos/{owner}/{repo}/collaborators`](https://docs.github.com/en/rest/collaborators/collaborators). 51 | 52 | ### Example configuration 53 | 54 | For example to configure one reviewer and change the "No change file required" magic sentence, the following configuration could be used: 55 | 56 | ```toml 57 | reviewers = ['octocat'] 58 | no_change_file = 'no change file required' 59 | ``` 60 | -------------------------------------------------------------------------------- /icon/black-circle.svg: -------------------------------------------------------------------------------- 1 | 2 | image/svg+xml 79 | -------------------------------------------------------------------------------- /icon/hook-only-black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pydantic/hooky/2bd7ce0ac5d5658888806871a3db16a11150c398/icon/hook-only-black.png -------------------------------------------------------------------------------- /icon/hook-only-black.svg: -------------------------------------------------------------------------------- 1 | 2 | image/svg+xml 71 | -------------------------------------------------------------------------------- /icon/hook-only-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pydantic/hooky/2bd7ce0ac5d5658888806871a3db16a11150c398/icon/hook-only-white.png -------------------------------------------------------------------------------- /icon/hook-only-white.svg: -------------------------------------------------------------------------------- 1 | 2 | image/svg+xml 71 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = 'hooky' 3 | description = 'Receive and respond to GitHub webhooks' 4 | version = '1' 5 | dependencies = [ 6 | 'asyncer', 7 | 'cryptography', 8 | 'fastapi', 9 | 'httptools', 10 | 'pydantic-settings', 11 | 'pydantic', 12 | 'PyGithub', 13 | 'PyJWT', 14 | 'redis', 15 | 'requests', 16 | 'rtoml', 17 | 'uvicorn', 18 | 'uvloop', 19 | ] 20 | 21 | [tool.ruff] 22 | line-length = 120 23 | extend-select = ['Q', 'RUF100', 'C90'] 24 | flake8-quotes = {inline-quotes = 'single', multiline-quotes = 'double'} 25 | mccabe = { max-complexity = 14 } 26 | 27 | [tool.pytest.ini_options] 28 | testpaths = 'tests' 29 | log_format = '%(name)s %(levelname)s: %(message)s' 30 | filterwarnings = [ 31 | 'error', 32 | 'ignore:There is no current event loop:DeprecationWarning', 33 | ] 34 | timeout = 30 35 | xfail_strict = true 36 | 37 | [tool.coverage.run] 38 | source = ['src'] 39 | branch = true 40 | 41 | [tool.coverage.report] 42 | precision = 2 43 | exclude_lines = [ 44 | 'pragma: no cover', 45 | 'raise NotImplementedError', 46 | 'raise NotImplemented', 47 | 'if TYPE_CHECKING:', 48 | '@overload', 49 | ] 50 | 51 | [tool.black] 52 | color = true 53 | line-length = 120 54 | target-version = ['py311'] 55 | skip-string-normalization = true 56 | skip-magic-trailing-comma = true 57 | 58 | [tool.isort] 59 | line_length = 120 60 | multi_line_output = 3 61 | include_trailing_comma = true 62 | force_grid_wrap = 0 63 | combine_as_imports = true 64 | color_output = true 65 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # used render uses this to create the image 2 | -r requirements/pyproject.txt 3 | -------------------------------------------------------------------------------- /requirements/all.txt: -------------------------------------------------------------------------------- 1 | -r ./pyproject.txt 2 | -r ./linting.txt 3 | -r ./testing.txt 4 | -------------------------------------------------------------------------------- /requirements/linting.in: -------------------------------------------------------------------------------- 1 | black 2 | ruff 3 | isort[colors] 4 | pyupgrade 5 | mypy 6 | pre-commit 7 | -------------------------------------------------------------------------------- /requirements/linting.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.11 3 | # by the following command: 4 | # 5 | # pip-compile --output-file=requirements/linting.txt --strip-extras requirements/linting.in 6 | # 7 | black==23.7.0 8 | # via -r requirements/linting.in 9 | cfgv==3.4.0 10 | # via pre-commit 11 | click==8.1.7 12 | # via black 13 | colorama==0.4.6 14 | # via isort 15 | distlib==0.3.7 16 | # via virtualenv 17 | filelock==3.12.3 18 | # via virtualenv 19 | identify==2.5.27 20 | # via pre-commit 21 | isort==5.12.0 22 | # via -r requirements/linting.in 23 | mypy==1.5.1 24 | # via -r requirements/linting.in 25 | mypy-extensions==1.0.0 26 | # via 27 | # black 28 | # mypy 29 | nodeenv==1.8.0 30 | # via pre-commit 31 | packaging==23.1 32 | # via black 33 | pathspec==0.11.2 34 | # via black 35 | platformdirs==3.10.0 36 | # via 37 | # black 38 | # virtualenv 39 | pre-commit==3.4.0 40 | # via -r requirements/linting.in 41 | pyupgrade==3.10.1 42 | # via -r requirements/linting.in 43 | pyyaml==6.0.1 44 | # via pre-commit 45 | ruff==0.0.287 46 | # via -r requirements/linting.in 47 | tokenize-rt==5.2.0 48 | # via pyupgrade 49 | typing-extensions==4.7.1 50 | # via mypy 51 | virtualenv==20.24.4 52 | # via pre-commit 53 | 54 | # The following packages are considered to be unsafe in a requirements file: 55 | # setuptools 56 | -------------------------------------------------------------------------------- /requirements/pyproject.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.11 3 | # by the following command: 4 | # 5 | # pip-compile --output-file=requirements/pyproject.txt --strip-extras pyproject.toml 6 | # 7 | annotated-types==0.5.0 8 | # via pydantic 9 | anyio==3.7.1 10 | # via 11 | # asyncer 12 | # fastapi 13 | # starlette 14 | asyncer==0.0.2 15 | # via hooky (pyproject.toml) 16 | certifi==2023.7.22 17 | # via requests 18 | cffi==1.15.1 19 | # via 20 | # cryptography 21 | # pynacl 22 | charset-normalizer==3.2.0 23 | # via requests 24 | click==8.1.7 25 | # via uvicorn 26 | cryptography==41.0.3 27 | # via 28 | # hooky (pyproject.toml) 29 | # pyjwt 30 | deprecated==1.2.14 31 | # via pygithub 32 | fastapi==0.103.1 33 | # via hooky (pyproject.toml) 34 | h11==0.14.0 35 | # via uvicorn 36 | httptools==0.6.0 37 | # via hooky (pyproject.toml) 38 | idna==3.4 39 | # via 40 | # anyio 41 | # requests 42 | pycparser==2.21 43 | # via cffi 44 | pydantic==2.3.0 45 | # via 46 | # fastapi 47 | # hooky (pyproject.toml) 48 | # pydantic-settings 49 | pydantic-core==2.6.3 50 | # via pydantic 51 | pydantic-settings==2.0.3 52 | # via hooky (pyproject.toml) 53 | pygithub==1.59.1 54 | # via hooky (pyproject.toml) 55 | pyjwt==2.8.0 56 | # via 57 | # hooky (pyproject.toml) 58 | # pygithub 59 | pynacl==1.5.0 60 | # via pygithub 61 | python-dotenv==1.0.0 62 | # via pydantic-settings 63 | redis==5.0.0 64 | # via hooky (pyproject.toml) 65 | requests==2.31.0 66 | # via 67 | # hooky (pyproject.toml) 68 | # pygithub 69 | rtoml==0.9.0 70 | # via hooky (pyproject.toml) 71 | sniffio==1.3.0 72 | # via anyio 73 | starlette==0.27.0 74 | # via fastapi 75 | typing-extensions==4.7.1 76 | # via 77 | # fastapi 78 | # pydantic 79 | # pydantic-core 80 | urllib3==2.0.4 81 | # via requests 82 | uvicorn==0.23.2 83 | # via hooky (pyproject.toml) 84 | uvloop==0.17.0 85 | # via hooky (pyproject.toml) 86 | wrapt==1.15.0 87 | # via deprecated 88 | -------------------------------------------------------------------------------- /requirements/testing.in: -------------------------------------------------------------------------------- 1 | aiohttp 2 | coverage[toml] 3 | dirty-equals 4 | foxglove-web 5 | pytest 6 | pytest-mock 7 | pytest-sugar 8 | pytest-timeout 9 | -------------------------------------------------------------------------------- /requirements/testing.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.11 3 | # by the following command: 4 | # 5 | # pip-compile --output-file=requirements/testing.txt --strip-extras requirements/testing.in 6 | # 7 | aiodns==3.0.0 8 | # via foxglove-web 9 | aiohttp==3.8.5 10 | # via -r requirements/testing.in 11 | aiosignal==1.3.1 12 | # via aiohttp 13 | annotated-types==0.5.0 14 | # via pydantic 15 | anyio==3.7.1 16 | # via 17 | # fastapi 18 | # httpcore 19 | # starlette 20 | arq==0.25.0 21 | # via foxglove-web 22 | async-timeout==4.0.3 23 | # via aiohttp 24 | asyncpg==0.28.0 25 | # via foxglove-web 26 | attrs==23.1.0 27 | # via aiohttp 28 | bcrypt==4.0.1 29 | # via foxglove-web 30 | buildpg==0.4 31 | # via foxglove-web 32 | certifi==2023.7.22 33 | # via 34 | # httpcore 35 | # httpx 36 | # sentry-sdk 37 | cffi==1.15.1 38 | # via pycares 39 | charset-normalizer==3.2.0 40 | # via aiohttp 41 | click==8.1.7 42 | # via 43 | # arq 44 | # typer 45 | # uvicorn 46 | coverage==7.3.0 47 | # via -r requirements/testing.in 48 | dirty-equals==0.6.0 49 | # via -r requirements/testing.in 50 | fastapi==0.103.1 51 | # via foxglove-web 52 | foxglove-web==0.0.39 53 | # via -r requirements/testing.in 54 | frozenlist==1.4.0 55 | # via 56 | # aiohttp 57 | # aiosignal 58 | h11==0.14.0 59 | # via 60 | # httpcore 61 | # uvicorn 62 | hiredis==2.2.3 63 | # via redis 64 | httpcore==0.17.3 65 | # via httpx 66 | httpx==0.24.1 67 | # via foxglove-web 68 | idna==3.4 69 | # via 70 | # anyio 71 | # httpx 72 | # yarl 73 | iniconfig==2.0.0 74 | # via pytest 75 | itsdangerous==2.1.2 76 | # via foxglove-web 77 | multidict==6.0.4 78 | # via 79 | # aiohttp 80 | # yarl 81 | packaging==23.1 82 | # via 83 | # pytest 84 | # pytest-sugar 85 | pluggy==1.3.0 86 | # via pytest 87 | pycares==4.3.0 88 | # via aiodns 89 | pycparser==2.21 90 | # via cffi 91 | pydantic==2.3.0 92 | # via 93 | # fastapi 94 | # foxglove-web 95 | # pydantic-settings 96 | pydantic-core==2.6.3 97 | # via pydantic 98 | pydantic-settings==2.0.3 99 | # via foxglove-web 100 | pytest==7.4.1 101 | # via 102 | # -r requirements/testing.in 103 | # pytest-mock 104 | # pytest-sugar 105 | # pytest-timeout 106 | pytest-mock==3.11.1 107 | # via -r requirements/testing.in 108 | pytest-sugar==0.9.7 109 | # via -r requirements/testing.in 110 | pytest-timeout==2.1.0 111 | # via -r requirements/testing.in 112 | python-dotenv==1.0.0 113 | # via pydantic-settings 114 | pytz==2023.3 115 | # via dirty-equals 116 | redis==5.0.0 117 | # via arq 118 | sentry-sdk==1.30.0 119 | # via foxglove-web 120 | sniffio==1.3.0 121 | # via 122 | # anyio 123 | # httpcore 124 | # httpx 125 | starlette==0.27.0 126 | # via fastapi 127 | termcolor==2.3.0 128 | # via pytest-sugar 129 | typer==0.9.0 130 | # via foxglove-web 131 | typing-extensions==4.7.1 132 | # via 133 | # arq 134 | # fastapi 135 | # pydantic 136 | # pydantic-core 137 | # typer 138 | urllib3==2.0.4 139 | # via sentry-sdk 140 | uvicorn==0.23.2 141 | # via foxglove-web 142 | yarl==1.9.2 143 | # via aiohttp 144 | -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- 1 | def __getattr__(name): 2 | """ 3 | This means `from src import app`, or `uvicorn src:app` works while allowing settings to be imported 4 | without importing views. 5 | """ 6 | from .views import app 7 | 8 | return app 9 | -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pydantic/hooky/2bd7ce0ac5d5658888806871a3db16a11150c398/src/favicon.ico -------------------------------------------------------------------------------- /src/github_auth.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from time import time 3 | 4 | import jwt 5 | import redis 6 | from cryptography.hazmat.backends import default_backend 7 | from cryptography.hazmat.backends.openssl.backend import Backend as OpenSSLBackend 8 | from github import Auth, Github, Repository as GhRepository 9 | from requests import Session 10 | 11 | from .settings import Settings, log 12 | 13 | __all__ = 'get_repo_client', 'GithubContext' 14 | github_base_url = 'https://api.github.com' 15 | 16 | 17 | def get_repo_client(repo_full_name: str, settings: Settings) -> 'GithubContext': 18 | """ 19 | This could all be async, but since it's call from sync code (that can't be async because of GitHub) 20 | there's no point in making it async. 21 | """ 22 | with redis.from_url(str(settings.redis_dsn)) as redis_client: 23 | cache_key = f'github_access_token_{repo_full_name}' 24 | if access_token := redis_client.get(cache_key): 25 | access_token = access_token.decode() 26 | log(f'Using cached access token {access_token:.7}... for {repo_full_name}') 27 | return GithubContext(access_token, repo_full_name) 28 | 29 | pem_bytes = settings.github_app_secret_key.read_bytes() 30 | 31 | private_key = typing.cast(OpenSSLBackend, default_backend()).load_pem_private_key(pem_bytes, None, False) 32 | 33 | now = int(time()) 34 | payload = {'iat': now - 30, 'exp': now + 60, 'iss': settings.github_app_id} 35 | jwt_value = jwt.encode(payload, private_key, algorithm='RS256') 36 | 37 | with Session() as session: 38 | session.headers.update({'Authorization': f'Bearer {jwt_value}', 'Accept': 'application/vnd.github+json'}) 39 | r = session.get(f'{github_base_url}/repos/{repo_full_name}/installation') 40 | r.raise_for_status() 41 | installation_id = r.json()['id'] 42 | 43 | r = session.post(f'{github_base_url}/app/installations/{installation_id}/access_tokens') 44 | r.raise_for_status() 45 | access_token = r.json()['token'] 46 | 47 | # access token's lifetime is 1 hour 48 | # https://docs.github.com/en/rest/apps/apps#create-an-installation-access-token-for-an-app 49 | redis_client.setex(cache_key, 3600 - 100, access_token) 50 | log(f'Created new access token {access_token:.7}... for {repo_full_name}') 51 | return GithubContext(access_token, repo_full_name) 52 | 53 | 54 | class GithubContext: 55 | def __init__(self, access_token: str, repo_full_name: str): 56 | self._gh = Github(auth=Auth.Token(access_token), base_url=github_base_url) 57 | self._repo = self._gh.get_repo(repo_full_name) 58 | 59 | def __enter__(self) -> GhRepository: 60 | return self._repo 61 | 62 | def __exit__(self, exc_type, exc_val, exc_tb): 63 | self._gh._Github__requester._Requester__connection.session.close() 64 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Hooky 5 | 6 | 7 | 42 | 43 | 44 | 45 |
46 |

Hooky

47 | 48 |

See the project README for more information.

49 |

Commit {{ SHORT_COMMIT }}.

50 |
51 | 52 | 53 | -------------------------------------------------------------------------------- /src/logic/__init__.py: -------------------------------------------------------------------------------- 1 | from textwrap import indent 2 | 3 | from ..settings import Settings, log 4 | from . import issues, prs 5 | from .models import EventParser, IssueEvent, PullRequestReviewEvent, PullRequestUpdateEvent 6 | 7 | __all__ = ('process_event',) 8 | 9 | 10 | def process_event(request_body: bytes, settings: Settings) -> tuple[bool, str]: 11 | try: 12 | event = EventParser.model_validate_json(request_body).root 13 | except ValueError as e: 14 | log(indent(f'{type(e).__name__}: {e}', ' ')) 15 | return False, 'Error parsing request body' 16 | 17 | if isinstance(event, IssueEvent): 18 | if event.issue.pull_request is None: 19 | return issues.process_issue(event=event, settings=settings) 20 | 21 | return prs.label_assign( 22 | event=event, 23 | event_type='comment', 24 | pr=event.issue, 25 | comment=event.comment, 26 | force_assign_author=False, 27 | settings=settings, 28 | ) 29 | elif isinstance(event, PullRequestReviewEvent): 30 | return prs.label_assign( 31 | event=event, 32 | event_type='review', 33 | pr=event.pull_request, 34 | comment=event.review, 35 | force_assign_author=event.review.state == 'changes_requested', 36 | settings=settings, 37 | ) 38 | else: 39 | assert isinstance(event, PullRequestUpdateEvent), 'unknown event type' 40 | return prs.check_change_file(event, settings) 41 | -------------------------------------------------------------------------------- /src/logic/common.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | import typing 5 | 6 | 7 | class BaseActor: 8 | ROLE: typing.ClassVar[str] 9 | _ROLE_REGEX: typing.ClassVar[re.Pattern] | None = None 10 | 11 | @classmethod 12 | def _get_role_regex(cls) -> re.Pattern: 13 | if cls._ROLE_REGEX is None: 14 | # for example "Selected Assignee: @samuelcolvin" or "Selected Reviewer: @samuelcolvin" 15 | cls._ROLE_REGEX = re.compile(rf'selected[ -]{cls.ROLE}:\s*@([\w\-]+)$', flags=re.I) 16 | return cls._ROLE_REGEX 17 | -------------------------------------------------------------------------------- /src/logic/issues.py: -------------------------------------------------------------------------------- 1 | import enum 2 | from dataclasses import dataclass, field 3 | from typing import Final 4 | 5 | import redis 6 | from github.Issue import Issue as GhIssue 7 | from github.Repository import Repository as GhRepository 8 | 9 | from ..github_auth import get_repo_client 10 | from ..repo_config import RepoConfig 11 | from ..settings import Settings, log 12 | from . import models 13 | from .common import BaseActor 14 | 15 | 16 | class IssueAction(enum.StrEnum): 17 | OPENED = 'opened' 18 | REOPENED = 'reopened' 19 | 20 | 21 | ISSUE_ACTIONS_TO_PROCESS: Final[tuple[IssueAction, ...]] = (IssueAction.OPENED,) 22 | 23 | 24 | def process_issue(*, event: models.IssueEvent, settings: Settings) -> tuple[bool, str]: 25 | """Processes an issue in the repo 26 | 27 | Performs following actions: 28 | - assigns new/reopened issues to the next person in the assignees list 29 | 30 | TODO: 31 | - assign reopened issue to the assignee selected before 32 | - use "can confirm" magic comment from a contributor to change labels (remove an `unconfirmed` label) 33 | - use "please update" magic comment to reassign to the author 34 | - reassign from the author back to contributor after any author's comment 35 | """ 36 | if event.action not in ISSUE_ACTIONS_TO_PROCESS: 37 | return False, f'Ignoring event action "{event.action}"' 38 | 39 | with get_repo_client(event.repository.full_name, settings) as gh_repo: 40 | gh_issue = gh_repo.get_issue(event.issue.number) 41 | config = RepoConfig.load(issue=gh_issue, settings=settings) 42 | 43 | log(f'{event.issue.user} ({event.action}): #{event.issue.number}') 44 | 45 | label_assign = LabelAssign( 46 | gh_issue=gh_issue, 47 | gh_repo=gh_repo, 48 | action=IssueAction(event.action), 49 | author=event.issue.user, 50 | repo_fullname=event.repository.full_name, 51 | config=config, 52 | settings=settings, 53 | ) 54 | 55 | return label_assign.assign_new() 56 | 57 | 58 | @dataclass(kw_only=True) 59 | class LabelAssign(BaseActor): 60 | ROLE = 'Assignee' 61 | 62 | gh_issue: GhIssue 63 | gh_repo: GhRepository 64 | action: IssueAction 65 | author: models.User 66 | repo_fullname: str 67 | config: RepoConfig 68 | settings: Settings 69 | assignees: list[str] = field(init=False) 70 | 71 | def __post_init__(self): 72 | self.assignees = self.config.assignees 73 | 74 | def assign_new(self) -> tuple[bool, str]: 75 | if self.action not in ISSUE_ACTIONS_TO_PROCESS: 76 | return False, f'Ignoring issue action "{self.action}"' 77 | 78 | if self.author.login in self.assignees: 79 | return False, f'@{self.author.login} is in repo assignees list, doing nothing' 80 | 81 | assignee = self._select_assignee() 82 | self._assign_user(assignee) 83 | self._add_label(self.config.unconfirmed_label) 84 | 85 | return (True, f'@{assignee} successfully assigned to issue, "{self.config.unconfirmed_label}" label added') 86 | 87 | def _select_assignee(self) -> str: 88 | key = f'assignee:{self.repo_fullname}' 89 | with redis.from_url(str(self.settings.redis_dsn)) as redis_client: 90 | assignees_count = len(self.assignees) 91 | assignee_index = redis_client.incr(key) - 1 92 | 93 | # so that key never hits 2**64 and causes an error 94 | if assignee_index >= 4_294_967_296: # 2**32 95 | assignee_index %= assignees_count 96 | redis_client.set(key, assignee_index + 1) 97 | 98 | return self.assignees[assignee_index % assignees_count] 99 | 100 | def _assign_user(self, username: str) -> None: 101 | if username in (gh_user.login for gh_user in self.gh_issue.assignees): 102 | return 103 | self.gh_issue.add_to_assignees(username) 104 | 105 | def _add_label(self, label: str) -> None: 106 | if label in (gh_label.name for gh_label in self.gh_issue.labels): 107 | return 108 | self.gh_issue.add_to_labels(label) 109 | 110 | def _add_reaction(self) -> None: 111 | self.gh_issue.create_reaction('+1') 112 | -------------------------------------------------------------------------------- /src/logic/models.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, RootModel 2 | 3 | 4 | class User(BaseModel): 5 | login: str 6 | 7 | 8 | class Comment(BaseModel): 9 | body: str 10 | user: User 11 | id: int 12 | 13 | 14 | class IssuePullRequest(BaseModel): 15 | url: str 16 | 17 | 18 | class Issue(BaseModel): 19 | pull_request: IssuePullRequest | None = None 20 | user: User 21 | number: int 22 | 23 | 24 | class Repository(BaseModel): 25 | full_name: str 26 | owner: User 27 | 28 | 29 | class IssueEvent(BaseModel): 30 | action: str # not defining a Literal here as we're not going to handle an exhaustive list of possible values 31 | comment: Comment | None = None 32 | issue: Issue 33 | repository: Repository 34 | 35 | 36 | class Review(BaseModel): 37 | body: str | None = None 38 | user: User 39 | state: str 40 | 41 | 42 | class PullRequest(BaseModel): 43 | number: int 44 | user: User 45 | state: str 46 | body: str | None = None 47 | 48 | 49 | class PullRequestReviewEvent(BaseModel): 50 | review: Review 51 | pull_request: PullRequest 52 | repository: Repository 53 | 54 | 55 | class PullRequestUpdateEvent(BaseModel): 56 | action: str 57 | pull_request: PullRequest 58 | repository: Repository 59 | 60 | 61 | Event = IssueEvent | PullRequestReviewEvent | PullRequestUpdateEvent 62 | 63 | 64 | class EventParser(RootModel): 65 | root: Event 66 | -------------------------------------------------------------------------------- /src/logic/prs.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Literal 3 | 4 | import redis 5 | from github.PullRequest import PullRequest as GhPullRequest 6 | from github.Repository import Repository as GhRepository 7 | 8 | from ..github_auth import get_repo_client 9 | from ..repo_config import RepoConfig 10 | from ..settings import Settings, log 11 | from .common import BaseActor 12 | from .models import Comment, Event, Issue, PullRequest, PullRequestUpdateEvent, Review 13 | 14 | 15 | def label_assign( 16 | *, 17 | event: Event, 18 | event_type: Literal['comment', 'review'], 19 | pr: Issue | PullRequest, 20 | comment: Comment | Review, 21 | force_assign_author: bool, 22 | settings: Settings, 23 | ) -> tuple[bool, str]: 24 | if comment.body is None: 25 | return False, '[Label and assign] review has no body' 26 | body = comment.body.lower() 27 | 28 | with get_repo_client(event.repository.full_name, settings) as gh_repo: 29 | gh_pr = gh_repo.get_pull(pr.number) 30 | config = RepoConfig.load(pr=gh_pr, settings=settings) 31 | 32 | log(f'{comment.user.login} ({event_type}): {body!r}') 33 | 34 | label_assign_ = LabelAssign( 35 | gh_pr, gh_repo, event_type, comment, pr.user.login, event.repository.full_name, config, settings 36 | ) 37 | if config.request_review_trigger in body: 38 | action_taken, msg = label_assign_.request_review() 39 | elif config.request_update_trigger in body or force_assign_author: 40 | action_taken, msg = label_assign_.assign_author() 41 | else: 42 | action_taken = False 43 | msg = ( 44 | f'neither {config.request_update_trigger!r} nor {config.request_review_trigger!r} ' 45 | f'found in comment body' 46 | ) 47 | return action_taken, f'[Label and assign] {msg}' 48 | 49 | 50 | class LabelAssign(BaseActor): 51 | ROLE = 'Reviewer' 52 | 53 | def __init__( 54 | self, 55 | gh_pr: GhPullRequest, 56 | gh_repo: GhRepository, 57 | event_type: Literal['comment', 'review'], 58 | comment: Comment, 59 | author: str, 60 | repo_fullname: str, 61 | config: RepoConfig, 62 | settings: Settings, 63 | ): 64 | self.gh_pr = gh_pr 65 | self.event_type = event_type 66 | self.comment = comment 67 | self.commenter = comment.user.login 68 | self.author = author 69 | self.repo_fullname = repo_fullname 70 | self.config = config 71 | self.settings = settings 72 | if config.reviewers: 73 | self.reviewers = config.reviewers 74 | else: 75 | self.reviewers = [r.login for r in gh_repo.get_collaborators()] 76 | self.commenter_is_reviewer = self.commenter in self.reviewers 77 | 78 | def assign_author(self) -> tuple[bool, str]: 79 | if not self.commenter_is_reviewer: 80 | return False, f'Only reviewers {self.show_reviewers()} can assign the author, not "{self.commenter}"' 81 | 82 | self.add_reaction() 83 | self.gh_pr.add_to_labels(self.config.awaiting_update_label) 84 | self.remove_label(self.config.awaiting_review_label) 85 | self.gh_pr.add_to_assignees(self.author) 86 | to_remove = [r for r in self.reviewers if r != self.author] 87 | if to_remove: 88 | self.gh_pr.remove_from_assignees(*to_remove) 89 | return ( 90 | True, 91 | f'Author {self.author} successfully assigned to PR, "{self.config.awaiting_update_label}" label added', 92 | ) 93 | 94 | def request_review(self) -> tuple[bool, str]: 95 | commenter_is_author = self.author == self.commenter 96 | if not (self.commenter_is_reviewer or commenter_is_author): 97 | return False, f'Only the PR author @{self.author} or reviewers can request a review, not "{self.commenter}"' 98 | 99 | self.add_reaction() 100 | self.gh_pr.add_to_labels(self.config.awaiting_review_label) 101 | self.remove_label(self.config.awaiting_update_label) 102 | 103 | try: 104 | reviewer = self.find_reviewer() 105 | except RuntimeError as e: 106 | return False, str(e) 107 | 108 | if reviewer != self.author: 109 | self.gh_pr.remove_from_assignees(self.author) 110 | self.gh_pr.add_to_assignees(reviewer) 111 | 112 | return ( 113 | True, 114 | f'@{reviewer} successfully assigned to PR as reviewer, ' 115 | f'"{self.config.awaiting_review_label}" label added', 116 | ) 117 | 118 | def add_reaction(self) -> None: 119 | """ 120 | Currently it seems there's no way to create a reaction on a review body, only on issue comments 121 | and review comments, although it's possible in the UI 122 | """ 123 | if self.event_type == 'comment': 124 | self.gh_pr.get_issue_comment(self.comment.id).create_reaction('+1') 125 | 126 | def remove_label(self, label: str): 127 | labels = self.gh_pr.get_labels() 128 | if any(lb.name == label for lb in labels): 129 | self.gh_pr.remove_from_labels(label) 130 | 131 | def show_reviewers(self): 132 | if self.reviewers: 133 | return ', '.join(f'"{r}"' for r in self.reviewers) 134 | else: 135 | return '(no reviewers configured)' 136 | 137 | def find_reviewer(self) -> str: 138 | """ 139 | Parses the PR body to find the reviewer, otherwise choose a reviewer by round-robin from `self.reviewers` 140 | and update the PR body to include the reviewer magic comment. 141 | """ 142 | pr_body = self.gh_pr.body or '' 143 | if m := self._get_role_regex().search(pr_body): 144 | # found the magic comment, inspect it 145 | username = m.group(1) 146 | if username in self.reviewers: 147 | # valid reviewer in the comment, no need to do anything else 148 | return username 149 | else: 150 | raise RuntimeError(f'Selected reviewer @{username} not in reviewers.') 151 | 152 | # reviewer not found in the PR body, choose a reviewer by round-robin 153 | key = f'reviewer:{self.repo_fullname}' 154 | with redis.from_url(str(self.settings.redis_dsn)) as redis_client: 155 | reviewer_index = redis_client.incr(key) - 1 156 | # so that key never hits 2**64 and causes an error 157 | if reviewer_index >= self.settings.reviewer_index_multiple * len(self.reviewers): 158 | reviewer_index %= len(self.reviewers) 159 | redis_client.set(key, reviewer_index + 1) 160 | 161 | reviewer = self.get_reviewer(reviewer_index) 162 | if reviewer == self.author: 163 | # if the reviewer is the author, choose the next reviewer 164 | # increment the index again so the same person isn't assigned next time 165 | reviewer_index = redis_client.incr(key) - 1 166 | reviewer = self.get_reviewer(reviewer_index) 167 | 168 | self.gh_pr.edit(body=f'{pr_body}\n\nSelected Reviewer: @{reviewer}') 169 | return reviewer 170 | 171 | def get_reviewer(self, reviewer_index: int) -> str: 172 | return self.reviewers[reviewer_index % len(self.reviewers)] 173 | 174 | 175 | closed_issue_template = ( 176 | r'(close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)\s+' 177 | r'(#|https://github.com/[^/]+/[^/]+/issues/){}' 178 | ) 179 | required_actions = {'opened', 'edited', 'reopened', 'synchronize'} 180 | CommitStatus = Literal['error', 'failure', 'pending', 'success'] 181 | 182 | 183 | def check_change_file(event: PullRequestUpdateEvent, settings: Settings) -> tuple[bool, str]: 184 | if event.pull_request.state != 'open': 185 | return False, f'[Check change file] Pull Request is {event.pull_request.state}, not open' 186 | if event.action not in required_actions: 187 | return False, f'[Check change file] file change not checked on "{event.action}"' 188 | if event.pull_request.user.login.endswith('[bot]'): 189 | return False, '[Check change file] Pull Request author is a bot' 190 | 191 | log(f'[Check change file] action={event.action} pull-request=#{event.pull_request.number}') 192 | with get_repo_client(event.repository.full_name, settings) as gh_repo: 193 | gh_pr = gh_repo.get_pull(event.pull_request.number) 194 | config = RepoConfig.load(pr=gh_pr, settings=settings) 195 | if not config.require_change_file: 196 | return False, '[Check change file] change file not required' 197 | 198 | body = event.pull_request.body.lower() if event.pull_request.body else '' 199 | if config.no_change_file in body: 200 | return set_status(gh_pr, 'success', f'Found "{config.no_change_file}" in Pull Request body') 201 | elif file_match := find_change_file(gh_pr): 202 | return set_status(gh_pr, *check_change_file_content(file_match, body, event.pull_request)) 203 | else: 204 | return set_status(gh_pr, 'error', 'No change file found') 205 | 206 | 207 | def check_change_file_content(file_match: re.Match, body: str, pr: PullRequest) -> tuple[CommitStatus, str]: 208 | file_id, file_author = file_match.groups() 209 | pr_author = pr.user.login 210 | if file_author.lower() != pr_author.lower(): 211 | return 'error', f'File "{file_match.group()}" has wrong author, expected "{pr_author}"' 212 | elif int(file_id) == pr.number: 213 | return 'success', f'Change file ID #{file_id} matches the Pull Request' 214 | elif re.search(closed_issue_template.format(file_id), body): 215 | return 'success', f'Change file ID #{file_id} matches Issue closed by the Pull Request' 216 | else: 217 | return 'error', 'Change file ID does not match Pull Request or closed Issue' 218 | 219 | 220 | def find_change_file(gh_pr: GhPullRequest) -> re.Match | None: 221 | for changed_file in gh_pr.get_files(): 222 | if changed_file.status == 'added' and (match := re.fullmatch(r'changes/(\d+)-(.+).md', changed_file.filename)): 223 | return match 224 | 225 | 226 | def set_status(gh_pr: GhPullRequest, state: CommitStatus, description: str) -> tuple[bool, str]: 227 | *_, last_commit = gh_pr.get_commits() 228 | last_commit.create_status( 229 | state, 230 | description=description, 231 | target_url='https://github.com/pydantic/hooky#readme', 232 | context='change-file-checks', 233 | ) 234 | return True, f'[Check change file] status set to "{state}" with description "{description}"' 235 | -------------------------------------------------------------------------------- /src/repo_config.py: -------------------------------------------------------------------------------- 1 | import base64 2 | from textwrap import indent 3 | 4 | import redis 5 | import rtoml 6 | from github import GithubException 7 | from github.Issue import Issue as GhIssue 8 | from github.PullRequest import PullRequest as GhPullRequest 9 | from github.Repository import Repository as GhRepository 10 | from pydantic import BaseModel, ValidationError 11 | 12 | from .settings import Settings, log 13 | 14 | __all__ = ('RepoConfig',) 15 | 16 | 17 | class RepoConfig(BaseModel): 18 | reviewers: list[str] = [] 19 | request_update_trigger: str = 'please update' 20 | request_review_trigger: str = 'please review' 21 | awaiting_update_label: str = 'awaiting author revision' 22 | awaiting_review_label: str = 'ready for review' 23 | no_change_file: str = 'skip change file check' 24 | require_change_file: bool = True 25 | assignees: list[str] = [] 26 | unconfirmed_label: str = 'unconfirmed' 27 | 28 | @classmethod 29 | def load(cls, *, pr: GhPullRequest | None = None, issue: GhIssue | None = None, settings: Settings) -> 'RepoConfig': 30 | assert (pr is None or issue is None) and pr != issue 31 | 32 | repo = pr.base.repo if pr is not None else issue.repository 33 | 34 | with redis.from_url(str(settings.redis_dsn)) as redis_client: 35 | repo_ref = pr.base.ref if pr is not None else repo.default_branch 36 | repo_cache_key = f'config_{repo.full_name}' 37 | 38 | if pr is not None: 39 | pr_cache_key = f'{repo_cache_key}_{repo_ref}' 40 | if pr_config := redis_client.get(pr_cache_key): 41 | return RepoConfig.model_validate_json(pr_config) 42 | if pr_config := cls._load_raw(repo, ref=repo_ref): 43 | redis_client.setex(pr_cache_key, settings.config_cache_timeout, pr_config.model_dump_json()) 44 | return pr_config 45 | 46 | if repo_config := redis_client.get(repo_cache_key): 47 | return RepoConfig.model_validate_json(repo_config) 48 | if repo_config := cls._load_raw(repo): 49 | redis_client.setex(repo_cache_key, settings.config_cache_timeout, repo_config.model_dump_json()) 50 | return repo_config 51 | 52 | default_config = cls() 53 | redis_client.setex(repo_cache_key, settings.config_cache_timeout, default_config.model_dump_json()) 54 | return default_config 55 | 56 | @classmethod 57 | def _load_raw(cls, repo: 'GhRepository', *, ref: str | None = None) -> 'RepoConfig | None': 58 | kwargs = {'ref': ref} if ref else {} 59 | prefix = f'{repo.full_name}#{ref}' if ref else f'{repo.full_name}#[default]' 60 | try: 61 | f = repo.get_contents('.hooky.toml', **kwargs) 62 | prefix += '/.hooky.toml' 63 | except GithubException: 64 | try: 65 | f = repo.get_contents('pyproject.toml', **kwargs) 66 | prefix += '/pyproject.toml' 67 | except GithubException as exc: 68 | log(f'{prefix}, No ".hooky.toml" or "pyproject.toml" found, using defaults: {exc}') 69 | return None 70 | 71 | content = base64.b64decode(f.content.encode()) 72 | try: 73 | config = rtoml.loads(content.decode()) 74 | except ValueError: 75 | log(f'{prefix}, Invalid config file, using defaults') 76 | return None 77 | try: 78 | hooky_config = config['tool']['hooky'] 79 | except KeyError: 80 | log(f'{prefix}, No [tools.hooky] section found, using defaults') 81 | return None 82 | 83 | try: 84 | config = cls.model_validate(hooky_config) 85 | except ValidationError as e: 86 | log(f'{prefix}, Error validating hooky config, using defaults') 87 | log(indent(f'{type(e).__name__}: {e}', ' ')) 88 | return None 89 | else: 90 | log(f'{prefix}, config: {config}') 91 | return config 92 | -------------------------------------------------------------------------------- /src/settings.py: -------------------------------------------------------------------------------- 1 | from pydantic import FilePath, RedisDsn, SecretBytes, field_validator 2 | from pydantic_settings import BaseSettings 3 | 4 | __all__ = 'Settings', 'log' 5 | _SETTINGS_CACHE: 'Settings | None' = None 6 | 7 | 8 | class Settings(BaseSettings): 9 | github_app_id: str = '227243' 10 | github_app_secret_key: FilePath = 'github_app_secret_key.pem' 11 | webhook_secret: SecretBytes 12 | marketplace_webhook_secret: SecretBytes = None 13 | redis_dsn: RedisDsn = 'redis://localhost:6379' 14 | config_cache_timeout: int = 600 15 | reviewer_index_multiple: int = 1000 16 | 17 | @classmethod 18 | def load_cached(cls, **kwargs) -> 'Settings': 19 | """ 20 | Allow settings to be set globally for testing. 21 | """ 22 | global _SETTINGS_CACHE 23 | if _SETTINGS_CACHE is None: 24 | _SETTINGS_CACHE = cls(**kwargs) 25 | return _SETTINGS_CACHE 26 | 27 | @field_validator('webhook_secret', 'marketplace_webhook_secret', mode='before') 28 | @staticmethod 29 | def str2bytes(value: str | bytes) -> bytes: 30 | if isinstance(value, str): 31 | value = value.encode() 32 | return value 33 | 34 | 35 | def log(msg: str) -> None: 36 | print(msg, flush=True) 37 | -------------------------------------------------------------------------------- /src/views.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import hmac 3 | import json 4 | import os 5 | from pathlib import Path 6 | 7 | from asyncer import asyncify 8 | from fastapi import FastAPI, Header, HTTPException, Request 9 | from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse 10 | 11 | from .logic import process_event 12 | from .settings import Settings, log 13 | 14 | settings = Settings.load_cached() 15 | app = FastAPI() 16 | THIS_DIR = Path(__file__).parent 17 | 18 | 19 | @app.get('/') 20 | def index(): 21 | index_content = (THIS_DIR / 'index.html').read_text() 22 | commit = os.getenv('RENDER_GIT_COMMIT', '???') 23 | index_content = index_content.replace('{{ COMMIT }}', commit).replace('{{ SHORT_COMMIT }}', commit[:7]) 24 | return HTMLResponse(content=index_content) 25 | 26 | 27 | @app.get('/favicon.ico') 28 | def favicon(): 29 | return FileResponse(THIS_DIR / 'favicon.ico') 30 | 31 | 32 | @app.post('/') 33 | async def webhook(request: Request, x_hub_signature_256: str = Header(default='')): 34 | request_body = await request.body() 35 | 36 | digest = hmac.new(settings.webhook_secret.get_secret_value(), request_body, hashlib.sha256).hexdigest() 37 | 38 | if not hmac.compare_digest(f'sha256={digest}', x_hub_signature_256): 39 | log(f'Invalid signature: {digest=} {x_hub_signature_256=}') 40 | raise HTTPException(status_code=403, detail='Invalid signature') 41 | 42 | action_taken, message = await asyncify(process_event)(request_body=request_body, settings=settings) 43 | message = message if action_taken else f'{message}, no action taken' 44 | log(message) 45 | return PlainTextResponse(message, status_code=200 if action_taken else 202) 46 | 47 | 48 | @app.post('/marketplace/') 49 | async def marketplace_webhook(request: Request, x_hub_signature_256: str = Header(default='')): 50 | # this endpoint doesn't actually do anything, it's here in case we want to use it in future 51 | request_body = await request.body() 52 | 53 | secret = settings.marketplace_webhook_secret 54 | if secret is None: 55 | raise HTTPException(status_code=403, detail='Marketplace secret not set') 56 | 57 | digest = hmac.new(secret.get_secret_value(), request_body, hashlib.sha256).hexdigest() 58 | 59 | if not hmac.compare_digest(f'sha256={digest}', x_hub_signature_256): 60 | log(f'Invalid marketplace signature: {digest=} {x_hub_signature_256=}') 61 | raise HTTPException(status_code=403, detail='Invalid marketplace signature') 62 | 63 | body = json.loads(request_body) 64 | log(f'Marketplace webhook: { json.dumps(body, indent=2)}') 65 | return PlainTextResponse('ok', status_code=202) 66 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pydantic/hooky/2bd7ce0ac5d5658888806871a3db16a11150c398/tests/__init__.py -------------------------------------------------------------------------------- /tests/blocks.py: -------------------------------------------------------------------------------- 1 | """ 2 | Blocks are a simple alternative for MagicMock which require all attributes to be defined in advance. 3 | 4 | See usage in ./logic.py for an example of usage. 5 | """ 6 | 7 | from abc import ABC, abstractmethod 8 | from copy import copy 9 | from dataclasses import dataclass 10 | from typing import Any 11 | 12 | 13 | @dataclass 14 | class History(ABC): 15 | path: list[str | int] 16 | 17 | def path_str(self): 18 | return '.'.join(str(i) for i in self.path) 19 | 20 | @abstractmethod 21 | def display(self): 22 | raise NotImplementedError 23 | 24 | 25 | class Block(ABC): 26 | _name: str 27 | 28 | def __init__(self): 29 | self.__path__: list[str | int] = [] 30 | self.__raw_history__: list[History] = [] 31 | 32 | def _set_history(self, path: list[str | int], history: list[History]) -> None: 33 | self.__path__ = path 34 | self.__raw_history__ = history 35 | 36 | def _path_append(self, *attrs: str | int) -> list[str | int]: 37 | new_path = copy(self.__path__) 38 | if new_path and attrs and new_path[-1] == attrs[0]: 39 | new_path.pop() 40 | return new_path + list(attrs) 41 | 42 | @property 43 | def __history__(self): 44 | return [f'{h.path_str()}: {h.display()}' for h in self.__raw_history__] 45 | 46 | @property 47 | def __name__(self) -> str: 48 | return f'{self.__class__.__name__}({self._name})' 49 | 50 | @abstractmethod 51 | def __repr__(self): 52 | raise NotImplementedError 53 | 54 | 55 | @dataclass 56 | class HistoryGetAttr(History): 57 | attr: Any 58 | 59 | def display(self): 60 | return repr(self.attr) 61 | 62 | 63 | Undefined = object() 64 | 65 | 66 | class AttrBlock(Block): 67 | def __init__(self, __name: str, /, **attrs: Any): 68 | super().__init__() 69 | self._name = __name 70 | self._attrs = attrs 71 | 72 | def __getattr__(self, item) -> Any: 73 | if (attr := self._attrs.get(item, Undefined)) is not Undefined: 74 | path = self._path_append(item) 75 | if isinstance(attr, Block): 76 | attr._set_history(path, self.__raw_history__) 77 | # self.__raw_history__.append(HistoryGetAttr(path, attr)) 78 | return attr 79 | else: 80 | raise AttributeError(f'{self.__name__!r} object has no attribute {item!r}') 81 | 82 | def __repr__(self): 83 | attrs_kwargs = ', '.join(f'{k}={v!r}' for k, v in self._attrs.items()) 84 | return f'{self.__class__.__name__}({self._name!r}, {attrs_kwargs})' 85 | 86 | 87 | @dataclass 88 | class HistoryCall(History): 89 | args: tuple[Any, ...] 90 | kwargs: dict[str, Any] 91 | return_value: Any 92 | raises: Any = None 93 | 94 | def display(self): 95 | args = ', '.join([repr(i) for i in self.args] + [f'{k}={v!r}' for k, v in self.kwargs.items()]) 96 | if self.raises: 97 | return f'raises {self.raises!r}' 98 | if self.return_value is not None: 99 | extra = f' -> {self.return_value!r}' 100 | else: 101 | extra = '' 102 | return f'Call({args}){extra}' 103 | 104 | 105 | class CallableBlock(Block): 106 | def __init__(self, name: str, return_value: Any = None, *, raises: Exception = None): 107 | super().__init__() 108 | self._name = name 109 | self._return_value = return_value 110 | self._raises = raises 111 | 112 | def __call__(self, *args, **kwargs): 113 | path = self._path_append(self._name) 114 | if self._raises is not None: 115 | raises = self._raises 116 | if isinstance(raises, Block): 117 | raises._set_history(path, self.__raw_history__) 118 | self.__raw_history__.append(HistoryCall(path, args, kwargs, None, raises)) 119 | raise raises 120 | else: 121 | return_value = self._return_value 122 | if isinstance(return_value, Block): 123 | return_value._set_history(path, self.__raw_history__) 124 | self.__raw_history__.append(HistoryCall(path, args, kwargs, return_value)) 125 | return return_value 126 | 127 | def __repr__(self): 128 | if self._return_value is None: 129 | return f'{self.__class__.__name__}({self._name!r})' 130 | else: 131 | return f'{self.__class__.__name__}({self._name!r}, {self._return_value!r})' 132 | 133 | 134 | @dataclass 135 | class HistoryIter(History): 136 | items: tuple[Any, ...] 137 | 138 | def display(self): 139 | args = ', '.join(repr(i) for i in self.items) 140 | return f'Iter({args})' 141 | 142 | 143 | class IterBlock(Block): 144 | def __init__(self, name: str, *items: Any): 145 | super().__init__() 146 | self._name = name 147 | self._items = items 148 | 149 | def __iter__(self, *args, **kwargs): 150 | path = self._path_append(self._name) 151 | items = self._items 152 | for i, item in enumerate(items): 153 | if isinstance(item, Block): 154 | item._set_history(self._path_append(self._name, i), self.__raw_history__) 155 | self.__raw_history__.append(HistoryIter(path, items)) 156 | return iter(items) 157 | 158 | def __repr__(self): 159 | args = (self._name,) + self._items 160 | return f'{self.__class__.__name__}({", ".join(repr(a) for a in args)})' 161 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import hashlib 3 | import hmac 4 | import json 5 | from typing import Any 6 | 7 | import pytest 8 | import redis 9 | from foxglove.testing import TestClient, create_dummy_server 10 | from requests import Response as RequestsResponse 11 | 12 | from src.settings import Settings 13 | 14 | from .dummy_server import routes 15 | 16 | 17 | @pytest.fixture(name='settings', scope='session') 18 | def fix_settings(): 19 | return Settings.load_cached( 20 | github_app_id='12345', 21 | redis_dsn='redis://localhost:6379/5', 22 | webhook_secret=b'webhook_secret', 23 | marketplace_webhook_secret=b'marketplace_webhook_secret', 24 | github_app_secret_key='tests/test_github_app_secret_key.pem', 25 | reviewer_index_multiple=10, 26 | ) 27 | 28 | 29 | @pytest.fixture(name='loop') 30 | def fix_loop(settings): 31 | try: 32 | loop = asyncio.get_event_loop() 33 | except RuntimeError: 34 | loop = asyncio.new_event_loop() 35 | asyncio.set_event_loop(loop) 36 | return loop 37 | 38 | 39 | @pytest.fixture(name='redis_cli') 40 | def fix_redis_cli(settings): 41 | with redis.from_url(str(settings.redis_dsn)) as redis_client: 42 | redis_client.flushdb() 43 | yield redis_client 44 | 45 | 46 | class Client(TestClient): 47 | def __init__(self, app, settings: Settings): 48 | super().__init__(app) 49 | self.settings = settings 50 | 51 | def webhook(self, data: dict[str, Any]) -> RequestsResponse: 52 | request_body = json.dumps(data).encode() 53 | digest = hmac.new(self.settings.webhook_secret.get_secret_value(), request_body, hashlib.sha256).hexdigest() 54 | return self.post('/', data=request_body, headers={'x-hub-signature-256': f'sha256={digest}'}) 55 | 56 | 57 | @pytest.fixture(name='client') 58 | def fix_client(settings: Settings, loop): 59 | from src import app 60 | 61 | with Client(app, settings) as client: 62 | yield client 63 | 64 | 65 | @pytest.fixture(name='dummy_server') 66 | def _fix_dummy_server(loop, redis_cli): 67 | from src import github_auth 68 | 69 | loop = asyncio.get_event_loop() 70 | ctx = {'dynamic': {}} 71 | ds = loop.run_until_complete(create_dummy_server(loop, extra_routes=routes, extra_context=ctx)) 72 | ctx['dynamic']['github_base_url'] = ds.server_name 73 | github_auth.github_base_url = ds.server_name 74 | 75 | yield ds 76 | 77 | loop.run_until_complete(ds.stop()) 78 | -------------------------------------------------------------------------------- /tests/dummy_server.py: -------------------------------------------------------------------------------- 1 | import base64 2 | 3 | from aiohttp import web 4 | from aiohttp.abc import Request 5 | from aiohttp.web_response import Response, json_response 6 | 7 | 8 | async def repo_details(request: Request) -> Response: 9 | github_base_url = request.app['dynamic']['github_base_url'] 10 | org = request.match_info['org'] 11 | repo = request.match_info['repo'] 12 | return json_response({'url': f'{github_base_url}/repos/{org}/{repo}', 'full_name': f'{org}/{repo}'}) 13 | 14 | 15 | async def pull_details(request: Request) -> Response: 16 | github_base_url = request.app['dynamic']['github_base_url'] 17 | org = request.match_info['org'] 18 | repo = request.match_info['repo'] 19 | pull_number = request.match_info['pull_number'] 20 | return json_response( 21 | { 22 | 'url': f'{github_base_url}/repos/{org}/{repo}/pulls/{pull_number}', 23 | 'issue_url': f'{github_base_url}/repos/{org}/{repo}/issues/{pull_number}', 24 | 'base': { 25 | 'label': 'foobar:main', 26 | 'ref': 'main', 27 | 'sha': 'abc1234', 28 | 'repo': {'url': f'{github_base_url}/repos/{org}/{repo}', 'full_name': f'{org}/{repo}'}, 29 | }, 30 | } 31 | ) 32 | 33 | 34 | async def pull_patch(_request: Request) -> Response: 35 | return json_response({'patch': 'foobar'}) 36 | 37 | 38 | async def pull_files(_request: Request) -> Response: 39 | return json_response( 40 | [ 41 | { 42 | 'deletions': 0, 43 | 'additions': 1, 44 | 'changes': 2, 45 | 'filename': 'changes/123-foobar.md', 46 | 'sha': 'abc', 47 | 'status': 'added', 48 | } 49 | ] 50 | ) 51 | 52 | 53 | async def pull_commits(request: Request) -> Response: 54 | github_base_url = request.app['dynamic']['github_base_url'] 55 | org = request.match_info['org'] 56 | repo = request.match_info['repo'] 57 | return json_response( 58 | [{'sha': 'abc', 'url': f'{github_base_url}/repos/{org}/{repo}/commits/abc', 'commit': {'message': 'foobar'}}] 59 | ) 60 | 61 | 62 | async def update_status(_request: Request) -> Response: 63 | return json_response({}) 64 | 65 | 66 | async def comment_details(request: Request) -> Response: 67 | github_base_url = request.app['dynamic']['github_base_url'] 68 | org = request.match_info['org'] 69 | repo = request.match_info['repo'] 70 | comment_id = request.match_info['comment_id'] 71 | return json_response({'url': f'{github_base_url}/repos/{org}/{repo}/comments/{comment_id}'}) 72 | 73 | 74 | async def comment_reaction(_request: Request) -> Response: 75 | return json_response({}) 76 | 77 | 78 | async def get_labels(_request: Request) -> Response: 79 | return json_response({}) 80 | 81 | 82 | async def add_labels(_request: Request) -> Response: 83 | return json_response({}) 84 | 85 | 86 | async def add_assignee(_request: Request) -> Response: 87 | return json_response({'assignees': []}) 88 | 89 | 90 | async def remove_assignee(_request: Request) -> Response: 91 | return json_response({'assignees': []}) 92 | 93 | 94 | sample_config = b""" 95 | [tool.hooky] 96 | reviewers = ['user1', 'user2'] 97 | assignees = ['user3', 'user4'] 98 | """ 99 | 100 | 101 | async def py_project_content(request: Request) -> Response: 102 | repo = request.match_info['repo'] 103 | if repo == 'no_reviewers': 104 | return json_response({}, status=404) 105 | else: 106 | return json_response( 107 | {'content': base64.b64encode(sample_config).decode(), 'encoding': 'base64', 'type': 'file'} 108 | ) 109 | 110 | 111 | async def get_collaborators(request: Request) -> Response: 112 | org = request.match_info['org'] 113 | return json_response([{'login': org, 'type': 'User'}, {'login': 'an_other', 'type': 'User'}]) 114 | 115 | 116 | async def repo_apps_installed(request: Request) -> Response: 117 | assert request.headers['Accept'] == 'application/vnd.github+json' 118 | return json_response({'id': '654321'}) 119 | 120 | 121 | async def installation_access_token(request: Request) -> Response: 122 | assert request.headers['Accept'] == 'application/vnd.github+json' 123 | return json_response({'token': 'foobar'}) 124 | 125 | 126 | async def issue_details(request: Request) -> Response: 127 | github_base_url = request.app['dynamic']['github_base_url'] 128 | org = request.match_info['org'] 129 | repo = request.match_info['repo'] 130 | issue_number = request.match_info['issue_number'] 131 | return json_response( 132 | { 133 | 'url': f'{github_base_url}/repos/{org}/{repo}/issues/{issue_number}', 134 | 'repository_url': f'{github_base_url}/repos/{org}/{repo}', 135 | 'number': issue_number, 136 | 'state': 'open', 137 | 'title': 'Found a bug', 138 | 'body': "I'm having a problem with this.", 139 | 'user': {'login': 'user2', 'id': 2, 'type': 'User'}, 140 | 'labels': [], 141 | 'assignee': None, 142 | 'assignees': [], 143 | 'milestone': None, 144 | 'locked': False, 145 | 'closed_at': None, 146 | 'created_at': '2011-04-22T13:33:48Z', 147 | 'updated_at': '2011-04-22T13:33:48Z', 148 | } 149 | ) 150 | 151 | 152 | async def issue_patch(_request: Request) -> Response: 153 | return json_response({'patch': 'foobar'}) 154 | 155 | 156 | async def issue_reactions(_request: Request) -> Response: 157 | return json_response({}) 158 | 159 | 160 | async def catch_all(request: Request) -> Response: 161 | print(f'{request.method}: {request.path} 404') 162 | return Response(body=f'{request.method} {request.path} 404', status=404) 163 | 164 | 165 | routes = [ 166 | web.get('/repos/{org}/{repo}', repo_details), 167 | web.get('/repos/{org}/{repo}/pulls/{pull_number}', pull_details), 168 | web.patch('/repos/{org}/{repo}/pulls/{pull_number}', pull_patch), 169 | web.get('/repos/{org}/{repo}/pulls/{pull_number}/files', pull_files), 170 | web.get('/repos/{org}/{repo}/pulls/{pull_number}/commits', pull_commits), 171 | web.post('/repos/{org}/{repo}/statuses/{commit}', update_status), 172 | web.get('/repos/{org}/{repo}/issues/comments/{comment_id}', comment_details), 173 | web.post('/repos/{org}/{repo}/comments/{comment_id}/reactions', comment_reaction), 174 | web.get('/repos/{org}/{repo}/issues/{issue_id}/labels', get_labels), 175 | web.post('/repos/{org}/{repo}/issues/{issue_id}/labels', add_labels), 176 | web.post('/repos/{org}/{repo}/issues/{issue_id}/assignees', add_assignee), 177 | web.delete('/repos/{org}/{repo}/issues/{issue_id}/assignees', remove_assignee), 178 | web.get('/repos/{org}/{repo}/contents/pyproject.toml', py_project_content), 179 | web.get('/repos/{org}/{repo}/collaborators', get_collaborators), 180 | web.get('/repos/{org}/{repo}/installation', repo_apps_installed), 181 | web.post('/app/installations/{installation}/access_tokens', installation_access_token), 182 | web.get('/repos/{org}/{repo}/issues/{issue_number}', issue_details), 183 | web.patch('/repos/{org}/{repo}/issues/{issue_number}', issue_patch), 184 | web.post('/repos/{org}/{repo}/issues/{issue_number}/reactions', issue_reactions), 185 | web.route('*', '/{path:.*}', catch_all), 186 | ] 187 | -------------------------------------------------------------------------------- /tests/test_github_app_secret_key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIICdwIBADANBgkqhkiG9w0BAQEFAASCAmEwggJdAgEAAoGBALEPKL1KyfnCz5rD 3 | KaWmQG7J++SDruXxtL3Qc5z6cDyrjsJxriw5DQxQsdRu/ZE8nD2kMi/cwxb2T/LU 4 | CwYs7LW7rGz/doo4vVOzp7rhRPuAxN6v+oRnYiLbEZaUusFId9DjHvCYSx9YmAqV 5 | 08qcFHC6z6FItJaIYmHWD6xxEQ/5AgMBAAECgYArMmyZCgcOxUWLF3Qwssfjf6nR 6 | zYK9HOQgrxuVv8/kLWLN85gvt9eEGqfYESAR7/yaWVXZMX3zOzK0JqFt50X0hFIW 7 | ILCbcRELt4CDjNxDsdOnzVr7gUP+j3KveaSHUe2/iKGq8xCU82FUK9mLYpezokNz 8 | Swtv04D2Sl2ZIGvShQJBAOcZpBdeCYsyOMui5zSlUdoYLfE2TOamCL/9gWSw2V4F 9 | C4kyYqA46HVSpPu2gbrwogQO+JEBwQlkrgYA09j4q/8CQQDEIuy6EXzbQ1TLGWqK 10 | QEKeaEPrDm4Yr++3sbZdfm9D+xTKs81gfIb5tstE4FfQxnWtXmgW1x3yYsBLyvev 11 | 66QHAkEAmOX+CvfMmKvBp/k/vzUh0ons24pxlqiDYYL3+QaIygvMdhk/54G/SuBD 12 | B8bYTjam+shs7IOck/poqNAjWYotQQJAIRjPn5ph2lIjVd5lFw0+8KIhi+G0fF/7 13 | 8KCBaId0WSFeYdIzfuukjzDkXiwJRYanxuieYfRM7mDxmBiY8UuvMwJBALJZuRK8 14 | 2+v8acrXcfUNlnDJlTTCqyaAQ/1M6SJR7J+Iau4YDA73PfXgpdQsPwk8E1h29s/F 15 | wO3voNu+69H+JFw= 16 | -----END PRIVATE KEY----- 17 | -------------------------------------------------------------------------------- /tests/test_github_auth.py: -------------------------------------------------------------------------------- 1 | from foxglove.testing import DummyServer 2 | 3 | from src.settings import Settings 4 | 5 | from .conftest import Client 6 | 7 | 8 | def test_config_cached(client: Client, settings: Settings, dummy_server: DummyServer): 9 | data = { 10 | 'action': 'created', 11 | 'comment': {'body': 'Hello world', 'user': {'login': 'user1'}, 'id': 123456}, 12 | 'issue': { 13 | 'pull_request': {'url': 'https://api.github.com/repos/user1/repo1/pulls/123'}, 14 | 'user': {'login': 'user1'}, 15 | 'number': 123, 16 | }, 17 | 'repository': {'full_name': 'user1/repo1', 'owner': {'login': 'user1'}}, 18 | } 19 | r = client.webhook(data) 20 | assert r.status_code == 202, r.text 21 | assert r.text == ( 22 | "[Label and assign] neither 'please update' nor 'please review' found in comment body, no action taken" 23 | ) 24 | log1 = [ 25 | 'GET /repos/user1/repo1/installation > 200', 26 | 'POST /app/installations/654321/access_tokens > 200', 27 | 'GET /repos/user1/repo1 > 200', 28 | 'GET /repos/user1/repo1/pulls/123 > 200', 29 | 'GET /repos/user1/repo1/contents/.hooky.toml?ref=main > 404', 30 | 'GET /repos/user1/repo1/contents/pyproject.toml?ref=main > 200', 31 | ] 32 | assert dummy_server.log == log1 33 | 34 | # do it again, installation is cached 35 | r = client.webhook(data) 36 | assert r.status_code == 202, r.text 37 | assert r.text == ( 38 | "[Label and assign] neither 'please update' nor 'please review' found in comment body, no action taken" 39 | ) 40 | assert dummy_server.log == log1 + ['GET /repos/user1/repo1 > 200', 'GET /repos/user1/repo1/pulls/123 > 200'] 41 | -------------------------------------------------------------------------------- /tests/test_index.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from .conftest import Client 4 | 5 | 6 | @pytest.mark.parametrize( 7 | 'method', 8 | [ 9 | 'get', 10 | pytest.param('head', marks=[pytest.mark.xfail(reason='Looks like a TestClient bug with the "HEAD" method')]), 11 | ], 12 | ) 13 | def test_index(client: Client, method): 14 | r = client.request(method, '/') 15 | assert r.status_code == 200, r.text 16 | assert r.headers['content-type'] == 'text/html; charset=utf-8' 17 | if method == 'get': 18 | assert '

Hooky

' in r.text 19 | assert '???' in r.text 20 | else: 21 | assert r.text == '' 22 | 23 | 24 | @pytest.mark.parametrize( 25 | 'method', 26 | [ 27 | 'get', 28 | pytest.param('head', marks=[pytest.mark.xfail(reason='Looks like a TestClient bug with the "HEAD" method')]), 29 | ], 30 | ) 31 | def test_favicon(client: Client, method): 32 | r = client.request(method, '/favicon.ico') 33 | assert r.status_code == 200, r.text 34 | # different on linux ('image/vnd.microsoft.icon') and macos ('image/x-icon') 35 | assert r.headers['content-type'] in {'image/vnd.microsoft.icon', 'image/x-icon'} 36 | -------------------------------------------------------------------------------- /tests/test_logic_issues.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import redis 3 | 4 | from src.logic.issues import IssueAction, LabelAssign 5 | from src.logic.models import User 6 | from src.repo_config import RepoConfig 7 | 8 | from .blocks import AttrBlock, CallableBlock, IterBlock 9 | 10 | 11 | @pytest.fixture(name='gh_repo') 12 | def fix_gh_repo(): 13 | return AttrBlock('GhRepo', get_collaborators=CallableBlock('get_collaborators', IterBlock('collaborators'))) 14 | 15 | 16 | @pytest.fixture(name='gh_issue') 17 | def fix_gh_issue(): 18 | return AttrBlock( 19 | 'GhIssue', 20 | edit=CallableBlock('edit'), 21 | body='this is the issue body', 22 | assignees=[], 23 | add_to_assignees=CallableBlock('add_to_assignees'), 24 | labels=[], 25 | add_to_labels=CallableBlock('add_to_labels'), 26 | create_reaction=CallableBlock('create_reaction'), 27 | ) 28 | 29 | 30 | def test_assign_new(settings, gh_issue, gh_repo, redis_cli): 31 | la = LabelAssign( 32 | gh_issue=gh_issue, 33 | gh_repo=gh_repo, 34 | action=IssueAction.OPENED, 35 | author=User(login='the_author'), 36 | repo_fullname='org/repo', 37 | config=RepoConfig(assignees=['user1', 'user2']), 38 | settings=settings, 39 | ) 40 | acted, msg = la.assign_new() 41 | assert acted, msg 42 | assert msg == '@user1 successfully assigned to issue, "unconfirmed" label added' 43 | assert gh_issue.__history__ == ["add_to_assignees: Call('user1')", "add_to_labels: Call('unconfirmed')"] 44 | 45 | la2 = LabelAssign( 46 | gh_issue=gh_issue, 47 | gh_repo=gh_repo, 48 | action=IssueAction.OPENED, 49 | author=User(login='the_author'), 50 | repo_fullname='org/repo', 51 | config=RepoConfig(assignees=['user1', 'user2']), 52 | settings=settings, 53 | ) 54 | acted, msg = la2.assign_new() 55 | assert acted, msg 56 | assert msg == '@user2 successfully assigned to issue, "unconfirmed" label added' 57 | 58 | 59 | def test_assign_new_one_assignee(settings, gh_issue, gh_repo, redis_cli): 60 | la = LabelAssign( 61 | gh_issue=gh_issue, 62 | gh_repo=gh_repo, 63 | action=IssueAction.OPENED, 64 | author=User(login='the_author'), 65 | repo_fullname='org/repo', 66 | config=RepoConfig(assignees=['user1']), 67 | settings=settings, 68 | ) 69 | acted, msg = la.assign_new() 70 | assert acted, msg 71 | assert msg == '@user1 successfully assigned to issue, "unconfirmed" label added' 72 | assert gh_issue.__history__ == ["add_to_assignees: Call('user1')", "add_to_labels: Call('unconfirmed')"] 73 | 74 | 75 | def test_do_not_assign_from_one_of_assignees(settings, gh_issue, gh_repo, redis_cli): 76 | la = LabelAssign( 77 | gh_issue=gh_issue, 78 | gh_repo=gh_repo, 79 | action=IssueAction.OPENED, 80 | author=User(login='user2'), 81 | repo_fullname='org/repo', 82 | config=RepoConfig(assignees=['user1', 'user2']), 83 | settings=settings, 84 | ) 85 | acted, msg = la.assign_new() 86 | assert not acted, msg 87 | assert gh_issue.__history__ == [] 88 | 89 | 90 | def test_do_not_assign_on_reopen(settings, gh_issue, gh_repo, redis_cli): 91 | la = LabelAssign( 92 | gh_issue=gh_issue, 93 | gh_repo=gh_repo, 94 | action=IssueAction.REOPENED, 95 | author=User(login='the_author'), 96 | repo_fullname='org/repo', 97 | config=RepoConfig(assignees=['user1']), 98 | settings=settings, 99 | ) 100 | acted, msg = la.assign_new() 101 | assert not acted, msg 102 | assert gh_issue.__history__ == [] 103 | 104 | 105 | def test_many_assignments(settings, gh_issue, gh_repo, redis_cli: redis.Redis): 106 | la = LabelAssign( 107 | gh_issue=gh_issue, 108 | gh_repo=gh_repo, 109 | action=IssueAction.OPENED, 110 | author=User(login='the_author'), 111 | repo_fullname='org/repo', 112 | config=RepoConfig(assignees=['user1', 'user2', 'user3', 'user4']), 113 | settings=settings, 114 | ) 115 | 116 | key = 'assignee:org/repo' 117 | assert redis_cli.get(key) is None 118 | 119 | assert la._select_assignee() == 'user1' 120 | assert la._select_assignee() == 'user2' 121 | assert la._select_assignee() == 'user3' 122 | assert la._select_assignee() == 'user4' 123 | assert la._select_assignee() == 'user1' 124 | assert la._select_assignee() == 'user2' 125 | 126 | redis_cli.set(key, 4_294_967_295) 127 | assert la._select_assignee() == 'user4' 128 | assert redis_cli.get(key) == b'4294967296' 129 | assert la._select_assignee() == 'user1' 130 | assert redis_cli.get(key) == b'1' 131 | assert la._select_assignee() == 'user2' 132 | assert la._select_assignee() == 'user3' 133 | assert la._select_assignee() == 'user4' 134 | assert la._select_assignee() == 'user1' 135 | 136 | redis_cli.set(key, 4_294_967_299) 137 | assert la._select_assignee() == 'user4' 138 | assert redis_cli.get(key) == b'4' 139 | 140 | redis_cli.set(key, 4_294_967_300) 141 | assert la._select_assignee() == 'user1' 142 | assert redis_cli.get(key) == b'1' 143 | -------------------------------------------------------------------------------- /tests/test_logic_prs.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import re 3 | from dataclasses import dataclass 4 | 5 | import pytest 6 | from github import GithubException 7 | 8 | from src.logic.models import Comment, PullRequest, PullRequestUpdateEvent, Repository, User 9 | from src.logic.prs import LabelAssign, check_change_file, check_change_file_content, find_change_file 10 | from src.repo_config import RepoConfig 11 | 12 | from .blocks import AttrBlock, CallableBlock, IterBlock 13 | 14 | 15 | @pytest.fixture(name='gh_repo') 16 | def fix_gh_repo(): 17 | return AttrBlock('GhRepo', get_collaborators=CallableBlock('get_collaborators', IterBlock('collaborators'))) 18 | 19 | 20 | @pytest.fixture(name='gh_pr') 21 | def fix_gh_pr(): 22 | return AttrBlock( 23 | 'GhPr', 24 | get_issue_comment=CallableBlock( 25 | 'get_issue_comment', AttrBlock('Comment', create_reaction=CallableBlock('create_reaction')) 26 | ), 27 | body='this is the pr body', 28 | add_to_labels=CallableBlock('add_to_labels'), 29 | get_labels=CallableBlock('get_labels', IterBlock('get_labels', AttrBlock('labels', name='ready for review'))), 30 | remove_from_labels=CallableBlock('remove_from_labels'), 31 | add_to_assignees=CallableBlock('add_to_assignees'), 32 | remove_from_assignees=CallableBlock('remove_from_assignees'), 33 | edit=CallableBlock('edit'), 34 | ) 35 | 36 | 37 | def test_assign_author(settings, gh_pr, gh_repo): 38 | la = LabelAssign( 39 | gh_pr, 40 | gh_repo, 41 | 'comment', 42 | Comment(body='x', user=User(login='user1'), id=123456), 43 | 'user1', 44 | 'org/repo', 45 | RepoConfig(reviewers=['user1', 'user2']), 46 | settings, 47 | ) 48 | assert la.assign_author() == ( 49 | True, 50 | 'Author user1 successfully assigned to PR, "awaiting author revision" label added', 51 | ) 52 | # insert_assert(gh_pr.__history__) 53 | assert gh_pr.__history__ == [ 54 | "get_issue_comment: Call(123456) -> AttrBlock('Comment', create_reaction=CallableBlock('create_reaction'))", 55 | "get_issue_comment.create_reaction: Call('+1')", 56 | "add_to_labels: Call('awaiting author revision')", 57 | "get_labels: Call() -> IterBlock('get_labels', AttrBlock('labels', name='ready for review'))", 58 | "get_labels: Iter(AttrBlock('labels', name='ready for review'))", 59 | "remove_from_labels: Call('ready for review')", 60 | "add_to_assignees: Call('user1')", 61 | "remove_from_assignees: Call('user2')", 62 | ] 63 | 64 | 65 | def test_assign_author_remove_label(settings, gh_pr, gh_repo): 66 | la = LabelAssign( 67 | gh_pr, 68 | gh_repo, 69 | 'comment', 70 | Comment(body='x', user=User(login='user1'), id=123456), 71 | 'user1', 72 | 'org/repo', 73 | RepoConfig(reviewers=['user1']), 74 | settings, 75 | ) 76 | assert la.assign_author() == ( 77 | True, 78 | 'Author user1 successfully assigned to PR, "awaiting author revision" label added', 79 | ) 80 | # insert_assert(gh_pr.__history__) 81 | assert gh_pr.__history__ == [ 82 | "get_issue_comment: Call(123456) -> AttrBlock('Comment', create_reaction=CallableBlock('create_reaction'))", 83 | "get_issue_comment.create_reaction: Call('+1')", 84 | "add_to_labels: Call('awaiting author revision')", 85 | "get_labels: Call() -> IterBlock('get_labels', AttrBlock('labels', name='ready for review'))", 86 | "get_labels: Iter(AttrBlock('labels', name='ready for review'))", 87 | "remove_from_labels: Call('ready for review')", 88 | "add_to_assignees: Call('user1')", 89 | ] 90 | 91 | 92 | def test_author_request_review(settings, gh_pr, gh_repo, redis_cli): 93 | la = LabelAssign( 94 | gh_pr, 95 | gh_repo, 96 | 'comment', 97 | Comment(body='x', user=User(login='the_author'), id=123456), 98 | 'the_author', 99 | 'org/repo', 100 | RepoConfig(reviewers=['user1', 'user2']), 101 | settings, 102 | ) 103 | acted, msg = la.request_review() 104 | assert acted, msg 105 | assert msg == '@user1 successfully assigned to PR as reviewer, "ready for review" label added' 106 | # insert_assert(gh_pr.__history__) 107 | assert gh_pr.__history__ == [ 108 | "get_issue_comment: Call(123456) -> AttrBlock('Comment', create_reaction=CallableBlock('create_reaction'))", 109 | "get_issue_comment.create_reaction: Call('+1')", 110 | "add_to_labels: Call('ready for review')", 111 | "get_labels: Call() -> IterBlock('get_labels', AttrBlock('labels', name='ready for review'))", 112 | "get_labels: Iter(AttrBlock('labels', name='ready for review'))", 113 | "edit: Call(body='this is the pr body\\n\\nSelected Reviewer: @user1')", 114 | "remove_from_assignees: Call('the_author')", 115 | "add_to_assignees: Call('user1')", 116 | ] 117 | 118 | la2 = LabelAssign( 119 | gh_pr, 120 | gh_repo, 121 | 'comment', 122 | Comment(body='x', user=User(login='author2'), id=123456), 123 | 'author2', 124 | 'org/repo', 125 | RepoConfig(reviewers=['user1', 'user2']), 126 | settings, 127 | ) 128 | acted, msg = la2.request_review() 129 | assert acted 130 | assert msg == '@user2 successfully assigned to PR as reviewer, "ready for review" label added' 131 | 132 | 133 | def test_request_review_magic_comment(settings, gh_repo, redis_cli): 134 | gh_pr = AttrBlock( 135 | 'GhPr', 136 | get_issue_comment=CallableBlock( 137 | 'get_issue_comment', AttrBlock('Comment', create_reaction=CallableBlock('create_reaction')) 138 | ), 139 | body='this is the pr body\n\nSelected Reviewer: @user2', 140 | add_to_labels=CallableBlock('add_to_labels'), 141 | get_labels=CallableBlock('get_labels', IterBlock('get_labels', AttrBlock('labels', name='ready for review'))), 142 | remove_from_labels=CallableBlock('remove_from_labels'), 143 | add_to_assignees=CallableBlock('add_to_assignees'), 144 | remove_from_assignees=CallableBlock('remove_from_assignees'), 145 | edit=CallableBlock('edit'), 146 | ) 147 | la = LabelAssign( 148 | gh_pr, 149 | gh_repo, 150 | 'comment', 151 | Comment(body='x', user=User(login='the_author'), id=123456), 152 | 'the_author', 153 | 'org/repo', 154 | RepoConfig(reviewers=['user1', 'user2']), 155 | settings, 156 | ) 157 | acted, msg = la.request_review() 158 | assert acted, msg 159 | assert msg == '@user2 successfully assigned to PR as reviewer, "ready for review" label added' 160 | # insert_assert(gh_pr.__history__) 161 | assert gh_pr.__history__ == [ 162 | "get_issue_comment: Call(123456) -> AttrBlock('Comment', create_reaction=CallableBlock('create_reaction'))", 163 | "get_issue_comment.create_reaction: Call('+1')", 164 | "add_to_labels: Call('ready for review')", 165 | "get_labels: Call() -> IterBlock('get_labels', AttrBlock('labels', name='ready for review'))", 166 | "get_labels: Iter(AttrBlock('labels', name='ready for review'))", 167 | "remove_from_assignees: Call('the_author')", 168 | "add_to_assignees: Call('user2')", 169 | ] 170 | 171 | 172 | def test_request_review_bad_magic_comment(settings, gh_repo, redis_cli): 173 | gh_pr = AttrBlock( 174 | 'GhPr', 175 | get_issue_comment=CallableBlock( 176 | 'get_issue_comment', AttrBlock('Comment', create_reaction=CallableBlock('create_reaction')) 177 | ), 178 | body='this is the pr body\n\nSelected Reviewer: @other-person', 179 | add_to_labels=CallableBlock('add_to_labels'), 180 | get_labels=CallableBlock('get_labels', IterBlock('get_labels', AttrBlock('labels', name='ready for review'))), 181 | remove_from_labels=CallableBlock('remove_from_labels'), 182 | add_to_assignees=CallableBlock('add_to_assignees'), 183 | remove_from_assignees=CallableBlock('remove_from_assignees'), 184 | edit=CallableBlock('edit'), 185 | ) 186 | la = LabelAssign( 187 | gh_pr, 188 | gh_repo, 189 | 'comment', 190 | Comment(body='x', user=User(login='the_author'), id=123456), 191 | 'the_author', 192 | 'org/repo', 193 | RepoConfig(reviewers=['user1', 'user2']), 194 | settings, 195 | ) 196 | acted, msg = la.request_review() 197 | assert not acted, msg 198 | assert msg == 'Selected reviewer @other-person not in reviewers.' 199 | 200 | 201 | def test_request_review_one_reviewer(settings, gh_pr, gh_repo, redis_cli): 202 | la = LabelAssign( 203 | gh_pr, 204 | gh_repo, 205 | 'comment', 206 | Comment(body='x', user=User(login='user1'), id=123456), 207 | 'user1', 208 | 'org/repo', 209 | RepoConfig(reviewers=['user1']), 210 | settings, 211 | ) 212 | acted, msg = la.request_review() 213 | assert acted, msg 214 | assert msg == '@user1 successfully assigned to PR as reviewer, "ready for review" label added' 215 | # insert_assert(gh_pr.__history__) 216 | assert gh_pr.__history__ == [ 217 | "get_issue_comment: Call(123456) -> AttrBlock('Comment', create_reaction=CallableBlock('create_reaction'))", 218 | "get_issue_comment.create_reaction: Call('+1')", 219 | "add_to_labels: Call('ready for review')", 220 | "get_labels: Call() -> IterBlock('get_labels', AttrBlock('labels', name='ready for review'))", 221 | "get_labels: Iter(AttrBlock('labels', name='ready for review'))", 222 | "edit: Call(body='this is the pr body\\n\\nSelected Reviewer: @user1')", 223 | ] 224 | 225 | 226 | def test_request_review_from_review(settings, gh_pr, gh_repo, redis_cli): 227 | la = LabelAssign( 228 | gh_pr, 229 | gh_repo, 230 | 'review', 231 | Comment(body='x', user=User(login='other'), id=123456), 232 | 'other', 233 | 'org/repo', 234 | RepoConfig(reviewers=['user1', 'user2']), 235 | settings, 236 | ) 237 | acted, msg = la.request_review() 238 | assert acted 239 | assert msg == '@user1 successfully assigned to PR as reviewer, "ready for review" label added' 240 | assert gh_pr.__history__ == [ 241 | "add_to_labels: Call('ready for review')", 242 | "get_labels: Call() -> IterBlock('get_labels', AttrBlock('labels', name='ready for review'))", 243 | "get_labels: Iter(AttrBlock('labels', name='ready for review'))", 244 | "edit: Call(body='this is the pr body\\n\\nSelected Reviewer: @user1')", 245 | "remove_from_assignees: Call('other')", 246 | "add_to_assignees: Call('user1')", 247 | ] 248 | 249 | 250 | def test_request_review_not_author(settings, gh_pr, gh_repo): 251 | la = LabelAssign( 252 | gh_pr, 253 | gh_repo, 254 | 'comment', 255 | Comment(body='x', user=User(login='commenter'), id=123456), 256 | 'the_auth', 257 | 'org/repo', 258 | RepoConfig(), 259 | settings, 260 | ) 261 | acted, msg = la.request_review() 262 | assert not acted 263 | assert msg == 'Only the PR author @the_auth or reviewers can request a review, not "commenter"' 264 | 265 | 266 | def test_assign_author_not_reviewer(settings, gh_pr, gh_repo): 267 | la = LabelAssign( 268 | gh_pr, 269 | gh_repo, 270 | 'comment', 271 | Comment(body='x', user=User(login='other'), id=123456), 272 | 'user1', 273 | 'org/repo', 274 | RepoConfig(reviewers=['user1', 'user2']), 275 | settings, 276 | ) 277 | assert la.assign_author() == (False, 'Only reviewers "user1", "user2" can assign the author, not "other"') 278 | assert gh_pr.__history__ == [] 279 | 280 | 281 | def test_assign_author_no_reviewers(settings, gh_pr, gh_repo): 282 | la = LabelAssign( 283 | gh_pr, 284 | gh_repo, 285 | 'comment', 286 | Comment(body='x', user=User(login='other'), id=123456), 287 | 'user1', 288 | 'org/repo', 289 | RepoConfig(), 290 | settings, 291 | ) 292 | assert la.assign_author() == (False, 'Only reviewers (no reviewers configured) can assign the author, not "other"') 293 | assert gh_repo.__history__ == [ 294 | "get_collaborators: Call() -> IterBlock('collaborators')", 295 | 'get_collaborators.collaborators: Iter()', 296 | ] 297 | assert gh_pr.__history__ == [] 298 | 299 | 300 | def test_get_collaborators(settings, gh_pr): 301 | gh_repo = AttrBlock( 302 | 'GhRepo', 303 | get_collaborators=CallableBlock( 304 | 'get_collaborators', 305 | IterBlock( 306 | 'collaborators', AttrBlock('Collaborator', login='colab1'), AttrBlock('Collaborator', login='colab2') 307 | ), 308 | ), 309 | ) 310 | la = LabelAssign( 311 | gh_pr, 312 | gh_repo, 313 | 'comment', 314 | Comment(body='x', user=User(login='colab2'), id=123456), 315 | 'user1', 316 | 'org/repo', 317 | RepoConfig(), 318 | settings, 319 | ) 320 | act, msg = la.assign_author() 321 | assert act, msg 322 | assert msg == 'Author user1 successfully assigned to PR, "awaiting author revision" label added' 323 | assert gh_repo.__history__ == [ 324 | ( 325 | "get_collaborators: Call() -> IterBlock('collaborators', AttrBlock('Collaborator', login='colab1'), " 326 | "AttrBlock('Collaborator', login='colab2'))" 327 | ), 328 | ( 329 | "get_collaborators.collaborators: Iter(AttrBlock('Collaborator', login='colab1')," 330 | " AttrBlock('Collaborator', login='colab2'))" 331 | ), 332 | ] 333 | assert gh_pr.__history__ == [ 334 | "get_issue_comment: Call(123456) -> AttrBlock('Comment', create_reaction=CallableBlock('create_reaction'))", 335 | "get_issue_comment.create_reaction: Call('+1')", 336 | "add_to_labels: Call('awaiting author revision')", 337 | "get_labels: Call() -> IterBlock('get_labels', AttrBlock('labels', name='ready for review'))", 338 | "get_labels: Iter(AttrBlock('labels', name='ready for review'))", 339 | "remove_from_labels: Call('ready for review')", 340 | "add_to_assignees: Call('user1')", 341 | "remove_from_assignees: Call('colab1', 'colab2')", 342 | ] 343 | 344 | 345 | def test_change_not_open(settings): 346 | e = PullRequestUpdateEvent( 347 | action='foo', 348 | pull_request=PullRequest(number=123, state='closed', user=User(login='user1'), body=None), 349 | repository=Repository(full_name='user/repo', owner=User(login='user1')), 350 | ) 351 | assert check_change_file(e, settings) == (False, '[Check change file] Pull Request is closed, not open') 352 | 353 | 354 | def test_change_wrong_action(settings): 355 | e = PullRequestUpdateEvent( 356 | action='foo', 357 | pull_request=PullRequest(number=123, state='open', user=User(login='user1'), body=None), 358 | repository=Repository(full_name='user/repo', owner=User(login='user1')), 359 | ) 360 | assert check_change_file(e, settings) == (False, '[Check change file] file change not checked on "foo"') 361 | 362 | 363 | def test_change_user_bot(settings): 364 | e = PullRequestUpdateEvent( 365 | action='opened', 366 | pull_request=PullRequest(number=123, state='open', user=User(login='foobar[bot]'), body=None), 367 | repository=Repository(full_name='user/repo', owner=User(login='user1')), 368 | ) 369 | assert check_change_file(e, settings) == (False, '[Check change file] Pull Request author is a bot') 370 | 371 | 372 | def build_gh(*, pr_files: tuple[AttrBlock, ...] = (), get_contents: CallableBlock = None): 373 | if get_contents is None: 374 | get_contents = CallableBlock('get_contents', raises=GithubException(404, 'Not Found', {})) 375 | 376 | return AttrBlock( 377 | 'Gh', 378 | _requester=AttrBlock( 379 | 'Requestor', _Requester__connection=AttrBlock('Connection', session=CallableBlock('session')) 380 | ), 381 | get_pull=CallableBlock( 382 | 'get_pull', 383 | AttrBlock( 384 | 'PullRequest', 385 | get_commits=CallableBlock( 386 | 'get_commits', 387 | IterBlock('commits', None, AttrBlock('Commit', create_status=CallableBlock('create_status'))), 388 | ), 389 | get_files=CallableBlock('get_files', IterBlock('files', *pr_files)), 390 | base=AttrBlock( 391 | 'Base', ref='foobar', repo=AttrBlock('Repo', full_name='user/repo', get_contents=get_contents) 392 | ), 393 | ), 394 | ), 395 | ) 396 | 397 | 398 | def test_change_no_change_comment(settings, mocker): 399 | e = PullRequestUpdateEvent( 400 | action='opened', 401 | pull_request=PullRequest(number=123, state='open', user=User(login='foobar'), body='skip change file check'), 402 | repository=Repository(full_name='user/repo', owner=User(login='user1')), 403 | ) 404 | gh = build_gh() 405 | mocker.patch('src.logic.prs.get_repo_client', return_value=FakeGhContext(gh)) 406 | act, msg = check_change_file(e, settings) 407 | assert act, msg 408 | assert msg == ( 409 | '[Check change file] status set to "success" with description ' 410 | '"Found "skip change file check" in Pull Request body"' 411 | ) 412 | 413 | 414 | class FakeGhContext: 415 | def __init__(self, gh): 416 | self.gh = gh 417 | 418 | def __enter__(self): 419 | return self.gh 420 | 421 | def __exit__(self, exc_type, exc_val, exc_tb): 422 | pass 423 | 424 | 425 | def test_change_no_change_file(settings, mocker): 426 | e = PullRequestUpdateEvent( 427 | action='opened', 428 | pull_request=PullRequest(number=123, state='open', user=User(login='foobar'), body=None), 429 | repository=Repository(full_name='user/repo', owner=User(login='user1')), 430 | ) 431 | gh = build_gh() 432 | mocker.patch('src.logic.prs.get_repo_client', return_value=FakeGhContext(gh)) 433 | assert check_change_file(e, settings) == ( 434 | True, 435 | '[Check change file] status set to "error" with description "No change file found"', 436 | ) 437 | # debug(gh.__history__) 438 | 439 | 440 | def test_change_file_not_required(settings, mocker): 441 | e = PullRequestUpdateEvent( 442 | action='opened', 443 | pull_request=PullRequest(number=123, state='open', user=User(login='foobar'), body=None), 444 | repository=Repository(full_name='user/repo', owner=User(login='user1')), 445 | ) 446 | config_change_not_required = base64.b64encode(b'[tool.hooky]\nrequire_change_file = false').decode() 447 | get_contents = CallableBlock( 448 | 'get_contents', AttrBlock('File', status='added', content=config_change_not_required, filename='.hooky.toml') 449 | ) 450 | gh = build_gh(get_contents=get_contents) 451 | mocker.patch('src.logic.prs.get_repo_client', return_value=FakeGhContext(gh)) 452 | act, msg = check_change_file(e, settings) 453 | assert not act 454 | assert msg == '[Check change file] change file not required' 455 | 456 | 457 | def test_file_content_match_pr(): 458 | m = re.fullmatch(r'changes/(\d+)-(.+).md', 'changes/123-foobar.md') 459 | pr = PullRequest(number=123, state='open', user=User(login='foobar'), body=None) 460 | status, msg = check_change_file_content(m, 'nothing', pr) 461 | assert status == 'success' 462 | assert msg == 'Change file ID #123 matches the Pull Request' 463 | 464 | 465 | def test_file_content_match_issue(): 466 | m = re.fullmatch(r'changes/(\d+)-(.+).md', 'changes/42-foobar.md') 467 | pr = PullRequest(number=123, state='open', user=User(login='foobar'), body=None) 468 | status, msg = check_change_file_content(m, 'fix #42', pr) 469 | assert status == 'success' 470 | assert msg == 'Change file ID #42 matches Issue closed by the Pull Request' 471 | 472 | 473 | def test_file_content_match_issue_url(): 474 | m = re.fullmatch(r'changes/(\d+)-(.+).md', 'changes/42-foobar.md') 475 | pr = PullRequest(number=123, state='open', user=User(login='foobar'), body=None) 476 | status, msg = check_change_file_content(m, 'closes https://github.com/foo/bar/issues/42', pr) 477 | assert status == 'success' 478 | assert msg == 'Change file ID #42 matches Issue closed by the Pull Request' 479 | 480 | 481 | def test_file_content_error(): 482 | m = re.fullmatch(r'changes/(\d+)-(.+).md', 'changes/42-foobar.md') 483 | pr = PullRequest(number=123, state='open', user=User(login='foobar'), body=None) 484 | status, msg = check_change_file_content(m, '', pr) 485 | assert status == 'error' 486 | assert msg == 'Change file ID does not match Pull Request or closed Issue' 487 | 488 | 489 | def test_file_content_wrong_author(): 490 | m = re.fullmatch(r'changes/(\d+)-(.+).md', 'changes/123-foobar.md') 491 | pr = PullRequest(number=123, state='open', user=User(login='another'), body=None) 492 | status, msg = check_change_file_content(m, 'nothing', pr) 493 | assert status == 'error' 494 | assert msg == 'File "changes/123-foobar.md" has wrong author, expected "another"' 495 | 496 | 497 | @dataclass 498 | class FakeFile: 499 | status: str 500 | filename: str 501 | 502 | 503 | @dataclass 504 | class FakePr: 505 | _files: list[FakeFile] 506 | 507 | def get_files(self): 508 | return self._files 509 | 510 | 511 | @pytest.mark.parametrize( 512 | 'files,expected', 513 | [ 514 | ([], None), 515 | ([FakeFile('added', 'changes/123-foobar.md')], ('123', 'foobar')), 516 | ([FakeFile('added', 'foobar'), FakeFile('added', 'changes/123-foobar.md')], ('123', 'foobar')), 517 | ([FakeFile('added', 'foobar'), FakeFile('removed', 'changes/123-foobar.md')], None), 518 | ], 519 | ids=repr, 520 | ) 521 | def test_find_change_file_ok(files, expected): 522 | m = find_change_file(FakePr(files)) 523 | if expected is None: 524 | assert m is None 525 | else: 526 | assert m.groups() == expected 527 | 528 | 529 | def test_many_reviews(settings, gh_pr, gh_repo, redis_cli): 530 | la = LabelAssign( 531 | gh_pr, 532 | gh_repo, 533 | 'comment', 534 | Comment(body='x', user=User(login='the_author'), id=123456), 535 | 'the_author', 536 | 'org/repo', 537 | RepoConfig(reviewers=['user1', 'user2', 'user3', 'user4']), 538 | settings, 539 | ) 540 | 541 | key = 'reviewer:org/repo' 542 | assert redis_cli.get(key) is None 543 | 544 | assert la.find_reviewer() == 'user1' 545 | assert la.find_reviewer() == 'user2' 546 | assert la.find_reviewer() == 'user3' 547 | assert la.find_reviewer() == 'user4' 548 | assert la.find_reviewer() == 'user1' 549 | assert la.find_reviewer() == 'user2' 550 | 551 | redis_cli.set(key, 39) 552 | assert la.find_reviewer() == 'user4' 553 | assert redis_cli.get(key) == b'40' 554 | assert la.find_reviewer() == 'user1' 555 | assert redis_cli.get(key) == b'1' 556 | assert la.find_reviewer() == 'user2' 557 | assert la.find_reviewer() == 'user3' 558 | assert la.find_reviewer() == 'user4' 559 | assert la.find_reviewer() == 'user1' 560 | 561 | redis_cli.set(key, 43) 562 | assert la.find_reviewer() == 'user4' 563 | assert redis_cli.get(key) == b'4' 564 | 565 | redis_cli.set(key, 44) 566 | assert la.find_reviewer() == 'user1' 567 | assert redis_cli.get(key) == b'1' 568 | -------------------------------------------------------------------------------- /tests/test_marketplace.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import hmac 3 | 4 | from src.settings import Settings 5 | 6 | from .conftest import Client 7 | 8 | 9 | def test_auth_ok_no_data(client: Client, settings: Settings): 10 | request_body = b'{}' 11 | digest = hmac.new(settings.marketplace_webhook_secret.get_secret_value(), request_body, hashlib.sha256).hexdigest() 12 | r = client.post('/marketplace/', data=request_body, headers={'x-hub-signature-256': f'sha256={digest}'}) 13 | assert r.status_code == 202, r.text 14 | assert r.text == 'ok' 15 | 16 | 17 | def test_auth_fails_no_header(client: Client, settings: Settings): 18 | request_body = b'{}' 19 | r = client.post('/marketplace/', data=request_body) 20 | assert r.status_code == 403, r.text 21 | assert r.json() == {'detail': 'Invalid marketplace signature'} 22 | 23 | 24 | def test_auth_fails_wrong_header(client: Client, settings: Settings): 25 | request_body = b'{}' 26 | r = client.post('/marketplace/', data=request_body, headers={'x-hub-signature-256': 'sha256=foobar'}) 27 | assert r.status_code == 403, r.text 28 | assert r.json() == {'detail': 'Invalid marketplace signature'} 29 | 30 | 31 | def test_not_set(client: Client, settings: Settings): 32 | marketplace_webhook_secret = settings.marketplace_webhook_secret 33 | settings.marketplace_webhook_secret = None 34 | try: 35 | request_body = b'{}' 36 | r = client.post('/marketplace/', data=request_body, headers={'x-hub-signature-256': 'sha256=foobar'}) 37 | assert r.status_code == 403, r.text 38 | assert r.json() == {'detail': 'Marketplace secret not set'} 39 | finally: 40 | settings.marketplace_webhook_secret = marketplace_webhook_secret 41 | -------------------------------------------------------------------------------- /tests/test_repo_config.py: -------------------------------------------------------------------------------- 1 | import base64 2 | from dataclasses import dataclass 3 | 4 | import pytest 5 | from github import GithubException 6 | 7 | from src.repo_config import RepoConfig 8 | 9 | 10 | @dataclass 11 | class FakeFileContent: 12 | content: str 13 | 14 | 15 | class FakeRepo: 16 | def __init__(self, content: str | dict[str, str] | None, full_name: str = 'test_org/test_repo'): 17 | if isinstance(content, str) or content is None: 18 | content = {'.hooky.toml:NotSet': content} 19 | self.content = content 20 | self.__calls__ = [] 21 | self.full_name = full_name 22 | 23 | def get_contents(self, path: str, ref: str = 'NotSet') -> FakeFileContent: 24 | content = self.content.get(f'{path}:{ref}') 25 | if content is None: 26 | self.__calls__.append(f'{path}:{ref} -> error') 27 | raise GithubException(404, 'Not found', {}) 28 | else: 29 | self.__calls__.append(f'{path}:{ref} -> success') 30 | return FakeFileContent(base64.b64encode(content.encode()).decode()) 31 | 32 | 33 | @pytest.mark.parametrize( 34 | 'content,log_contains', 35 | [ 36 | ( 37 | None, 38 | 'test_org/test_repo#[default], No ".hooky.toml" or "pyproject.toml" found, using defaults: 404 "Not found"', 39 | ), 40 | ('foobar', 'test_org/test_repo#[default]/.hooky.toml, Invalid config file, using defaults'), 41 | ('x = 4', 'test_org/test_repo#[default]/.hooky.toml, No [tools.hooky] section found, using defaults'), 42 | ( 43 | '[tool.hooky]\nreviewers="foobar"', 44 | 'test_org/test_repo#[default]/.hooky.toml, Error validating hooky config, using defaults', 45 | ), 46 | ], 47 | ) 48 | def test_get_config_invalid(content, log_contains, capsys): 49 | repo = FakeRepo(content) 50 | assert RepoConfig._load_raw(repo) is None 51 | out, err = capsys.readouterr() 52 | assert log_contains in out 53 | 54 | 55 | # language=toml 56 | valid_config = """\ 57 | [tool.hooky] 58 | reviewers = ['foobar', 'barfoo'] 59 | request_update_trigger = 'eggs' 60 | request_review_trigger = 'spam' 61 | awaiting_update_label = 'ham' 62 | awaiting_review_label = 'fries' 63 | no_change_file = 'fake' 64 | require_change_file = false 65 | assignees = ['user_a', 'user_b'] 66 | unconfirmed_label = 'unconfirmed label' 67 | """ 68 | 69 | 70 | def test_get_config_valid(): 71 | repo = FakeRepo(valid_config) 72 | config = RepoConfig._load_raw(repo) 73 | assert config.model_dump() == { 74 | 'reviewers': ['foobar', 'barfoo'], 75 | 'request_update_trigger': 'eggs', 76 | 'request_review_trigger': 'spam', 77 | 'awaiting_update_label': 'ham', 78 | 'awaiting_review_label': 'fries', 79 | 'no_change_file': 'fake', 80 | 'require_change_file': False, 81 | 'assignees': ['user_a', 'user_b'], 82 | 'unconfirmed_label': 'unconfirmed label', 83 | } 84 | 85 | 86 | @dataclass 87 | class FakeBase: 88 | repo: FakeRepo 89 | ref: str 90 | 91 | 92 | @dataclass 93 | class CustomPr: 94 | base: FakeBase 95 | 96 | 97 | def test_cached_default(settings, redis_cli, capsys): 98 | repo = FakeRepo({'pyproject.toml:main': None, 'pyproject.toml:NotSet': valid_config}) 99 | pr = CustomPr(base=FakeBase(repo=repo, ref='main')) 100 | config = RepoConfig.load(pr=pr, settings=settings) 101 | assert config.reviewers == ['foobar', 'barfoo'] 102 | assert repo.__calls__ == [ 103 | '.hooky.toml:main -> error', 104 | 'pyproject.toml:main -> error', 105 | '.hooky.toml:NotSet -> error', 106 | 'pyproject.toml:NotSet -> success', 107 | ] 108 | config = RepoConfig.load(pr=pr, settings=settings) 109 | assert config.reviewers == ['foobar', 'barfoo'] 110 | assert repo.__calls__ == [ 111 | '.hooky.toml:main -> error', 112 | 'pyproject.toml:main -> error', 113 | '.hooky.toml:NotSet -> error', 114 | 'pyproject.toml:NotSet -> success', 115 | '.hooky.toml:main -> error', 116 | 'pyproject.toml:main -> error', 117 | ] 118 | out, err = capsys.readouterr() 119 | assert ( 120 | 'test_org/test_repo#[default]/pyproject.toml, ' 121 | "config: reviewers=['foobar', 'barfoo'] request_update_trigger='eggs'" 122 | ) in out 123 | -------------------------------------------------------------------------------- /tests/test_webhooks.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import hmac 3 | 4 | from foxglove.testing import DummyServer 5 | 6 | from src.settings import Settings 7 | 8 | from .conftest import Client 9 | 10 | 11 | def test_auth_ok_no_data(client: Client, settings: Settings): 12 | request_body = b'{}' 13 | digest = hmac.new(settings.webhook_secret.get_secret_value(), request_body, hashlib.sha256).hexdigest() 14 | r = client.post('/', data=request_body, headers={'x-hub-signature-256': f'sha256={digest}'}) 15 | assert r.status_code == 202, r.text 16 | assert r.text == 'Error parsing request body, no action taken' 17 | 18 | 19 | def test_auth_fails_no_header(client: Client, settings: Settings): 20 | request_body = b'{}' 21 | r = client.post('/', data=request_body) 22 | assert r.status_code == 403, r.text 23 | assert r.json() == {'detail': 'Invalid signature'} 24 | 25 | 26 | def test_auth_fails_wrong_header(client: Client, settings: Settings): 27 | request_body = b'{}' 28 | r = client.post('/', data=request_body, headers={'x-hub-signature-256': 'sha256=foobar'}) 29 | assert r.status_code == 403, r.text 30 | assert r.json() == {'detail': 'Invalid signature'} 31 | 32 | 33 | def test_created(client: Client): 34 | r = client.webhook( 35 | { 36 | 'action': 'created', 37 | 'comment': {'body': 'Hello world', 'user': {'login': 'user1'}, 'id': 123456}, 38 | 'issue': {'user': {'login': 'user1'}, 'number': 123}, 39 | 'repository': {'full_name': 'user1/repo1', 'owner': {'login': 'user1'}}, 40 | } 41 | ) 42 | assert r.status_code == 202, r.text 43 | assert r.text == 'Ignoring event action "created", no action taken' 44 | 45 | 46 | def test_please_review(dummy_server: DummyServer, client: Client): 47 | r = client.webhook( 48 | { 49 | 'action': 'created', 50 | 'comment': {'body': 'Hello world, please review', 'user': {'login': 'user1'}, 'id': 123456}, 51 | 'issue': { 52 | 'pull_request': {'url': 'https://api.github.com/repos/user1/repo1/pulls/123'}, 53 | 'user': {'login': 'user1'}, 54 | 'number': 123, 55 | }, 56 | 'repository': {'full_name': 'user1/repo1', 'owner': {'login': 'user1'}}, 57 | } 58 | ) 59 | assert r.status_code == 200, r.text 60 | assert r.text == ( 61 | '[Label and assign] @user2 successfully assigned to PR as reviewer, "ready for review" label added' 62 | ) 63 | assert dummy_server.log == [ 64 | 'GET /repos/user1/repo1/installation > 200', 65 | 'POST /app/installations/654321/access_tokens > 200', 66 | 'GET /repos/user1/repo1 > 200', 67 | 'GET /repos/user1/repo1/pulls/123 > 200', 68 | 'GET /repos/user1/repo1/contents/.hooky.toml?ref=main > 404', 69 | 'GET /repos/user1/repo1/contents/pyproject.toml?ref=main > 200', 70 | 'GET /repos/user1/repo1/issues/comments/123456 > 200', 71 | 'POST /repos/user1/repo1/comments/123456/reactions > 200', 72 | 'POST /repos/user1/repo1/issues/123/labels > 200', 73 | 'GET /repos/user1/repo1/issues/123/labels > 200', 74 | 'PATCH /repos/user1/repo1/pulls/123 > 200', 75 | 'DELETE /repos/user1/repo1/issues/123/assignees > 200', 76 | 'POST /repos/user1/repo1/issues/123/assignees > 200', 77 | ] 78 | 79 | 80 | def test_please_review_no_reviews(dummy_server: DummyServer, client: Client): 81 | r = client.webhook( 82 | { 83 | 'action': 'created', 84 | 'comment': {'body': 'Hello world, please review', 'user': {'login': 'user1'}, 'id': 123456}, 85 | 'issue': { 86 | 'pull_request': {'url': 'https://api.github.com/repos/user1/repo1/pulls/123'}, 87 | 'user': {'login': 'user1'}, 88 | 'number': 123, 89 | }, 90 | 'repository': {'full_name': 'foobar/no_reviewers', 'owner': {'login': 'foobar'}}, 91 | } 92 | ) 93 | assert r.status_code == 200, r.text 94 | assert r.text == ( 95 | '[Label and assign] @foobar successfully assigned to PR as reviewer, "ready for review" label added' 96 | ) 97 | assert dummy_server.log == [ 98 | 'GET /repos/foobar/no_reviewers/installation > 200', 99 | 'POST /app/installations/654321/access_tokens > 200', 100 | 'GET /repos/foobar/no_reviewers > 200', 101 | 'GET /repos/foobar/no_reviewers/pulls/123 > 200', 102 | 'GET /repos/foobar/no_reviewers/contents/.hooky.toml?ref=main > 404', 103 | 'GET /repos/foobar/no_reviewers/contents/pyproject.toml?ref=main > 404', 104 | 'GET /repos/foobar/no_reviewers/contents/.hooky.toml > 404', 105 | 'GET /repos/foobar/no_reviewers/contents/pyproject.toml > 404', 106 | 'GET /repos/foobar/no_reviewers/collaborators > 200', 107 | 'GET /repos/foobar/no_reviewers/issues/comments/123456 > 200', 108 | 'POST /repos/foobar/no_reviewers/comments/123456/reactions > 200', 109 | 'POST /repos/foobar/no_reviewers/issues/123/labels > 200', 110 | 'GET /repos/foobar/no_reviewers/issues/123/labels > 200', 111 | 'PATCH /repos/foobar/no_reviewers/pulls/123 > 200', 112 | 'DELETE /repos/foobar/no_reviewers/issues/123/assignees > 200', 113 | 'POST /repos/foobar/no_reviewers/issues/123/assignees > 200', 114 | ] 115 | 116 | 117 | def test_comment_please_update(dummy_server: DummyServer, client: Client): 118 | r = client.webhook( 119 | { 120 | 'action': 'created', 121 | 'comment': {'body': 'Hello world, please update', 'user': {'login': 'user1'}, 'id': 123456}, 122 | 'issue': { 123 | 'pull_request': {'url': 'https://api.github.com/repos/user1/repo1/pulls/123'}, 124 | 'user': {'login': 'user1'}, 125 | 'number': 123, 126 | }, 127 | 'repository': {'full_name': 'user1/repo1', 'owner': {'login': 'user1'}}, 128 | } 129 | ) 130 | assert r.status_code == 200, r.text 131 | assert r.text == ( 132 | '[Label and assign] Author user1 successfully assigned to PR, "awaiting author revision" label added' 133 | ) 134 | assert dummy_server.log == [ 135 | 'GET /repos/user1/repo1/installation > 200', 136 | 'POST /app/installations/654321/access_tokens > 200', 137 | 'GET /repos/user1/repo1 > 200', 138 | 'GET /repos/user1/repo1/pulls/123 > 200', 139 | 'GET /repos/user1/repo1/contents/.hooky.toml?ref=main > 404', 140 | 'GET /repos/user1/repo1/contents/pyproject.toml?ref=main > 200', 141 | 'GET /repos/user1/repo1/issues/comments/123456 > 200', 142 | 'POST /repos/user1/repo1/comments/123456/reactions > 200', 143 | 'POST /repos/user1/repo1/issues/123/labels > 200', 144 | 'GET /repos/user1/repo1/issues/123/labels > 200', 145 | 'POST /repos/user1/repo1/issues/123/assignees > 200', 146 | 'DELETE /repos/user1/repo1/issues/123/assignees > 200', 147 | ] 148 | 149 | 150 | def test_review_please_update(dummy_server: DummyServer, client: Client): 151 | r = client.webhook( 152 | { 153 | 'review': {'body': 'Hello world', 'user': {'login': 'user1'}, 'state': 'comment'}, 154 | 'pull_request': { 155 | 'number': 123, 156 | 'user': {'login': 'user1'}, 157 | 'state': 'open', 158 | 'pull_request': 'this is the body', 159 | }, 160 | 'repository': {'full_name': 'user1/repo1', 'owner': {'login': 'user1'}}, 161 | } 162 | ) 163 | assert r.status_code == 202, r.text 164 | assert r.text == ( 165 | "[Label and assign] neither 'please update' nor 'please review' found in comment body, no action taken" 166 | ) 167 | assert dummy_server.log == [ 168 | 'GET /repos/user1/repo1/installation > 200', 169 | 'POST /app/installations/654321/access_tokens > 200', 170 | 'GET /repos/user1/repo1 > 200', 171 | 'GET /repos/user1/repo1/pulls/123 > 200', 172 | 'GET /repos/user1/repo1/contents/.hooky.toml?ref=main > 404', 173 | 'GET /repos/user1/repo1/contents/pyproject.toml?ref=main > 200', 174 | ] 175 | 176 | 177 | def test_review_no_body(dummy_server: DummyServer, client: Client): 178 | r = client.webhook( 179 | { 180 | 'review': {'body': None, 'user': {'login': 'user1'}, 'state': 'comment'}, 181 | 'pull_request': { 182 | 'number': 123, 183 | 'user': {'login': 'user1'}, 184 | 'state': 'open', 185 | 'pull_request': 'this is the body', 186 | }, 187 | 'repository': {'full_name': 'user1/repo1', 'owner': {'login': 'user1'}}, 188 | } 189 | ) 190 | assert r.status_code == 202, r.text 191 | assert r.text == '[Label and assign] review has no body, no action taken' 192 | assert dummy_server.log == [] 193 | 194 | 195 | def test_change_file(dummy_server: DummyServer, client: Client): 196 | r = client.webhook( 197 | { 198 | 'action': 'opened', 199 | 'pull_request': {'number': 123, 'user': {'login': 'foobar'}, 'state': 'open', 'body': 'this is a new PR'}, 200 | 'repository': {'full_name': 'user1/repo1', 'owner': {'login': 'user1'}}, 201 | } 202 | ) 203 | assert r.status_code == 200, r.text 204 | assert r.text == ( 205 | '[Check change file] status set to "success" with description "Change file ID #123 matches the Pull Request"' 206 | ) 207 | assert dummy_server.log == [ 208 | 'GET /repos/user1/repo1/installation > 200', 209 | 'POST /app/installations/654321/access_tokens > 200', 210 | 'GET /repos/user1/repo1 > 200', 211 | 'GET /repos/user1/repo1/pulls/123 > 200', 212 | 'GET /repos/user1/repo1/contents/.hooky.toml?ref=main > 404', 213 | 'GET /repos/user1/repo1/contents/pyproject.toml?ref=main > 200', 214 | 'GET /repos/user1/repo1/pulls/123/files > 200', 215 | 'GET /repos/user1/repo1/pulls/123/commits > 200', 216 | 'POST /repos/user1/repo1/statuses/abc > 200', 217 | ] 218 | 219 | 220 | def test_issue_opened(dummy_server: DummyServer, client: Client): 221 | r = client.webhook( 222 | { 223 | 'action': 'opened', 224 | 'issue': {'user': {'login': 'user1'}, 'number': 123}, 225 | 'repository': {'full_name': 'user1/repo1', 'owner': {'login': 'user1'}}, 226 | } 227 | ) 228 | assert r.status_code == 200, r.text 229 | assert r.text == '@user3 successfully assigned to issue, "unconfirmed" label added' 230 | assert dummy_server.log == [ 231 | 'GET /repos/user1/repo1/installation > 200', 232 | 'POST /app/installations/654321/access_tokens > 200', 233 | 'GET /repos/user1/repo1 > 200', 234 | 'GET /repos/user1/repo1/issues/123 > 200', 235 | 'GET /repos/user1/repo1 > 200', 236 | 'GET /repos/user1/repo1/contents/.hooky.toml > 404', 237 | 'GET /repos/user1/repo1/contents/pyproject.toml > 200', 238 | 'POST /repos/user1/repo1/issues/123/assignees > 200', 239 | 'POST /repos/user1/repo1/issues/123/labels > 200', 240 | ] 241 | 242 | 243 | def test_issue_opened_by_assignee(dummy_server: DummyServer, client: Client): 244 | r = client.webhook( 245 | { 246 | 'action': 'opened', 247 | 'issue': {'user': {'login': 'user3'}, 'number': 123}, 248 | 'repository': {'full_name': 'user1/repo1', 'owner': {'login': 'user1'}}, 249 | } 250 | ) 251 | assert r.status_code == 202, r.text 252 | assert r.text == '@user3 is in repo assignees list, doing nothing, no action taken' 253 | assert dummy_server.log == [ 254 | 'GET /repos/user1/repo1/installation > 200', 255 | 'POST /app/installations/654321/access_tokens > 200', 256 | 'GET /repos/user1/repo1 > 200', 257 | 'GET /repos/user1/repo1/issues/123 > 200', 258 | 'GET /repos/user1/repo1 > 200', 259 | 'GET /repos/user1/repo1/contents/.hooky.toml > 404', 260 | 'GET /repos/user1/repo1/contents/pyproject.toml > 200', 261 | ] 262 | 263 | 264 | def test_issue_reopened(dummy_server: DummyServer, client: Client): 265 | r = client.webhook( 266 | { 267 | 'action': 'reopened', 268 | 'issue': {'user': {'login': 'user1'}, 'number': 123}, 269 | 'repository': {'full_name': 'user1/repo1', 'owner': {'login': 'user1'}}, 270 | } 271 | ) 272 | assert r.status_code == 202, r.text 273 | assert r.text == 'Ignoring event action "reopened", no action taken' 274 | assert dummy_server.log == [] 275 | --------------------------------------------------------------------------------