├── .flake8 ├── .github ├── dependabot.yml ├── release.yml └── workflows │ ├── build.yml │ └── connect.py ├── .gitignore ├── .mypy.ini ├── .pre-commit-config.yaml ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── Procfile ├── README.md ├── meeseeksdev ├── __init__.py ├── __main__.py ├── commands.py ├── meeseeksbox │ ├── __init__.py │ ├── commands.py │ ├── core.py │ ├── scopes.py │ └── utils.py └── tests │ ├── __init__.py │ ├── test_misc.py │ └── test_webhook.py ├── package.json ├── requirements-test.txt ├── requirements.txt └── runtime.txt /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | enable-extensions = G 4 | extend-ignore = 5 | G200, G202, 6 | # E501 line too long 7 | E501 8 | # E741 ambiguous variable name 9 | E741 10 | # E266 too many leading '#' for block commen 11 | E266 12 | # E731 do not assign a lambda expression, use a def 13 | E731 14 | # E203 whitespace before ':' 15 | E203 16 | # E231 whitespace after ':' 17 | E231 18 | # E221 multiple spaces before operator 19 | E221 20 | # E222 multiple spaces after operator 21 | E222 22 | per-file-ignores = 23 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | groups: 8 | actions: 9 | patterns: 10 | - "*" 11 | labels: 12 | - "github-actions" 13 | - "dependabot" 14 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | authors: 4 | - dependabot 5 | - pre-commit-ci 6 | - meeseeksmachine 7 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: 3 | push: 4 | branches: ["main"] 5 | pull_request: 6 | workflow_dispatch: 7 | 8 | concurrency: 9 | group: ci-${{ github.ref }} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | # Run "pre-commit run --all-files --hook-stage=manual" 14 | pre-commit: 15 | runs-on: ubuntu-latest 16 | timeout-minutes: 5 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: actions/setup-python@v5 20 | - uses: pre-commit/action@v3.0.1 21 | with: 22 | extra_args: --all-files --hook-stage=manual 23 | - name: Help message if pre-commit fail 24 | if: ${{ failure() }} 25 | run: | 26 | echo "You can install pre-commit hooks to automatically run formatting" 27 | echo "on each commit with:" 28 | echo " pre-commit install" 29 | echo "or you can run by hand on staged files with" 30 | echo " pre-commit run" 31 | echo "or after-the-fact on already committed files with" 32 | echo " pre-commit run --all-files --hook-stage=manual" 33 | 34 | build: 35 | runs-on: ubuntu-latest 36 | timeout-minutes: 20 37 | strategy: 38 | fail-fast: false 39 | matrix: 40 | python-version: ["3.10"] 41 | steps: 42 | - name: Checkout 43 | uses: actions/checkout@v4 44 | - name: Base Setup 45 | uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 46 | - name: Install the Python dependencies 47 | run: | 48 | pip install -r requirements-test.txt 49 | - name: Run the tests 50 | run: python -m pytest -vv -raXs || python -m pytest -vv -raXs --lf 51 | - name: Start the App 52 | env: 53 | GITHUB_INTEGRATION_ID: 812 54 | GITHUB_BOT_NAME: meeseeksdev-test 55 | WEBHOOK_SECRET: foo 56 | PERSONAL_ACCOUNT_NAME: snuffy 57 | PERSONAL_ACCOUNT_TOKEN: token 58 | TESTING: true 59 | run: | 60 | set -eux 61 | python -m meeseeksdev & 62 | TASK_PID=$! 63 | # Make sure the task is running 64 | ps -p $TASK_PID 65 | # Connect to the task 66 | python .github/workflows/connect.py 67 | # Kill the task 68 | kill $TASK_PID 69 | wait $TASK_PID 70 | -------------------------------------------------------------------------------- /.github/workflows/connect.py: -------------------------------------------------------------------------------- 1 | import errno 2 | import time 3 | from urllib import request 4 | from urllib.error import URLError 5 | 6 | t0 = time.time() 7 | found = False 8 | url = "http://localhost:5000" 9 | 10 | while (time.time() - t0) < 60: 11 | try: 12 | request.urlopen(url) 13 | found = True 14 | break 15 | except URLError as e: 16 | if e.reason.errno == errno.ECONNREFUSED: # type:ignore 17 | time.sleep(1) 18 | continue 19 | raise 20 | 21 | 22 | if not found: 23 | raise ValueError(f"Could not connect to {url}") 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | -------------------------------------------------------------------------------- /.mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | check_untyped_defs = true 3 | disallow_incomplete_defs = true 4 | no_implicit_optional = true 5 | pretty = true 6 | show_error_context = true 7 | show_error_codes = true 8 | strict_equality = true 9 | strict_optional = true 10 | warn_no_return = true 11 | warn_return_any = true 12 | warn_unused_configs = true 13 | warn_unused_ignores = true 14 | warn_redundant_casts = true 15 | 16 | [mypy-keen] 17 | ignore_missing_imports = True 18 | 19 | [mypy-there] 20 | ignore_missing_imports = True 21 | 22 | [mypy-yieldbreaker] 23 | ignore_missing_imports = True 24 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_language_version: 2 | node: system 3 | 4 | repos: 5 | - repo: https://github.com/pre-commit/pre-commit-hooks 6 | rev: v4.3.0 7 | hooks: 8 | - id: end-of-file-fixer 9 | - id: check-case-conflict 10 | - id: check-executables-have-shebangs 11 | - id: requirements-txt-fixer 12 | - id: check-added-large-files 13 | - id: check-case-conflict 14 | - id: check-toml 15 | - id: check-yaml 16 | - id: debug-statements 17 | - id: forbid-new-submodules 18 | - id: check-builtin-literals 19 | - id: trailing-whitespace 20 | 21 | - repo: https://github.com/psf/black 22 | rev: 22.10.0 23 | hooks: 24 | - id: black 25 | args: ["--line-length", "100"] 26 | 27 | - repo: https://github.com/PyCQA/isort 28 | rev: 5.12.0 29 | hooks: 30 | - id: isort 31 | files: \.py$ 32 | args: [--profile=black] 33 | 34 | - repo: https://github.com/pre-commit/mirrors-mypy 35 | rev: v0.991 36 | hooks: 37 | - id: mypy 38 | args: ["--config-file", ".mypy.ini"] 39 | additional_dependencies: [types-requests, types-PyYAML, types-mock, tornado, black, pytest, gitpython, pyjwt] 40 | 41 | - repo: https://github.com/abravalheri/validate-pyproject 42 | rev: v0.10.1 43 | hooks: 44 | - id: validate-pyproject 45 | stages: [manual] 46 | 47 | - repo: https://github.com/executablebooks/mdformat 48 | rev: 0.7.16 49 | hooks: 50 | - id: mdformat 51 | 52 | - repo: https://github.com/asottile/pyupgrade 53 | rev: v3.2.2 54 | hooks: 55 | - id: pyupgrade 56 | args: [--py38-plus] 57 | 58 | - repo: https://github.com/PyCQA/doc8 59 | rev: v1.0.0 60 | hooks: 61 | - id: doc8 62 | args: [--max-line-length=200] 63 | exclude: docs/source/other/full-config.rst 64 | stages: [manual] 65 | 66 | - repo: https://github.com/PyCQA/flake8 67 | rev: 6.0.0 68 | hooks: 69 | - id: flake8 70 | additional_dependencies: 71 | ["flake8-bugbear==22.6.22", "flake8-implicit-str-concat==0.2.0"] 72 | stages: [manual] 73 | 74 | - repo: https://github.com/python-jsonschema/check-jsonschema 75 | rev: 0.19.2 76 | hooks: 77 | - id: check-github-workflows 78 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Test Deployment 4 | 5 | - Install the [Heroku CLI](https://devcenter.heroku.com/articles/heroku-cli#download-and-install). 6 | 7 | You will need to have an account in Heroku. 8 | 9 | Log in to Heroku: 10 | 11 | ```bash 12 | heroku login 13 | ``` 14 | 15 | If creating, run: 16 | 17 | ```bash 18 | heroku create meeseeksdev-$USER 19 | ``` 20 | 21 | Otherwise, run: 22 | 23 | ```bash 24 | heroku git:remote -a meeseeksdev-$USER 25 | ``` 26 | 27 | Then run: 28 | 29 | ```bash 30 | git push heroku $(git rev-parse --abbrev-ref HEAD):master 31 | heroku open 32 | ``` 33 | 34 | To view the logs in a terminal window, use: 35 | 36 | ```bash 37 | heroku logs --app meeseeksdev=$USER -t 38 | ``` 39 | 40 | ### GitHub App Configuration 41 | 42 | Create a GitHub App for testing on your account 43 | Homepage URL: https://meeseeksdev-$USER.herokuapp.com/ 44 | Webhook URL: https://meeseeksdev-$USER.herokuapp.com/webhook 45 | Webhook Secret: Set and store as WEBHOOK_SECRET env variable 46 | Private Key: Generate and store as B64KEY env variable 47 | 48 | Grant write access to content, issues, and users. 49 | Subscribe to Issue and Issue Comment Events. 50 | 51 | Install the application on your user account, at least in your MeeseeksDev fork. 52 | 53 | ### Heroku Configuration 54 | 55 | You will need a Github token with access to cancel builds. This 56 | 57 | This needs to be setup on the [Heroku Application settings](https://dashboard.heroku.com/apps/jupyterlab-bot/settings) 58 | 59 | On the `Config Vars`. section set the following keys:: 60 | 61 | ``` 62 | GITHUB_INTEGRATION_ID="" 63 | B64KEY="" 64 | GITHUB_BOT_NAME="" 65 | WEBHOOK_SECRET="" 66 | PERSONAL_ACCOUNT_NAME="" 67 | PERSONAL_ACCOUNT_TOKEN="" 68 | ``` 69 | 70 | ### Code Styling 71 | 72 | `MeeseeksDev` has adopted automatic code formatting so you shouldn't 73 | need to worry too much about your code style. 74 | As long as your code is valid, 75 | the pre-commit hook should take care of how it should look. 76 | `pre-commit` and its associated hooks will automatically be installed when 77 | you run `pip install -e ".[test]"` 78 | 79 | To install `pre-commit` manually, run the following:: 80 | 81 | ```shell 82 | pip install pre-commit 83 | pre-commit install 84 | ``` 85 | 86 | You can invoke the pre-commit hook by hand at any time with: 87 | 88 | ```shell 89 | pre-commit run 90 | ``` 91 | 92 | which should run any autoformatting on your code 93 | and tell you about any errors it couldn't fix automatically. 94 | You may also install [black integration](https://github.com/psf/black#editor-integration) 95 | into your text editor to format code automatically. 96 | 97 | If you have already committed files before setting up the pre-commit 98 | hook with `pre-commit install`, you can fix everything up using 99 | `pre-commit run --all-files`. You need to make the fixing commit 100 | yourself after that. 101 | 102 | Some of the hooks only run on CI by default, but you can invoke them by 103 | running with the `--hook-stage manual` argument. 104 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Matthias Bussonnier 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | 3 | .PHONY: clean 4 | 5 | clean: 6 | find . -name '*.pyc' -delete 7 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: python -m meeseeksdev 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MeeseeksBox 2 | 3 | A base for stateless GitHub Bot,and one hosted implementation thereof. 4 | 5 | See what is a [Meeseeks and a MeeseeksBox](https://www.youtube.com/watch?v=qUYvIAP3qQk). 6 | 7 | See [usage statistics](https://meeseeksbox.github.io/). 8 | 9 | ## Hosted for you 10 | 11 | We host MeeseeksBox(es) and will expose them as GitHub Integrations so you don't 12 | have to host and run your own. You can if you want, it should be pretty 13 | simple. 14 | 15 | The advantage of having one and only one box, is to do cross repository 16 | operations (and fix security bugs). 17 | 18 | The drawback is if there is a security issue, then we're screwed. 19 | 20 | ## Activate on your Repo 21 | 22 | 1. Head [there](https://github.com/apps/lumberbot-app/) and activate 23 | MeeseeksDev on repos you have access to. 24 | 25 | 1. On a repository with MeeseeksDev installed say: `@MeeseeksDev Hello` to be 26 | sure MeeseeksDev is correctly installed. 27 | 28 | 1. Enjoy 29 | 30 | You might also want to tell your CI-integration (like travis-ci) **not** to test the **push** __and__ **the merge**. 31 | To do so use: 32 | 33 | ``` 34 | branches: 35 | except: 36 | - /^auto-backport-of-pr-[0-9]+$/ 37 | ``` 38 | 39 | ## per-repository configuration 40 | 41 | If you want per-repository configuration, create a `.meeseeksdev.yml` file at 42 | the root of the repository. For now this file allow you to give fine-grained 43 | permissions to users. 44 | 45 | ``` 46 | users: 47 | : 48 | can: 49 | - 50 | - 51 | - ... 52 | 53 | can: 54 | - ... 55 | ``` 56 | 57 | This will allow `` to ask `@meeseeksdev` to perform above commands. 58 | The conf file is the one that sits on the repository default branch (usually 59 | `master`). 60 | 61 | ## What can a MeeseeksBox do ? 62 | 63 | Comment on a Pr or issue. 64 | 65 | You _may_ put multiple commands, one per line. 66 | 67 | MrMeeseeks _may_ not like what you ask, and just ignore you. 68 | 69 | ### @MeeseeksDev hello 70 | 71 | Respond with 72 | 73 | > Hello {user} look at me, I'm Mr Meeseeks 74 | 75 | To test whether a Meeseeks understand you. 76 | 77 | ### @MeeseeksDev backport \[to\] {branch} 78 | 79 | If issued from a PR which is merged, attempt to backport (cherry-pick the 80 | merge commit) on an older branch and submit a PR with this backport (on said branch) 81 | 82 | Apply origin-pr labels and milestone to backport. 83 | 84 | - No option to push directly (yet), if implemented should apply only with clean backport. 85 | - Investigate what to do in case of conflict 86 | - likely commit with conflict, and let maintainers resolve conflict 87 | 88 | Repo admins only 89 | 90 | Note: Cloning can take a long-time. So expect MrMeeseeks to be busy while this 91 | happen. Also heroku has a 2min deadline and other limitations, so MrMeeseeks can 92 | likely be killed. I haven't implemented a queue yet. 93 | 94 | ### @MeeseeksDev black 95 | 96 | If issued from a PR, will apply black to commits made in this PR and push 97 | the updated commits. 98 | 99 | You can also use "blackify" as an alias. 100 | 101 | Repo admins only, we plan to make it available to PR authors as well. 102 | 103 | MeeseeksDev Bot needs to be installed on the PR source repository for this to work. 104 | If it's not it will ask you to do so. 105 | 106 | ### @MeeseeksDev pre-commit 107 | 108 | If issued from a PR, will apply pre-commit to this PR and push 109 | a commit with the changes made. If no changes are made, or the changes 110 | cannot be automatically fixed, it will show a comment in the PR and bail. 111 | 112 | You can also use "precommit" as an alias. 113 | 114 | Repo admins only, we plan to make it available to PR authors as well. 115 | 116 | MeeseeksDev Bot needs to be installed on the PR source repository for this to work. 117 | If it's not it will ask you to do so. 118 | 119 | ### @MeeseeksDev pep8ify 120 | 121 | (in progress) 122 | 123 | If issued from a PR, will apply autopep8 to the current lines changed by this 124 | PR, and push an extra commit to it that fixes pep8. 125 | 126 | Code in progress and due to GitHub API limitation only works if MeeseeksDev 127 | also available on Source repo of the PR. 128 | 129 | Repo admins only, plan to make it available to PR author as well. 130 | 131 | MeeseeksDev Bot need to be installed on the PR source repository for this to work. 132 | If it's not it will ask you to do so. 133 | 134 | ### @MeeseeksDev migrate \[to\] {target org/repo} 135 | 136 | Needs MeeseeksBox to be installed on both current and target repo. Command 137 | issuer to be admin on both. 138 | 139 | MeeseeksDev will open a similar issue, replicate all comments with links to 140 | first, migrate labels (if possible). 141 | 142 | ### @MeeseeksDev close 143 | 144 | Close the issue. Useful when replying by mail 145 | 146 | ### @MeeseeksDev open 147 | 148 | Reopen the issue. 149 | 150 | ### @MeeseeksDev tag {comma, separated, case sensitive, taglist} 151 | 152 | Tag with said tags if availlable (comma separated, need to be exact match) 153 | 154 | ### @MeeseeksDev untag {comma, separated, case sensitive, taglist} 155 | 156 | Remove said tags if present (comma separated, need to be exact match) 157 | 158 | ### @MeeseeksDev merge \[merge|squash|rebase\] 159 | 160 | Issuer needs at least write permission. 161 | 162 | If Mergeable, Merge current PR using said methods (`merge` if no arguments) 163 | 164 | ## Command Extras 165 | 166 | You can be polite and use "please" with any of the commands, e.g. "@Meeseeksdev please close". 167 | 168 | You can optionally use the word "run" in the command, e.g. "@Meeseeksdev please run pre-commit". 169 | 170 | ## Simple extension. 171 | 172 | Most extension and new command for the MeeseeksBox are only one function, for 173 | example here is how to let everyone request the zen of Python: 174 | 175 | ````python 176 | from textwrap import dedent 177 | 178 | @everyone 179 | def zen(*, session, payload, arguments): 180 | comment_url = payload['issue']['comments_url'] 181 | session.post_comment(comment_url, 182 | dedent( 183 | """ 184 | Zen of Python ([pep 20](https://www.python.org/dev/peps/pep-0020/)) 185 | ``` 186 | >>> import this 187 | Beautiful is better than ugly. 188 | Sparse is better than dense. 189 | .... 190 | Although never is often better than *right* now. 191 | Namespaces are one honking great idea -- let's do more of those! 192 | ``` 193 | """ 194 | )) 195 | ```` 196 | 197 | The `session` object is authenticated with the repository the command came from. 198 | If you need to authenticate with another repository with MeeseeksBox installed `yield` the `org/repo` slug. 199 | 200 | ```python 201 | @admin 202 | def foo(*, session, payload, argument): 203 | other_session = yield 'MeeseeksBox/MeeseeksBox' 204 | if other_session: 205 | print('you are allowed to access MeeseeksBox/MeeseeksBox') 206 | other_session.do_stuff() 207 | else: 208 | session.post_comment("Sorry Jerry you are not allowed to do that.") 209 | ``` 210 | 211 | # Why do you request so much permission ? 212 | 213 | GitHub API does not allow to change permissions once given (yet). We don't want 214 | you to go though the process of reinstalling all integrations. 215 | 216 | We would like to request less permission if necessary. 217 | 218 | # Setup. 219 | 220 | See CONTIBUTING.md for for information. 221 | 222 | # Warnings 223 | 224 | This is still alpha software, user and org that can use it are still hardcoded. 225 | If you want access open an issue for me to allowlist your org and users. 226 | 227 | Because of GitHub API limitation, MeeseeksBox can not yet make the distinction 228 | between read-only and read-write collaborators. 229 | 230 | ## Addons 231 | 232 | ``` 233 | heroku addons:create keen 234 | ``` 235 | 236 | ## Changelog 237 | 238 | - 2017-10-31: Backport now support squash-merge 239 | -------------------------------------------------------------------------------- /meeseeksdev/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Meeseeksbox main app module 3 | """ 4 | import base64 5 | import os 6 | import signal 7 | 8 | from .commands import close, help_make, merge, migrate_issue_request 9 | from .commands import open as _open 10 | from .commands import ready 11 | from .meeseeksbox.commands import ( 12 | black_suggest, 13 | blackify, 14 | debug, 15 | party, 16 | precommit, 17 | replyuser, 18 | safe_backport, 19 | say, 20 | tag, 21 | untag, 22 | zen, 23 | ) 24 | from .meeseeksbox.core import Config, MeeseeksBox 25 | 26 | org_allowlist = [ 27 | "MeeseeksBox", 28 | "Jupyter", 29 | "IPython", 30 | "JupyterLab", 31 | "jupyter-server", 32 | "jupyter-widgets", 33 | "voila-dashboards", 34 | "jupyter-xeus", 35 | "Carreau", 36 | "matplotlib", 37 | "scikit-learn", 38 | "pandas-dev", 39 | "scikit-image", 40 | ] 41 | 42 | usr_denylist: list = [] 43 | 44 | usr_allowlist = [ 45 | "Carreau", 46 | "gnestor", 47 | "ivanov", 48 | "fperez", 49 | "mpacer", 50 | "minrk", 51 | "takluyver", 52 | "sylvaincorlay", 53 | "ellisonbg", 54 | "blink1073", 55 | "damianavila", 56 | "jdfreder", 57 | "rgbkrk", 58 | "tacaswell", 59 | "willingc", 60 | "jhamrick", 61 | "lgpage", 62 | "jasongrout", 63 | "ian-r-rose", 64 | "kevin-bates", 65 | # matplotlib people 66 | "tacaswell", 67 | "QuLogic", 68 | "anntzer", 69 | "NelleV", 70 | "dstansby", 71 | "efiring", 72 | "choldgraf", 73 | "dstansby", 74 | "dopplershift", 75 | "jklymak", 76 | "weathergod", 77 | "timhoffm", 78 | # pandas-dev 79 | "jreback", 80 | "jorisvandenbossche", 81 | "gfyoung", 82 | "TomAugspurger", 83 | ] 84 | 85 | # https://github.com/integrations/meeseeksdev/installations/new 86 | # already ? https://github.com/organizations/MeeseeksBox/settings/installations/4268 87 | # https://github.com/integration/meeseeksdev 88 | 89 | 90 | def load_config_from_env(): 91 | """ 92 | Load the configuration, for now stored in the environment 93 | """ 94 | config: dict = {} 95 | 96 | integration_id_str = os.environ.get("GITHUB_INTEGRATION_ID") 97 | botname = os.environ.get("GITHUB_BOT_NAME", None) 98 | 99 | if not integration_id_str: 100 | raise ValueError("Please set GITHUB_INTEGRATION_ID") 101 | 102 | if not botname: 103 | raise ValueError("Need to set a botname") 104 | if "@" in botname: 105 | print("Don't include @ in the botname !") 106 | 107 | botname = botname.replace("@", "") 108 | at_botname = "@" + botname 109 | integration_id = int(integration_id_str) 110 | 111 | if "B64KEY" in os.environ: 112 | config["key"] = base64.b64decode(bytes(os.environ["B64KEY"], "ASCII")) 113 | elif "TESTING" not in os.environ: 114 | raise ValueError("Missing B64KEY environment variable") 115 | config["botname"] = botname 116 | config["at_botname"] = at_botname 117 | config["integration_id"] = integration_id 118 | config["webhook_secret"] = os.environ.get("WEBHOOK_SECRET") 119 | config["port"] = int(os.environ.get("PORT", 5000)) 120 | # config option to forward requests as-is to a test server. 121 | config["forward_staging_url"] = os.environ.get("FORWARD_STAGING_URL", "") 122 | print("saw config forward", config["forward_staging_url"]) 123 | 124 | # Despite their names, this are not __your__ account, but an account created 125 | # for some functionalities of mr-meeseeks. Indeed, github does not allow 126 | # cross repositories pull-requests with Applications, so I use a personal 127 | # account just for that. 128 | config["personal_account_name"] = os.environ.get("PERSONAL_ACCOUNT_NAME") 129 | config["personal_account_token"] = os.environ.get("PERSONAL_ACCOUNT_TOKEN") 130 | 131 | return Config(**config).validate() 132 | 133 | 134 | green = "\x1b[0;32m" 135 | yellow = "\x1b[0;33m" 136 | blue = "\x1b[0;34m" 137 | red = "\x1b[0;31m" 138 | normal = "\x1b[0m" 139 | 140 | 141 | def main(): 142 | print(blue + "====== (re) starting ======" + normal) 143 | config = load_config_from_env() 144 | 145 | app_v = os.environ.get("HEROKU_RELEASE_VERSION", None) 146 | if app_v: 147 | import keen 148 | 149 | try: 150 | keen.add_event("deploy", {"version": int(app_v[1:])}) 151 | except Exception as e: 152 | print(e) 153 | config.org_allowlist = org_allowlist + [o.lower() for o in org_allowlist] 154 | config.user_allowlist = usr_allowlist + [u.lower() for u in usr_allowlist] 155 | config.user_denylist = usr_denylist + [u.lower() for u in usr_denylist] 156 | commands = { 157 | "hello": replyuser, 158 | "zen": zen, 159 | "backport": safe_backport, 160 | "safe_backport": safe_backport, 161 | "migrate": migrate_issue_request, 162 | "tag": tag, 163 | "untag": untag, 164 | "open": _open, 165 | "close": close, 166 | "autopep8": blackify, 167 | "reformat": blackify, 168 | "black": blackify, 169 | "blackify": blackify, 170 | "suggestions": black_suggest, 171 | "pre-commit": precommit, 172 | "precommit": precommit, 173 | "ready": ready, 174 | "merge": merge, 175 | "say": say, 176 | "debug": debug, 177 | "party": party, 178 | } 179 | commands["help"] = help_make(commands) 180 | box = MeeseeksBox(commands=commands, config=config) 181 | 182 | signal.signal(signal.SIGTERM, box.sig_handler) 183 | signal.signal(signal.SIGINT, box.sig_handler) 184 | 185 | box.start() 186 | 187 | 188 | if __name__ == "__main__": 189 | main() 190 | -------------------------------------------------------------------------------- /meeseeksdev/__main__.py: -------------------------------------------------------------------------------- 1 | from . import main 2 | 3 | main() 4 | -------------------------------------------------------------------------------- /meeseeksdev/commands.py: -------------------------------------------------------------------------------- 1 | """ 2 | Define a few commands 3 | """ 4 | 5 | from textwrap import dedent 6 | from typing import Generator, Optional 7 | 8 | from .meeseeksbox.commands import tag, untag 9 | from .meeseeksbox.scopes import everyone, pr_author, write 10 | from .meeseeksbox.utils import Session, fix_comment_body, fix_issue_body 11 | 12 | 13 | def _format_doc(function, name): 14 | if not function.__doc__: 15 | doc = " " 16 | else: 17 | doc = function.__doc__.splitlines() 18 | first, other = doc[0], "\n".join(doc[1:]) 19 | return f"`@meeseeksdev {name} {first}` ({function.scope}) \n{other} " 20 | 21 | 22 | def help_make(commands): 23 | 24 | data = "\n".join([_format_doc(v, k) for k, v in commands.items()]) 25 | 26 | @everyone 27 | def help(*, session, payload, arguments): 28 | comment_url = payload["issue"]["comments_url"] 29 | session.post_comment( 30 | comment_url, 31 | dedent( 32 | """The following commands are available:\n\n{} 33 | """.format( 34 | data 35 | ) 36 | ), 37 | ) 38 | 39 | return help 40 | 41 | 42 | @write 43 | def close(*, session, payload, arguments, local_config=None): 44 | session.ghrequest("PATCH", payload["issue"]["url"], json={"state": "closed"}) 45 | 46 | 47 | @write 48 | def open(*, session, payload, arguments, local_config=None): 49 | session.ghrequest("PATCH", payload["issue"]["url"], json={"state": "open"}) 50 | 51 | 52 | @write 53 | def migrate_issue_request( 54 | *, 55 | session: Session, 56 | payload: dict, 57 | arguments: str, 58 | local_config: Optional[dict] = None, 59 | ) -> Generator: 60 | """[to] {org}/{repo} 61 | 62 | Need to be admin on target repo. Replicate all comments on target repo and close current on. 63 | """ 64 | 65 | """Todo: 66 | 67 | - Works through pagination of comments 68 | - Works through pagination of labels 69 | 70 | Link to non-migrated labels. 71 | """ 72 | if arguments.startswith("to "): 73 | arguments = arguments[3:] 74 | 75 | org_repo = arguments 76 | org, repo = arguments.split("/") 77 | 78 | target_session = yield org_repo 79 | if not target_session: 80 | session.post_comment( 81 | payload["issue"]["comments_url"], 82 | body="I'm afraid I can't do that. Maybe I need to be installed on target repository ?\n" 83 | "Click [here](https://github.com/integrations/meeseeksdev/installations/new) to do that.", 84 | ) 85 | return 86 | 87 | issue_title = payload["issue"]["title"] 88 | issue_body = payload["issue"]["body"] 89 | original_org = payload["organization"]["login"] 90 | original_repo = payload["repository"]["name"] 91 | original_poster = payload["issue"]["user"]["login"] 92 | original_number = payload["issue"]["number"] 93 | migration_requester = payload["comment"]["user"]["login"] 94 | request_id = payload["comment"]["id"] 95 | original_labels = [l["name"] for l in payload["issue"]["labels"]] 96 | 97 | if original_labels: 98 | available_labels = target_session.ghrequest( 99 | "GET", 100 | f"https://api.github.com/repos/{org}/{repo}/labels", 101 | None, 102 | ).json() 103 | 104 | available_labels = [l["name"] for l in available_labels] 105 | 106 | migrate_labels = [ 107 | l for l in original_labels if (l in available_labels and l != "Still Needs Manual Backport") 108 | ] 109 | not_set_labels = [ 110 | l 111 | for l in original_labels 112 | if (l not in available_labels and l != "Still Needs Manual Backport") 113 | ] 114 | 115 | new_response = target_session.create_issue( 116 | org, 117 | repo, 118 | issue_title, 119 | fix_issue_body( 120 | issue_body, 121 | original_poster, 122 | original_repo, 123 | original_org, 124 | original_number, 125 | migration_requester, 126 | ), 127 | labels=migrate_labels, 128 | ) 129 | 130 | new_issue = new_response.json() 131 | new_comment_url = new_issue["comments_url"] 132 | 133 | original_comments = session.ghrequest("GET", payload["issue"]["comments_url"], None).json() 134 | 135 | for comment in original_comments: 136 | if comment["id"] == request_id: 137 | continue 138 | body = comment["body"] 139 | op = comment["user"]["login"] 140 | url = comment["html_url"] 141 | target_session.post_comment( 142 | new_comment_url, 143 | body=fix_comment_body(body, op, url, original_org, original_repo), 144 | ) 145 | 146 | if not_set_labels: 147 | body = "I was not able to apply the following label(s): %s " % ",".join(not_set_labels) 148 | target_session.post_comment(new_comment_url, body=body) 149 | 150 | session.post_comment( 151 | payload["issue"]["comments_url"], 152 | body="Done as {}/{}#{}.".format(org, repo, new_issue["number"]), 153 | ) 154 | session.ghrequest("PATCH", payload["issue"]["url"], json={"state": "closed"}) 155 | 156 | 157 | @pr_author 158 | @write 159 | def ready(*, session, payload, arguments, local_config=None): 160 | """{no arguments} 161 | 162 | Remove "waiting for author" tag, adds "need review" tag. Can also be issued 163 | if you are the current PR author even if you are not admin. 164 | """ 165 | tag(session, payload, "need review") 166 | untag(session, payload, "waiting for author") 167 | 168 | 169 | @write 170 | def merge(*, session, payload, arguments, method="merge", local_config=None): 171 | print("===== merging =====") 172 | if arguments: 173 | if arguments not in {"merge", "squash", "rebase"}: 174 | print("don't know how to merge with methods", arguments) 175 | return 176 | else: 177 | method = arguments 178 | prnumber = payload["issue"]["number"] 179 | org_name = payload["repository"]["owner"]["login"] 180 | repo_name = payload["repository"]["name"] 181 | 182 | # collect extended payload on the PR 183 | print("== Collecting data on Pull-request...") 184 | r = session.ghrequest( 185 | "GET", 186 | f"https://api.github.com/repos/{org_name}/{repo_name}/pulls/{prnumber}", 187 | json=None, 188 | ) 189 | pr_data = r.json() 190 | head_sha = pr_data["head"]["sha"] 191 | mergeable = pr_data["mergeable"] 192 | repo_name = pr_data["head"]["repo"]["name"] 193 | if mergeable: 194 | 195 | resp = session.ghrequest( 196 | "PUT", 197 | "https://api.github.com/repos/{}/{}/pulls/{}/merge".format( 198 | org_name, repo_name, prnumber 199 | ), 200 | json={"sha": head_sha, "merge_method": method}, 201 | override_accept_header="application/vnd.github.polaris-preview+json", 202 | ) 203 | print("------------") 204 | print(resp.json()) 205 | print("------------") 206 | resp.raise_for_status() 207 | else: 208 | print("Not mergeable", pr_data["mergeable"]) 209 | 210 | 211 | ### 212 | # Lock and Unlock are not yet available for integration. 213 | ### 214 | # def _lock_primitive(meth,*, session, payload, arguments): 215 | # number = payload['issue']['number'] 216 | # org_name = payload['repository']['owner']['login'] 217 | # repo_name = payload['repository']['name'] 218 | # session.ghrequest('PUT', 'https://api.github.com/repos/{}/{}/issues/{}/lock'.format(org_name, repo_name, number)) 219 | # 220 | # @admin 221 | # def lock(**kwargs): 222 | # _lock_primitive('PUT', **kwargs) 223 | # 224 | # @admin 225 | # def unlock(**kwargs): 226 | # _lock_primitive('DELETE', **kwargs) 227 | -------------------------------------------------------------------------------- /meeseeksdev/meeseeksbox/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | MeeseeksBox 3 | 4 | Base of a framework to write stateless bots on GitHub. 5 | 6 | Mainly writte to use the (currently Beta) new GitHub "Integration" API, and 7 | handle authencation of user. 8 | """ 9 | 10 | from .core import Config # noqa 11 | from .core import MeeseeksBox # noqa 12 | 13 | version_info = (0, 0, 2) 14 | 15 | __version__ = ".".join(map(str, version_info)) 16 | -------------------------------------------------------------------------------- /meeseeksdev/meeseeksbox/commands.py: -------------------------------------------------------------------------------- 1 | """ 2 | Define a few commands 3 | """ 4 | 5 | import os 6 | import pipes 7 | import random 8 | import re 9 | import sys 10 | import time 11 | import traceback 12 | from pathlib import Path 13 | from textwrap import dedent 14 | from typing import Generator, Optional 15 | from unittest import mock 16 | 17 | import git 18 | 19 | from .scopes import admin, everyone, write 20 | from .utils import Session, add_event, fix_comment_body, fix_issue_body, run 21 | 22 | green = "\033[0;32m" 23 | yellow = "\033[0;33m" 24 | red = "\033[0;31m" 25 | blue = "\x1b[0;34m" 26 | normal = "\033[0m" 27 | 28 | 29 | @everyone 30 | def replyuser(*, session, payload, arguments, local_config=None): 31 | print("I'm replying to a user, look at me.") 32 | comment_url = payload["issue"]["comments_url"] 33 | user = payload["comment"]["user"]["login"] 34 | c = random.choice( 35 | ( 36 | "Helloooo @{user}, I'm Mr. Meeseeks! Look at me!", 37 | "Look at me, @{user}, I'm Mr. Meeseeks! ", 38 | "I'm Mr. Meeseek, @{user}, Look at meee ! ", 39 | ) 40 | ) 41 | session.post_comment(comment_url, c.format(user=user)) 42 | 43 | 44 | @write 45 | def say(*, session, payload, arguments, local_config=None): 46 | print("Oh, got local_config", local_config) 47 | comment_url = payload.get("issue", payload.get("pull_request"))["comments_url"] 48 | session.post_comment(comment_url, "".join(arguments)) 49 | 50 | 51 | @write 52 | def debug(*, session, payload, arguments, local_config=None): 53 | print("DEBUG") 54 | print("session", session) 55 | print("payload", payload) 56 | print("arguments", arguments) 57 | print("local_config", local_config) 58 | 59 | 60 | @everyone 61 | def party(*, session, payload, arguments, local_config=None): 62 | comment_url = payload.get("issue", payload.get("pull_request"))["comments_url"] 63 | parrot = "![party parrot](http://cultofthepartyparrot.com/parrots/hd/parrot.gif)" 64 | session.post_comment(comment_url, parrot * 10) 65 | 66 | 67 | @everyone 68 | def zen(*, session, payload, arguments, local_config=None): 69 | comment_url = payload.get("issue", payload.get("pull_request"))["comments_url"] 70 | session.post_comment( 71 | comment_url, 72 | dedent( 73 | """ 74 | Zen of Python ([pep 20](https://www.python.org/dev/peps/pep-0020/)) 75 | ``` 76 | >>> import this 77 | Beautiful is better than ugly. 78 | Explicit is better than implicit. 79 | Simple is better than complex. 80 | Complex is better than complicated. 81 | Flat is better than nested. 82 | Sparse is better than dense. 83 | Readability counts. 84 | Special cases aren't special enough to break the rules. 85 | Although practicality beats purity. 86 | Errors should never pass silently. 87 | Unless explicitly silenced. 88 | In the face of ambiguity, refuse the temptation to guess. 89 | There should be one-- and preferably only one --obvious way to do it. 90 | Although that way may not be obvious at first unless you're Dutch. 91 | Now is better than never. 92 | Although never is often better than *right* now. 93 | If the implementation is hard to explain, it's a bad idea. 94 | If the implementation is easy to explain, it may be a good idea. 95 | Namespaces are one honking great idea -- let's do more of those! 96 | ``` 97 | """ 98 | ), 99 | ) 100 | 101 | 102 | @admin 103 | def replyadmin(*, session, payload, arguments, local_config=None): 104 | comment_url = payload["issue"]["comments_url"] 105 | user = payload["issue"]["user"]["login"] 106 | session.post_comment(comment_url, f"Hello @{user}. Waiting for your orders.") 107 | 108 | 109 | def _compute_pwd_changes(allowlist): 110 | import glob 111 | from difflib import SequenceMatcher 112 | 113 | import black 114 | 115 | post_changes = [] 116 | import os 117 | 118 | print("== pwd", os.getcwd()) 119 | print("== listdir", os.listdir()) 120 | 121 | for path in glob.glob("**/*.py", recursive=True): 122 | print("=== scanning", path, path in allowlist) 123 | if path not in allowlist: 124 | # we don't touch files not in this PR. 125 | continue 126 | p = Path(path) 127 | old = p.read_text() 128 | new = black.format_str(old, mode=black.FileMode()) 129 | if new != old: 130 | print("will differ") 131 | nl = new.splitlines() 132 | ol = old.splitlines() 133 | s = SequenceMatcher(None, ol, nl) 134 | for t, a1, a2, b1, b2 in s.get_opcodes(): 135 | if t == "replace": 136 | 137 | c = "```suggestion\n" 138 | 139 | for n in nl[b1:b2]: 140 | c += n 141 | c += "\n" 142 | c += "```" 143 | ch = (p.as_posix(), a1, a2, c) 144 | post_changes.append(ch) 145 | return post_changes 146 | 147 | 148 | @admin 149 | def black_suggest(*, session, payload, arguments, local_config=None): 150 | print("===== reformatting suggestions. =====") 151 | 152 | prnumber = payload["issue"]["number"] 153 | # prtitle = payload["issue"]["title"] 154 | org_name = payload["repository"]["owner"]["login"] 155 | repo_name = payload["repository"]["name"] 156 | 157 | # collect extended payload on the PR 158 | print("== Collecting data on Pull-request...") 159 | r = session.ghrequest( 160 | "GET", 161 | f"https://api.github.com/repos/{org_name}/{repo_name}/pulls/{prnumber}", 162 | json=None, 163 | ) 164 | pr_data = r.json() 165 | head_sha = pr_data["head"]["sha"] 166 | # base_sha = pr_data["base"]["sha"] 167 | # branch = pr_data["head"]["ref"] 168 | # author_login = pr_data["head"]["repo"]["owner"]["login"] 169 | repo_name = pr_data["head"]["repo"]["name"] 170 | 171 | # commits_url = pr_data["commits_url"] 172 | 173 | # commits_data = session.ghrequest("GET", commits_url).json() 174 | 175 | # that will likely fail, as if PR, we need to bypass the fact that the 176 | # requester has technically no access to committer repo. 177 | # TODO, check if maintainer 178 | ## target_session = yield "{}/{}".format(author_login, repo_name) 179 | ## if target_session: 180 | ## print('installed on target repository') 181 | ## atk = target_session.token() 182 | ## else: 183 | ## print('use allow edit as maintainer') 184 | ## atk = session.token() 185 | ## comment_url = payload["issue"]["comments_url"] 186 | ## session.post_comment( 187 | ## comment_url, 188 | ## body="Would you mind installing me on your fork so that I can update your branch? \n" 189 | ## "Click [here](https://github.com/apps/meeseeksdev/installations/new)" 190 | ## "to do that, and follow the instructions to add your fork." 191 | ## "I'm going to try to push as a maintainer but this may not work." 192 | ## ) 193 | # if not target_session: 194 | # comment_url = payload["issue"]["comments_url"] 195 | # session.post_comment( 196 | # comment_url, 197 | # body="I'm afraid I can't do that. Maybe I need to be installed on target repository?\n" 198 | # "Click [here](https://github.com/apps/meeseeksdev/installations/new) to do that.".format( 199 | # botname="meeseeksdev" 200 | # ), 201 | # ) 202 | # return 203 | 204 | # clone locally 205 | # this process can take some time, regen token 206 | 207 | # paginated by 30 files, let's nto go that far (yet) 208 | files_response = session.ghrequest( 209 | "GET", 210 | f"https://api.github.com/repos/{org_name}/{repo_name}/pulls/{prnumber}/files", 211 | ) 212 | pr_files = [r["filename"] for r in files_response.json()] 213 | print("== PR contains", len(pr_files), "files") 214 | 215 | if os.path.exists(repo_name): 216 | print("== Cleaning up previous work ... ") 217 | run(f"rm -rf {repo_name}") 218 | print("== Done cleaning ") 219 | 220 | print(f"== Cloning repository from {org_name}/{repo_name}, this can take some time ...") 221 | process = run( 222 | [ 223 | "git", 224 | "clone", 225 | "https://x-access-token:{}@github.com/{}/{}".format( 226 | session.token(), org_name, repo_name 227 | ), 228 | ] 229 | ) 230 | print("== Cloned..") 231 | process.check_returncode() 232 | 233 | run("git config --global user.email meeseeksmachine@gmail.com") 234 | run("git config --global user.name FriendlyBot") 235 | 236 | # do the pep8ify on local filesystem 237 | repo = git.Repo(repo_name) 238 | # branch = master 239 | # print(f"== Fetching branch `{branch}` ...") 240 | # repo.remotes.origin.fetch("{}:workbranch".format(branch)) 241 | # repo.git.checkout("workbranch") 242 | print("== Fetching Commits to reformat ...") 243 | repo.remotes.origin.fetch(f"{head_sha}") 244 | print("== All have been fetched correctly") 245 | repo.git.checkout(head_sha) 246 | print(f"== checked PR head {head_sha}") 247 | 248 | print("== Computing changes....") 249 | os.chdir(repo_name) 250 | changes = _compute_pwd_changes(pr_files) 251 | os.chdir("..") 252 | print("... computed", len(changes), changes) 253 | 254 | COMFORT_FADE = "application/vnd.github.comfort-fade-preview+json" 255 | # comment_url = payload["issue"]["comments_url"] 256 | # session.post_comment( 257 | # comment_url, 258 | # body=dedent(""" 259 | # I've rebased this Pull Request, applied `black` on all the 260 | # individual commits, and pushed. You may have trouble pushing further 261 | # commits, but feel free to force push and ask me to reformat again. 262 | # """) 263 | # ) 264 | 265 | for path, start, end, body in changes: 266 | print(f"== will suggest the following on {path} {start+1} to {end}\n", body) 267 | if start + 1 != end: 268 | data = { 269 | "body": body, 270 | "commit_id": head_sha, 271 | "path": path, 272 | "start_line": start + 1, 273 | "start_side": "RIGHT", 274 | "line": end, 275 | "side": "RIGHT", 276 | } 277 | 278 | try: 279 | _ = session.ghrequest( 280 | "POST", 281 | f"https://api.github.com/repos/{org_name}/{repo_name}/pulls/{prnumber}/comments", 282 | json=data, 283 | override_accept_header=COMFORT_FADE, 284 | ) 285 | except Exception: 286 | # likely unprecessable entity out of range 287 | pass 288 | else: 289 | # we can't seem to do single line with COMFORT_FADE 290 | data = { 291 | "body": body, 292 | "commit_id": head_sha, 293 | "path": path, 294 | "line": end, 295 | "side": "RIGHT", 296 | } 297 | 298 | try: 299 | _ = session.ghrequest( 300 | "POST", 301 | f"https://api.github.com/repos/{org_name}/{repo_name}/pulls/{prnumber}/comments", 302 | json=data, 303 | ) 304 | except Exception: 305 | # likely unprocessable entity out of range 306 | pass 307 | if os.path.exists(repo_name): 308 | print("== Cleaning up repo... ") 309 | run(f"rm -rf {repo_name}") 310 | print("== Done cleaning ") 311 | 312 | 313 | def prep_for_command( 314 | name: str, 315 | session: Session, 316 | payload: dict, 317 | arguments: str, 318 | local_config: Optional[dict] = None, 319 | ) -> Generator: 320 | """Prepare to run a command against a local checkout of a repo.""" 321 | print(f"===== running command {name} =====") 322 | print("===== ============ =====") 323 | # collect initial payload 324 | # https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#issue_comment 325 | prnumber = payload["issue"]["number"] 326 | org_name = payload["repository"]["owner"]["login"] 327 | repo_name = payload["repository"]["name"] 328 | comment_url = payload["issue"]["comments_url"] 329 | 330 | # collect extended payload on the PR 331 | # https://docs.github.com/en/rest/reference/pulls#get-a-pull-request 332 | print("== Collecting data on Pull-request...") 333 | r = session.ghrequest( 334 | "GET", 335 | f"https://api.github.com/repos/{org_name}/{repo_name}/pulls/{prnumber}", 336 | json=None, 337 | ) 338 | pr_data = r.json() 339 | head_sha = pr_data["head"]["sha"] 340 | branch = pr_data["head"]["ref"] 341 | author_login = pr_data["head"]["repo"]["owner"]["login"] 342 | repo_name = pr_data["head"]["repo"]["name"] 343 | maintainer_can_modify = pr_data["maintainer_can_modify"] 344 | 345 | # Check to see if we can successfully push changees to the PR. 346 | target_session = yield f"{author_login}/{repo_name}" 347 | 348 | if target_session: 349 | print("installed on target repository") 350 | atk = target_session.token() 351 | session.post_comment(comment_url, body=f"Running {name} on this Pull Request...") 352 | else: 353 | print("use allow edit as maintainer") 354 | if maintainer_can_modify: 355 | msg = "For now I will push as a maintainer since it is enabled." 356 | else: 357 | msg = 'I would push as a maintainer but I cannot unless "Allow edits from maintainers" is enabled for this Pull Request.' 358 | atk = session.token() 359 | session.post_comment( 360 | comment_url, 361 | body=f"@{author_login}, would you mind installing me on your fork so that I can update your branch?\n" 362 | "Click [here](https://github.com/apps/meeseeksdev/installations/new) " 363 | "to do that, and follow the instructions to add your fork.\n\n" 364 | f"{msg}", 365 | ) 366 | if not maintainer_can_modify: 367 | print("=== Bailing since we do not have permissions") 368 | return 369 | 370 | if os.path.exists(repo_name): 371 | print("== Cleaning up previous work ... ") 372 | run(f"rm -rf {repo_name}", check=True) 373 | print("== Done cleaning ") 374 | 375 | print(f"== Cloning repository from {author_login}/{repo_name}, this can take some time ...") 376 | process = run( 377 | [ 378 | "git", 379 | "clone", 380 | f"https://x-access-token:{atk}@github.com/{author_login}/{repo_name}", 381 | ] 382 | ) 383 | print("== Cloned..") 384 | process.check_returncode() 385 | 386 | run("git config --global user.email meeseeksmachine@gmail.com") 387 | run("git config --global user.name FriendlyBot") 388 | 389 | # do the command on local filesystem 390 | repo = git.Repo(repo_name) 391 | print(f"== Fetching branch `{branch}` to run {name} on ...") 392 | repo.remotes.origin.fetch(f"{branch}:workbranch") 393 | repo.git.checkout("workbranch") 394 | print(f"== Fetching Commits to run {name} on ...") 395 | repo.remotes.origin.fetch(f"{head_sha}") 396 | print("== All have been fetched correctly") 397 | 398 | os.chdir(repo_name) 399 | 400 | 401 | def push_the_work(session, payload, arguments, local_config=None): 402 | """Push the work down in a local repo to the remote repo.""" 403 | prnumber = payload["issue"]["number"] 404 | org_name = payload["repository"]["owner"]["login"] 405 | repo_name = payload["repository"]["name"] 406 | 407 | # collect extended payload on the PR 408 | print("== Collecting data on Pull-request...") 409 | r = session.ghrequest( 410 | "GET", 411 | f"https://api.github.com/repos/{org_name}/{repo_name}/pulls/{prnumber}", 412 | json=None, 413 | ) 414 | pr_data = r.json() 415 | branch = pr_data["head"]["ref"] 416 | repo_name = pr_data["head"]["repo"]["name"] 417 | 418 | # Open the repo in the cwd 419 | repo = git.Repo(".") 420 | 421 | # Push the work 422 | print("== Pushing work....:") 423 | print(f"pushing with workbranch:{branch}") 424 | succeeded = True 425 | try: 426 | repo.remotes.origin.push(f"workbranch:{branch}", force=True).raise_if_error() 427 | except Exception: 428 | succeeded = False 429 | 430 | # Clean up 431 | default_branch = session.ghrequest( 432 | "GET", f"https://api.github.com/repos/{org_name}/{repo_name}" 433 | ).json()["default_branch"] 434 | repo.git.checkout(default_branch) 435 | repo.branches.workbranch.delete(repo, "workbranch", force=True) 436 | return succeeded 437 | 438 | 439 | @admin 440 | def precommit( 441 | *, 442 | session: Session, 443 | payload: dict, 444 | arguments: str, 445 | local_config: Optional[dict] = None, 446 | ) -> Generator: 447 | comment_url = payload["issue"]["comments_url"] 448 | 449 | """Run pre-commit against a PR and push the changes.""" 450 | yield from prep_for_command("precommit", session, payload, arguments, local_config=local_config) 451 | 452 | # Make sure there is a pre-commit file. 453 | config = Path("./.pre-commit-config.yaml") 454 | if not config.exists(): 455 | # Alert the caller and bail. 456 | session.post_comment( 457 | comment_url, 458 | body=dedent( 459 | """ 460 | I was unable to fix pre-commit errors because there is no 461 | ".pre-commit-config.yaml" file. 462 | """ 463 | ), 464 | ) 465 | return 466 | 467 | # Install the package in editable mode if there are local hooks. 468 | if "repo: local" in config.read_text(): 469 | run("pip install --user -e .") 470 | 471 | cmd = "pre-commit run --all-files --hook-stage=manual" 472 | 473 | # Run the command 474 | process = run(cmd) 475 | 476 | # See if the pre-commit succeeded, meaning there was nothing to do 477 | if process.returncode == 0: 478 | 479 | # Alert the caller and bail. 480 | session.post_comment( 481 | comment_url, 482 | body=dedent( 483 | """ 484 | I was unable to run "pre-commit" because there was nothing to do. 485 | """ 486 | ), 487 | ) 488 | return 489 | 490 | # Add any changed files. 491 | process = run('git commit -a -m "Apply pre-commit"') 492 | made_changes = process.returncode == 0 493 | 494 | # Run again to see if we've auto-fixed 495 | process = run(cmd) 496 | 497 | # If second run fails, then we can't auto-fix 498 | if process.returncode != 0: 499 | 500 | if not made_changes: 501 | # Alert the caller and bail. 502 | session.post_comment( 503 | comment_url, 504 | body=dedent( 505 | """ 506 | I was unable to fix pre-commit errors automatically. 507 | Try running `pre-commit run --all-files` locally. 508 | """ 509 | ), 510 | ) 511 | return 512 | 513 | session.post_comment( 514 | comment_url, 515 | body=dedent( 516 | """ 517 | I was unable to fix all of the pre-commit errors automatically. Try running `pre-commit run --all-files` locally. 518 | """ 519 | ), 520 | ) 521 | 522 | succeeded = push_the_work(session, payload, arguments, local_config=local_config) 523 | 524 | # Tell the caller we've finished 525 | comment_url = payload["issue"]["comments_url"] 526 | if succeeded: 527 | session.post_comment( 528 | comment_url, 529 | body=dedent( 530 | """ 531 | I've applied "pre-commit" and pushed. You may have trouble pushing further 532 | commits, but feel free to force push and ask me to run again. 533 | """ 534 | ), 535 | ) 536 | else: 537 | session.post_comment(comment_url, body="I was unable to push due to errors") 538 | 539 | 540 | @admin 541 | def blackify(*, session, payload, arguments, local_config=None): 542 | """Run black against all commits of on a PR and push the new commits.""" 543 | yield from prep_for_command("blackify", session, payload, arguments, local_config=local_config) 544 | 545 | comment_url = payload["issue"]["comments_url"] 546 | 547 | prnumber = payload["issue"]["number"] 548 | org_name = payload["repository"]["owner"]["login"] 549 | repo_name = payload["repository"]["name"] 550 | 551 | r = session.ghrequest( 552 | "GET", 553 | f"https://api.github.com/repos/{org_name}/{repo_name}/pulls/{prnumber}", 554 | json=None, 555 | ) 556 | pr_data = r.json() 557 | commits_url = pr_data["commits_url"] 558 | commits_data = session.ghrequest("GET", commits_url).json() 559 | 560 | for commit in commits_data: 561 | if len(commit["parents"]) != 1: 562 | session.post_comment( 563 | comment_url, 564 | body="It looks like the history is not linear in this pull-request. I'm afraid I can't rebase.\n", 565 | ) 566 | return 567 | 568 | # so far we assume that the commit we rebase on is the first. 569 | to_rebase_on = commits_data[0]["parents"][0]["sha"] 570 | 571 | process = run( 572 | [ 573 | "git", 574 | "rebase", 575 | "-x", 576 | "black --fast . && git commit -a --amend --no-edit", 577 | "--strategy-option=theirs", 578 | "--autosquash", 579 | to_rebase_on, 580 | ] 581 | ) 582 | 583 | if process.returncode != 0: 584 | session.post_comment( 585 | comment_url, 586 | body=dedent( 587 | """ 588 | I was unable to run "blackify" due to an error. 589 | """ 590 | ), 591 | ) 592 | return 593 | 594 | succeeded = push_the_work(session, payload, arguments, local_config=local_config) 595 | 596 | # Tell the caller we've finished 597 | if succeeded: 598 | session.post_comment( 599 | comment_url, 600 | body=dedent( 601 | """ 602 | I've rebased this Pull Request, applied `black` on all the 603 | individual commits, and pushed. You may have trouble pushing further 604 | commits, but feel free to force push and ask me to reformat again. 605 | """ 606 | ), 607 | ) 608 | else: 609 | session.post_comment(comment_url, body="I was unable to push due to errors") 610 | 611 | 612 | @write 613 | def safe_backport(session, payload, arguments, local_config=None): 614 | """[to] {branch}""" 615 | import builtins 616 | 617 | print = lambda *args, **kwargs: builtins.print(" [backport]", *args, **kwargs) 618 | 619 | s_clone_time = 0.0 620 | s_success = False 621 | s_reason = "unknown" 622 | s_fork_time = 0.0 623 | s_clean_time = 0.0 624 | s_ff_time = 0.0 625 | s_slug = "" 626 | 627 | def keen_stats(): 628 | nonlocal s_slug 629 | nonlocal s_clone_time 630 | nonlocal s_success 631 | nonlocal s_reason 632 | nonlocal s_fork_time 633 | nonlocal s_clean_time 634 | nonlocal s_ff_time 635 | add_event( 636 | "backport_stats", 637 | { 638 | "slug": s_slug, 639 | "clone_time": s_clone_time, 640 | "fork_time": s_fork_time, 641 | "clean_time": s_clean_time, 642 | "success": s_success, 643 | "fast_forward_opt_time": s_ff_time, 644 | "reason": s_reason, 645 | }, 646 | ) 647 | 648 | if arguments is None: 649 | arguments = "" 650 | target_branch = arguments 651 | if target_branch.startswith("to "): 652 | target_branch = target_branch[3:].strip() 653 | # collect initial payload 654 | if "issue" not in payload: 655 | print( 656 | green 657 | + 'debug safe_autobackport, "issue" not in payload, likely trigerd by milisetone merge.' 658 | + normal 659 | ) 660 | prnumber = payload.get("issue", payload).get("number") 661 | prtitle = payload.get("issue", payload.get("pull_request", {})).get("title") 662 | org_name = payload["repository"]["owner"]["login"] 663 | repo_name = payload["repository"]["name"] 664 | comment_url = payload.get("issue", payload.get("pull_request"))["comments_url"] 665 | maybe_wrong_named_branch = False 666 | s_slug = f"{org_name}/{repo_name}" 667 | try: 668 | default_branch = session.ghrequest( 669 | "GET", f"https://api.github.com/repos/{org_name}/{repo_name}" 670 | ).json()["default_branch"] 671 | existing_branches = session.ghrequest( 672 | "GET", f"https://api.github.com/repos/{org_name}/{repo_name}/branches" 673 | ).json() 674 | existing_branches_names = {b["name"] for b in existing_branches} 675 | if target_branch not in existing_branches_names and target_branch.endswith("."): 676 | target_branch = target_branch[:-1] 677 | 678 | if target_branch not in existing_branches_names: 679 | print( 680 | red 681 | + f"Request to backport to `{target_branch}`, which does not seem to exist. Known : {existing_branches_names}" 682 | ) 683 | maybe_wrong_named_branch = True 684 | else: 685 | print(green + f"found branch {target_branch}") 686 | except Exception: 687 | import traceback 688 | 689 | traceback.print_exc() 690 | s_reason = "Exception line 256" 691 | keen_stats() 692 | try: 693 | 694 | # collect extended payload on the PR 695 | print("== Collecting data on Pull-request ...") 696 | r = session.ghrequest( 697 | "GET", 698 | f"https://api.github.com/repos/{org_name}/{repo_name}/pulls/{prnumber}", 699 | json=None, 700 | ) 701 | pr_data = r.json() 702 | merge_sha = pr_data["merge_commit_sha"] 703 | milestone = pr_data["milestone"] 704 | if milestone: 705 | milestone_number = pr_data["milestone"].get("number", None) 706 | else: 707 | milestone_number = None 708 | 709 | print("----------------------------------------") 710 | # print('milestone data :', pr_data['milestone']) 711 | print("----------------------------------------") 712 | if not target_branch.strip(): 713 | milestone_title = pr_data["milestone"]["title"] 714 | parts = milestone_title.split(".") 715 | parts[-1] = "x" 716 | infered_target_branch = ".".join(parts) 717 | print("inferring branch ...", infered_target_branch) 718 | target_branch = infered_target_branch 719 | add_event("backport_infering_branch", {"infering_remove_x": 1}) 720 | 721 | if milestone_number: 722 | milestone_number = int(milestone_number) 723 | labels_names = [] 724 | try: 725 | labels_names = [ 726 | l["name"] for l in pr_data["labels"] if l["name"] != "Still Needs Manual Backport" 727 | ] 728 | if not labels_names and ("issue" in payload.keys()): 729 | labels_names = [ 730 | l["name"] 731 | for l in payload["issue"]["labels"] 732 | if l["name"] != "Still Needs Manual Backport" 733 | ] 734 | except KeyError: 735 | print("Did not find labels|", pr_data) 736 | # clone locally 737 | # this process can take some time, regen token 738 | atk = session.token() 739 | 740 | # FORK it. 741 | fork_epoch = time.time() 742 | frk = session.personal_request( 743 | "POST", f"https://api.github.com/repos/{org_name}/{repo_name}/forks" 744 | ).json() 745 | 746 | for i in range(5): 747 | ff = session.personal_request("GET", frk["url"], raise_for_status=False) 748 | if ff.status_code == 200: 749 | add_event("fork_wait", {"n": i}) 750 | break 751 | time.sleep(1) 752 | s_fork_time = time.time() - fork_epoch 753 | 754 | ## optimize-fetch-experiment 755 | print("Attempting FF") 756 | if os.path.exists(repo_name): 757 | try: 758 | re_fetch_epoch = time.time() 759 | run( 760 | [ 761 | "git", 762 | "remote", 763 | "set-url", 764 | "origin", 765 | f"https://x-access-token:{atk}@github.com/{org_name}/{repo_name}", 766 | ], 767 | cwd=repo_name, 768 | ).check_returncode() 769 | 770 | repo = git.Repo(repo_name) 771 | print(f"FF: Git fetch {default_branch}") 772 | repo.remotes.origin.fetch(default_branch) 773 | repo.git.checkout(default_branch) 774 | run( 775 | ["git", "reset", "--hard", f"origin/{default_branch}"], 776 | cwd=repo_name, 777 | ).check_returncode() 778 | run(["git", "describe", "--tag"], cwd=repo_name) 779 | re_fetch_delta = time.time() - re_fetch_epoch 780 | print(blue + f"FF took {re_fetch_delta}s") 781 | s_ff_time = re_fetch_delta 782 | except Exception: 783 | # something went wrong. Kill repository it's going to be 784 | # recloned. 785 | clean_epoch = time.time() 786 | if os.path.exists(repo_name): 787 | print("== Cleaning up previous work... ") 788 | run(f"rm -rf {repo_name}") 789 | print("== Done cleaning ") 790 | s_clean_time = time.time() - clean_epoch 791 | import traceback 792 | 793 | traceback.print_exc() 794 | ## end optimise-fetch-experiment 795 | 796 | clone_epoch = time.time() 797 | action = "set-url" 798 | what_was_done = "Fast-Forwarded" 799 | if not os.path.exists(repo_name): 800 | print("== Cloning current repository, this can take some time..") 801 | process = run( 802 | [ 803 | "git", 804 | "clone", 805 | f"https://x-access-token:{atk}@github.com/{org_name}/{repo_name}", 806 | ] 807 | ) 808 | process.check_returncode() 809 | action = "add" 810 | what_was_done = "Cloned" 811 | 812 | s_clone_time = time.time() - clone_epoch 813 | 814 | process = run( 815 | [ 816 | "git", 817 | "remote", 818 | action, 819 | session.personal_account_name, 820 | f"https://x-access-token:{session.personal_account_token}@github.com/{session.personal_account_name}/{repo_name}", 821 | ], 822 | cwd=repo_name, 823 | ) 824 | print("==", what_was_done) 825 | process.check_returncode() 826 | 827 | run("git config --global user.email meeseeksmachine@gmail.com") 828 | run("git config --global user.name MeeseeksDev[bot]") 829 | 830 | # do the backport on local filesystem 831 | repo = git.Repo(repo_name) 832 | print(f"== Fetching branch to backport on ... {target_branch}") 833 | repo.remotes.origin.fetch(f"refs/heads/{target_branch}:workbranch") 834 | repo.git.checkout("workbranch") 835 | print(f"== Fetching Commits to {merge_sha} backport...") 836 | repo.remotes.origin.fetch(f"{merge_sha}") 837 | print("== All has been fetched correctly") 838 | 839 | print("Cherry-picking %s" % merge_sha) 840 | args: tuple = ("-x", "-m", "1", merge_sha) 841 | 842 | msg = "Backport PR #%i: %s" % (prnumber, prtitle) 843 | remote_submit_branch = f"auto-backport-of-pr-{prnumber}-on-{target_branch}" 844 | 845 | try: 846 | with mock.patch.dict("os.environ", {"GIT_EDITOR": "true"}): 847 | try: 848 | repo.git.cherry_pick(*args) 849 | except git.GitCommandError as e: 850 | if "is not a merge." in e.stderr: 851 | print("Likely not a merge PR...Attempting squash and merge picking.") 852 | args = (merge_sha,) 853 | repo.git.cherry_pick(*args) 854 | else: 855 | raise 856 | 857 | except git.GitCommandError as e: 858 | if ("git commit --allow-empty" in e.stderr.lower()) or ( 859 | "git commit --allow-empty" in e.stdout.lower() 860 | ): 861 | session.post_comment( 862 | comment_url, 863 | "Can't Dooooo.... It seem like this is already backported (commit is empty)." 864 | "I won't do anything. MrMeeseeks out.", 865 | ) 866 | print(e.stderr) 867 | print("----") 868 | print(e.stdout) 869 | print("----") 870 | s_reason = "empty commit" 871 | keen_stats() 872 | return 873 | elif "after resolving the conflicts" in e.stderr.lower(): 874 | # TODO, here we should also do a git merge --abort 875 | # to avoid thrashing the cache at next backport request. 876 | cmd = " ".join(pipes.quote(arg) for arg in sys.argv) 877 | print( 878 | "\nPatch did not apply. Resolve conflicts (add, not commit), then re-run `%s`" 879 | % cmd, 880 | file=sys.stderr, 881 | ) 882 | session.post_comment( 883 | comment_url, 884 | f"""Owee, I'm MrMeeseeks, Look at me. 885 | 886 | There seem to be a conflict, please backport manually. Here are approximate instructions: 887 | 888 | 1. Checkout backport branch and update it. 889 | 890 | ``` 891 | git checkout {target_branch} 892 | git pull 893 | ``` 894 | 895 | 2. Cherry pick the first parent branch of the this PR on top of the older branch: 896 | ``` 897 | git cherry-pick -x -m1 {merge_sha} 898 | ``` 899 | 900 | 3. You will likely have some merge/cherry-pick conflict here, fix them and commit: 901 | 902 | ``` 903 | git commit -am {msg!r} 904 | ``` 905 | 906 | 4. Push to a named branch: 907 | 908 | ``` 909 | git push YOURFORK {target_branch}:{remote_submit_branch} 910 | ``` 911 | 912 | 5. Create a PR against branch {target_branch}, I would have named this PR: 913 | 914 | > "Backport PR #{prnumber} on branch {target_branch} ({prtitle})" 915 | 916 | And apply the correct labels and milestones. 917 | 918 | Congratulations — you did some good work! Hopefully your backport PR will be tested by the continuous integration and merged soon! 919 | 920 | Remember to remove the `Still Needs Manual Backport` label once the PR gets merged. 921 | 922 | If these instructions are inaccurate, feel free to [suggest an improvement](https://github.com/MeeseeksBox/MeeseeksDev). 923 | """, 924 | ) 925 | org = payload["repository"]["owner"]["login"] 926 | repo = payload["repository"]["name"] 927 | num = payload.get("issue", payload).get("number") 928 | url = f"https://api.github.com/repos/{org}/{repo}/issues/{num}/labels" 929 | print("trying to apply still needs manual backport") 930 | reply = session.ghrequest("POST", url, json=["Still Needs Manual Backport"]) 931 | print("Should be applied:", reply) 932 | s_reason = "conflicts" 933 | keen_stats() 934 | return 935 | else: 936 | session.post_comment( 937 | comment_url, 938 | "Oops, something went wrong applying the patch ... Please have a look at my logs.", 939 | ) 940 | print(e.stderr) 941 | print("----") 942 | print(e.stdout) 943 | print("----") 944 | s_reason = "Unknown error line 491" 945 | keen_stats() 946 | return 947 | except Exception as e: 948 | session.post_comment( 949 | comment_url, "Hum, I actually crashed, that should not have happened." 950 | ) 951 | if hasattr(e, "stderr"): 952 | print( 953 | "\n" + e.stderr.decode("utf8", "replace"), 954 | file=sys.stderr, 955 | ) 956 | print("\n" + repo.git.status(), file=sys.stderr) 957 | add_event("error", {"git_crash": 1}) 958 | s_reason = "Unknown error line 501" 959 | keen_stats() 960 | 961 | return 962 | 963 | # write the commit message 964 | repo.git.commit("--amend", "-m", msg) 965 | 966 | print("== PR #%i applied, with msg:" % prnumber) 967 | print() 968 | print(msg) 969 | print("== ") 970 | 971 | # Push the backported work 972 | print("== Pushing work....:") 973 | succeeded = True 974 | try: 975 | print(f"Trying to push to {remote_submit_branch} of {session.personal_account_name}") 976 | repo.remotes[session.personal_account_name].push( 977 | f"workbranch:{remote_submit_branch}" 978 | ).raise_if_error() 979 | except Exception as e: 980 | import traceback 981 | 982 | content = traceback.format_exc() 983 | print(content.replace(session.personal_account_token, "...")) 984 | 985 | print("could not push to self remote") 986 | s_reason = "Could not push" 987 | keen_stats() 988 | 989 | session.post_comment( 990 | comment_url, 991 | f"Could not push to {remote_submit_branch} due to error, aborting.", 992 | ) 993 | print(e) 994 | succeeded = False 995 | 996 | repo.git.checkout(default_branch) 997 | repo.branches.workbranch.delete(repo, "workbranch", force=True) 998 | 999 | # TODO checkout the default_branch and get rid of branch 1000 | 1001 | if not succeeded: 1002 | return 1003 | 1004 | # Make the PR on GitHub 1005 | print( 1006 | "try to create PR with milestone", 1007 | milestone_number, 1008 | "and labels", 1009 | labels_names, 1010 | ) 1011 | new_pr = session.personal_request( 1012 | "POST", 1013 | f"https://api.github.com/repos/{org_name}/{repo_name}/pulls", 1014 | json={ 1015 | "title": f"Backport PR #{prnumber} on branch {target_branch} ({prtitle})", 1016 | "body": msg, 1017 | "head": f"{session.personal_account_name}:{remote_submit_branch}", 1018 | "base": target_branch, 1019 | }, 1020 | ).json() 1021 | 1022 | new_number = new_pr["number"] 1023 | resp = session.ghrequest( 1024 | "PATCH", 1025 | f"https://api.github.com/repos/{org_name}/{repo_name}/issues/{new_number}", 1026 | json={"milestone": milestone_number, "labels": labels_names}, 1027 | ) 1028 | # print(resp.json()) 1029 | except Exception as e: 1030 | extra_info = "" 1031 | if maybe_wrong_named_branch: 1032 | extra_info = ( 1033 | "\n\n It seems that the branch you are trying to backport to does not exist." 1034 | ) 1035 | session.post_comment( 1036 | comment_url, 1037 | "Something went wrong ... Please have a look at my logs." + extra_info, 1038 | ) 1039 | add_event("error", {"unknown_crash": 1}) 1040 | print("Something went wrong") 1041 | print(e) 1042 | s_reason = "Remote branch does not exist" 1043 | keen_stats() 1044 | raise 1045 | 1046 | resp.raise_for_status() 1047 | 1048 | print("Backported as PR", new_number) 1049 | s_reason = "Success" 1050 | s_success = True 1051 | keen_stats() 1052 | return new_pr 1053 | 1054 | 1055 | @admin 1056 | def tag(session, payload, arguments, local_config=None): 1057 | "tag[, tag, [...] ]" 1058 | print("Got local config for tag: ", local_config) 1059 | org = payload["repository"]["owner"]["login"] 1060 | repo = payload["repository"]["name"] 1061 | num = payload.get("issue", payload.get("pull_request")).get("number") 1062 | url = f"https://api.github.com/repos/{org}/{repo}/issues/{num}/labels" 1063 | arguments = arguments.replace("'", '"') 1064 | quoted = re.findall(r"\"(.+?)\"", arguments.replace("'", '"')) 1065 | for q in quoted: 1066 | arguments = arguments.replace('"%s"' % q, "") 1067 | tags = [arg.strip() for arg in arguments.split(",") if arg.strip()] + quoted 1068 | print("raw tags:", tags) 1069 | to_apply = [] 1070 | not_applied = [] 1071 | try: 1072 | label_payload = session.ghrequest( 1073 | "GET", f"https://api.github.com/repos/{org}/{repo}/labels" 1074 | ) 1075 | 1076 | label_payloads = [label_payload] 1077 | 1078 | def get_next_link(req): 1079 | all_links = req.headers.get("Link") 1080 | if 'rel="next"' in all_links: 1081 | links = all_links.split(",") 1082 | next_link = [l for l in links if "next" in l][0] # assume only one. 1083 | if next_link: 1084 | return next_link.split(";")[0].strip(" <>") 1085 | 1086 | # let's assume no more than 200 labels 1087 | resp = label_payload 1088 | try: 1089 | for i in range(10): 1090 | print("get labels page", i) 1091 | next_link = get_next_link(resp) 1092 | if next_link: 1093 | resp = session.ghrequest("GET", next_link) 1094 | label_payloads.append(resp) 1095 | else: 1096 | break 1097 | except Exception: 1098 | traceback.print_exc() 1099 | 1100 | know_labels = [] 1101 | for p in label_payloads: 1102 | know_labels.extend([label["name"] for label in p.json()]) 1103 | print("known labels", know_labels) 1104 | 1105 | not_known_tags = [t for t in tags if t not in know_labels] 1106 | known_tags = [t for t in tags if t in know_labels] 1107 | print("known tags", known_tags) 1108 | print("known labels", not_known_tags) 1109 | 1110 | # try to look at casing 1111 | nk = [] 1112 | known_lower_normal = {l.lower(): l for l in know_labels} 1113 | print("known labels lower", known_lower_normal) 1114 | for t in not_known_tags: 1115 | target = known_lower_normal.get(t.lower()) 1116 | print("mapping t", t, target) 1117 | if target: 1118 | known_tags.append(t) 1119 | else: 1120 | print("will not apply", t) 1121 | nk.append(t) 1122 | 1123 | to_apply = known_tags 1124 | not_applied = nk 1125 | except Exception: 1126 | print(red + "something went wrong getting labels" + normal) 1127 | 1128 | traceback.print_exc() 1129 | if local_config: 1130 | only = set(local_config.get("only", [])) 1131 | any_tags = local_config.get("any", False) 1132 | if any_tags: 1133 | print("not filtering, any tags set") 1134 | elif only: 1135 | allowed_tags = [t for t in to_apply if t.lower() in only] 1136 | not_allowed_tags = [t for t in to_apply if t.lower() not in only] 1137 | 1138 | print("will only allow", allowed_tags) 1139 | print("will refuse", not_allowed_tags) 1140 | to_apply = allowed_tags 1141 | not_applied.extend(not_allowed_tags) 1142 | if to_apply: 1143 | session.ghrequest("POST", url, json=to_apply) 1144 | if not_applied: 1145 | comment_url = payload.get("issue", payload.get("pull_request"))["comments_url"] 1146 | lf = "`,`".join(not_applied) 1147 | user = payload.get("comment", {}).get("user", {}).get("login", None) 1148 | session.post_comment( 1149 | comment_url, 1150 | f"Aww {user}, I was not able to apply the following label(s): `{lf}`. Either " 1151 | "because they are not existing labels on this repository or because you do not have the permission to apply these." 1152 | "I tried my best to guess by looking at the casing, but I was unable to find matching labels.", 1153 | ) 1154 | 1155 | 1156 | @admin 1157 | def untag(session, payload, arguments, local_config=None): 1158 | "tag[, tag, [...] ]" 1159 | org = payload["repository"]["owner"]["login"] 1160 | repo = payload["repository"]["name"] 1161 | num = payload.get("issue", payload.get("pull_request")).get("number") 1162 | tags = [arg.strip() for arg in arguments.split(",")] 1163 | name = "{name}" 1164 | url = f"https://api.github.com/repos/{org}/{repo}/issues/{num}/labels/{name}" 1165 | no_untag = [] 1166 | for tag in tags: 1167 | try: 1168 | session.ghrequest("DELETE", url.format(name=tag)) 1169 | except Exception: 1170 | no_untag.append(tag) 1171 | print("was not able to remove tags:", no_untag) 1172 | 1173 | 1174 | @write 1175 | def migrate_issue_request( 1176 | *, 1177 | session: Session, 1178 | payload: dict, 1179 | arguments: str, 1180 | local_config: Optional[dict] = None, 1181 | ) -> Generator: 1182 | """Todo: 1183 | 1184 | - Works through pagination of comments 1185 | - Works through pagination of labels 1186 | 1187 | Link to non-migrated labels. 1188 | """ 1189 | if arguments.startswith("to "): 1190 | arguments = arguments[3:] 1191 | 1192 | org_repo = arguments 1193 | org, repo = arguments.split("/") 1194 | 1195 | target_session = yield org_repo 1196 | if not target_session: 1197 | session.post_comment(payload["issue"]["comments_url"], "It appears that I can't do that") 1198 | return 1199 | 1200 | issue_title = payload["issue"]["title"] 1201 | issue_body = payload["issue"]["body"] 1202 | original_org = payload["repository"]["owner"]["login"] 1203 | original_repo = payload["repository"]["name"] 1204 | original_poster = payload["issue"]["user"]["login"] 1205 | original_number = payload["issue"]["number"] 1206 | migration_requester = payload["comment"]["user"]["login"] 1207 | request_id = payload["comment"]["id"] 1208 | original_labels = [l["name"] for l in payload["issue"]["labels"]] 1209 | 1210 | if original_labels: 1211 | available_labels = target_session.ghrequest( 1212 | "GET", 1213 | f"https://api.github.com/repos/{org}/{repo}/labels", 1214 | None, 1215 | ).json() 1216 | 1217 | available_labels = [l["name"] for l in available_labels] 1218 | 1219 | migrate_labels = [l for l in original_labels if l in available_labels] 1220 | not_set_labels = [l for l in original_labels if l not in available_labels] 1221 | 1222 | new_response = target_session.create_issue( 1223 | org, 1224 | repo, 1225 | issue_title, 1226 | fix_issue_body( 1227 | issue_body, 1228 | original_poster, 1229 | original_repo, 1230 | original_org, 1231 | original_number, 1232 | migration_requester, 1233 | ), 1234 | labels=migrate_labels, 1235 | ) 1236 | 1237 | new_issue = new_response.json() 1238 | new_comment_url = new_issue["comments_url"] 1239 | 1240 | original_comments = session.ghrequest("GET", payload["issue"]["comments_url"], None).json() 1241 | 1242 | for comment in original_comments: 1243 | if comment["id"] == request_id: 1244 | continue 1245 | body = comment["body"] 1246 | op = comment["user"]["login"] 1247 | url = comment["html_url"] 1248 | target_session.post_comment( 1249 | new_comment_url, 1250 | body=fix_comment_body(body, op, url, original_org, original_repo), 1251 | ) 1252 | 1253 | if not_set_labels: 1254 | body = "I was not able to apply the following label(s): %s " % ",".join(not_set_labels) 1255 | target_session.post_comment(new_comment_url, body=body) 1256 | 1257 | session.post_comment( 1258 | payload["issue"]["comments_url"], 1259 | body="Done as {}/{}#{}.".format(org, repo, new_issue["number"]), 1260 | ) 1261 | session.ghrequest("PATCH", payload["issue"]["url"], json={"state": "closed"}) 1262 | 1263 | 1264 | @write 1265 | def quote(*, session, payload, arguments, local_config=None): 1266 | if arguments.lower() == "over the world": 1267 | comment_url = payload["issue"]["comments_url"] 1268 | user = payload["issue"]["user"]["login"] 1269 | session.post_comment( 1270 | comment_url, 1271 | """ 1272 | > MeeseeksDev: Gee, {user}, what do you want to do tonight? 1273 | {user}: The same thing we do every night, MeeseeksDev - try to take over the world! 1274 | """.format( 1275 | user=user 1276 | ), 1277 | ) 1278 | -------------------------------------------------------------------------------- /meeseeksdev/meeseeksbox/core.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import hmac 3 | import inspect 4 | import json 5 | import re 6 | import time 7 | from asyncio import Future 8 | from concurrent.futures import ThreadPoolExecutor as Pool 9 | 10 | import tornado.httpserver 11 | import tornado.ioloop 12 | import tornado.web 13 | import yaml 14 | from tornado.ioloop import IOLoop 15 | from yieldbreaker import YieldBreaker 16 | 17 | from .scopes import Permission 18 | from .utils import ACCEPT_HEADER_SYMMETRA, Authenticator, add_event, clear_caches 19 | 20 | green = "\033[0;32m" 21 | yellow = "\033[0;33m" 22 | red = "\033[0;31m" 23 | normal = "\033[0m" 24 | 25 | pool = Pool(6) 26 | 27 | 28 | class Config: 29 | botname = None 30 | integration_id = -1 31 | key = None 32 | botname = None 33 | at_botname = None 34 | integration_id = None 35 | webhook_secret = None 36 | personal_account_name = None 37 | personal_account_token = None 38 | 39 | def __init__(self, **kwargs): 40 | self.__dict__.update(kwargs) 41 | 42 | def validate(self): 43 | missing = [ 44 | attr 45 | for attr in dir(self) 46 | if not attr.startswith("_") and getattr(self, attr) is None and attr != "key" 47 | ] 48 | if missing: 49 | raise ValueError(f"The following configuration options are missing : {missing}") 50 | return self 51 | 52 | 53 | def verify_signature(payload, signature, secret): 54 | """ 55 | Make sure hooks are encoded correctly 56 | """ 57 | expected = "sha1=" + hmac.new(secret.encode("ascii"), payload, "sha1").hexdigest() 58 | return hmac.compare_digest(signature, expected) 59 | 60 | 61 | class BaseHandler(tornado.web.RequestHandler): 62 | def error(self, message): 63 | self.set_status(500) 64 | self.write({"status": "error", "message": message}) 65 | 66 | def success(self, message="", payload=None): 67 | self.write({"status": "success", "message": message, "data": payload or {}}) 68 | 69 | 70 | class MainHandler(BaseHandler): 71 | def get(self): 72 | self.finish("No") 73 | 74 | 75 | def _strip_extras(c): 76 | if c.startswith("please "): 77 | c = c[6:].lstrip() 78 | if c.startswith("run "): 79 | c = c[4:].lstrip() 80 | return c 81 | 82 | 83 | def process_mentioning_comment(body: str, bot_re: re.Pattern) -> list: 84 | """ 85 | Given a comment body and a bot name parse this into a tuple of (command, arguments) 86 | """ 87 | lines = body.splitlines() 88 | lines = [ 89 | l.strip() 90 | for l in lines 91 | if (bot_re.search(l) and not l.startswith(">")) 92 | or l.startswith("!msbox") 93 | or l.startswith("bot>") 94 | ] 95 | nl = [] 96 | for l in lines: 97 | if l.startswith("!msbox"): 98 | nl.append(l.split("!msbox")[-1].strip()) 99 | elif l.startswith("bot>"): 100 | nl.append(l.split("bot>")[-1].strip()) 101 | else: 102 | nl.append(bot_re.split(l)[-1].strip()) 103 | 104 | command_args = [_strip_extras(l).split(" ", 1) for l in nl] 105 | command_args = [c if len(c) > 1 else [c[0], None] for c in command_args] 106 | return command_args 107 | 108 | 109 | class WebHookHandler(MainHandler): 110 | def initialize(self, actions, config, auth, *args, **kwargs): 111 | self.actions = actions 112 | self.config = config 113 | self.auth = auth 114 | super().initialize(*args, **kwargs) 115 | 116 | def get(self): 117 | self.finish("Webhook alive and listening") 118 | 119 | def post(self): 120 | if self.config.forward_staging_url: 121 | try: 122 | 123 | def fn(req, url): 124 | try: 125 | import requests 126 | 127 | headers = { 128 | k: req.headers[k] 129 | for k in ( 130 | "content-type", 131 | "User-Agent", 132 | "X-GitHub-Delivery", 133 | "X-GitHub-Event", 134 | "X-Hub-Signature", 135 | ) 136 | } 137 | req = requests.Request("POST", url, headers=headers, data=req.body) 138 | prepared = req.prepare() 139 | with requests.Session() as s: 140 | res = s.send(prepared) # type:ignore[attr-defined] 141 | return res 142 | except Exception: 143 | import traceback 144 | 145 | traceback.print_exc() 146 | 147 | pool.submit(fn, self.request, self.config.forward_staging_url) 148 | except Exception: 149 | print(red + "failure to forward") 150 | import traceback 151 | 152 | traceback.print_exc() 153 | if "X-Hub-Signature" not in self.request.headers: 154 | add_event("attack", {"type": "no X-Hub-Signature"}) 155 | return self.error("WebHook not configured with secret") 156 | 157 | if not verify_signature( 158 | self.request.body, 159 | self.request.headers["X-Hub-Signature"], 160 | self.config.webhook_secret, 161 | ): 162 | add_event("attack", {"type": "wrong signature"}) 163 | return self.error("Cannot validate GitHub payload with provided WebHook secret") 164 | 165 | payload = tornado.escape.json_decode(self.request.body) 166 | org = payload.get("repository", {}).get("owner", {}).get("login") 167 | if not org: 168 | org = payload.get("issue", {}).get("repository", {}).get("owner", {}).get("login") 169 | print("org in issue", org) 170 | 171 | if payload.get("action", None) in [ 172 | "edited", 173 | "assigned", 174 | "labeled", 175 | "opened", 176 | "created", 177 | "submitted", 178 | ]: 179 | add_event("ignore_org_missing", {"edited": "reason"}) 180 | else: 181 | if hasattr(self.config, "org_allowlist") and (org not in self.config.org_allowlist): 182 | add_event("post", {"reject_organisation": org}) 183 | 184 | sender = payload.get("sender", {}).get("login", {}) 185 | if hasattr(self.config, "user_denylist") and (sender in self.config.user_denylist): 186 | add_event("post", {"blocked_user": sender}) 187 | self.finish("Blocked user.") 188 | return 189 | 190 | action = payload.get("action", None) 191 | add_event("post", {"accepted_action": action}) 192 | unknown_repo = red + "" + normal 193 | repo = payload.get("repository", {}).get("full_name", unknown_repo) 194 | if repo == unknown_repo: 195 | import there 196 | 197 | there.print(json.dumps(payload)) 198 | if payload.get("commits"): 199 | # TODO 200 | etype = self.request.headers.get("X-GitHub-Event") 201 | num = payload.get("size") 202 | ref = payload.get("ref") 203 | by = payload.get("pusher", {}).get("name") 204 | print( 205 | green 206 | + f"(https://github.com/{repo}) `{num}` commit(s) were pushed to `{ref}` by `{by}` – type: {etype}" 207 | ) 208 | self.finish("commits were pushed to {repo}") 209 | return 210 | 211 | if action: 212 | return self.dispatch_action(action, payload) 213 | else: 214 | event_type = self.request.headers.get("X-GitHub-Event") 215 | if event_type == "pull_request": 216 | return self.finish() 217 | 218 | if event_type in { 219 | "status", 220 | "fork", 221 | "deployment_status", 222 | "deployment", 223 | "delete", 224 | "push", 225 | "create", 226 | }: 227 | print(f"(https://github.com/{repo}) Not handling event type `{event_type}` yet.") 228 | return self.finish() 229 | 230 | print(f"({repo}) No action available for the webhook :", event_type) 231 | 232 | @property 233 | def mention_bot_re(self): 234 | botname = self.config.botname 235 | return re.compile("@?" + re.escape(botname) + r"(?:\[bot\])?", re.IGNORECASE) 236 | 237 | def dispatch_action(self, type_: str, payload: dict) -> Future: 238 | botname = self.config.botname 239 | repo = payload.get("repository", {}).get("full_name", red + "" + normal) 240 | # new issue/PR opened 241 | if type_ == "opened": 242 | issue = payload.get("issue", None) 243 | if not issue: 244 | pr_number = payload.get("pull_request", {}).get("number", None) 245 | print(green + f"(https://github.com/{repo}/pull/{pr_number}) `opened`.") 246 | return self.finish("Not really good, request has no issue") 247 | if issue: 248 | user = payload["issue"]["user"]["login"] 249 | if user == self.config.botname.lower() + "[bot]": 250 | return self.finish("Not responding to self") 251 | # todo dispatch on on-open 252 | 253 | elif type_ == "added": 254 | installation = payload.get("installation", None) 255 | if installation and installation.get("account"): 256 | print(f"({repo}) we got a new installation.") 257 | self.auth._build_auth_id_mapping() 258 | return self.finish() 259 | else: 260 | pass 261 | # print("can't deal with this kind of payload yet", payload) 262 | # new comment created 263 | elif type_ == "created": 264 | comment = payload.get("comment", None) 265 | installation = payload.get("installation", None) 266 | issue = payload.get("issue", {}) 267 | no_issue_number = red + "" + normal 268 | if not issue: 269 | pull_request = payload.get("pull_request", {}) 270 | if pull_request: 271 | what = "pulls" 272 | number = pull_request.get("number", no_issue_number) 273 | else: 274 | number = no_issue_number 275 | else: 276 | what = "issues" 277 | number = issue.get("number", no_issue_number) 278 | 279 | if number is no_issue_number: 280 | 281 | print(list(payload.keys())) 282 | if comment: 283 | user = payload["comment"]["user"]["login"] 284 | if user == botname.lower() + "[bot]": 285 | print( 286 | green 287 | + f"(https://github.com/{repo}/{what}/{number}) Not responding to self" 288 | ) 289 | return self.finish("Not responding to self") 290 | if "[bot]" in user: 291 | print( 292 | green 293 | + f"(https://github.com/{repo}/{what}/{number}) Not responding to another bot ({user})" 294 | ) 295 | return self.finish("Not responding to another bot") 296 | body = payload["comment"]["body"] 297 | if self.mention_bot_re.findall(body) or ("!msbox" in body): 298 | self.dispatch_on_mention(body, payload, user) 299 | else: 300 | pass 301 | # import textwrap 302 | # print(f'({repo}/{what}/{number}) Was not mentioned by ', 303 | # #self.config.botname,')\n', 304 | # #textwrap.indent(body, 305 | # user, 'on', f'{what}/{number}') 306 | elif installation and installation.get("account"): 307 | print(f"({repo}) we got a new installation.") 308 | self.auth._build_auth_id_mapping() 309 | return self.finish() 310 | else: 311 | print("not handled", payload) 312 | elif type_ == "submitted": 313 | # print(f'({repo}) ignoring `submitted`') 314 | pass 315 | else: 316 | if type_ == "closed": 317 | is_pr = payload.get("pull_request", {}) 318 | num = is_pr.get("number", "????") 319 | merged = is_pr.get("merged", None) 320 | action = is_pr.get("action", None) 321 | if is_pr: 322 | merged_by = is_pr.get("merged_by", {}) 323 | if merged_by is None: 324 | merged_by = {} 325 | login = merged_by.get("login") 326 | print( 327 | green 328 | + f"(https://github.com/{repo}/pull/{num}) merged (action: {action}, merged:{merged}) by {login}" 329 | ) 330 | if merged_by: 331 | description = [] 332 | try: 333 | raw_labels = is_pr.get("labels", []) 334 | if raw_labels: 335 | installation_id = payload["installation"]["id"] 336 | session = self.auth.session(installation_id) 337 | for raw_label in raw_labels: 338 | label = session.ghrequest( 339 | "GET", 340 | raw_label.get("url", ""), 341 | override_accept_header=ACCEPT_HEADER_SYMMETRA, 342 | ).json() 343 | # apparently can still be none-like ? 344 | label_desc = label.get("description", "") or "" 345 | description.append(label_desc.replace("&", "\n")) 346 | except Exception: 347 | import traceback 348 | 349 | traceback.print_exc() 350 | milestone = is_pr.get("milestone", {}) 351 | if milestone: 352 | description.append(milestone.get("description", "") or "") 353 | description_str = "\n".join(description) 354 | if "on-merge:" in description_str and is_pr["base"]["ref"] in ( 355 | "master", 356 | "main", 357 | ): 358 | did_backport = False 359 | for description_line in description_str.splitlines(): 360 | line = description_line.strip() 361 | if line.startswith("on-merge:"): 362 | todo = line[len("on-merge:") :].strip() 363 | self.dispatch_on_mention( 364 | "@meeseeksdev " + todo, 365 | payload, 366 | merged_by["login"], 367 | ) 368 | did_backport = True 369 | if not did_backport: 370 | print( 371 | '"on-merge:" found in milestone description, but unable to parse command.', 372 | 'Is "on-merge:" on a separate line?', 373 | ) 374 | print(description_str) 375 | else: 376 | print( 377 | f'PR is not targeting main/master branch ({is_pr["base"]["ref"]}),' 378 | 'or "on-merge:" not in milestone (or label) description:' 379 | ) 380 | print(description_str) 381 | else: 382 | print(f"({repo}) Hum, closed, PR but not merged") 383 | else: 384 | pass 385 | # print("can't deal with `", type_, "` (for issues) yet") 386 | elif type_ == "milestoned": 387 | pass 388 | else: 389 | pass 390 | # print(f"({repo}) can't deal with `{type_}` yet") 391 | return self.finish() 392 | 393 | # def _action_allowed(args): 394 | # """ 395 | # determine if an action requester can make an action 396 | 397 | # Typically only 398 | # - the requester have a permission higher than the required permission. 399 | 400 | # Or: 401 | # - If pull-request, the requester is the author. 402 | # """ 403 | 404 | def dispatch_on_mention(self, body: str, payload: dict, user: str) -> None: 405 | """ 406 | Core of the logic that let people require actions from the bot. 407 | 408 | Logic is relatively strait forward at the base, 409 | let `user` only trigger action it has sufficient permissions to do so. 410 | 411 | Typically an action can be done if you are at least: 412 | - owner 413 | - admin 414 | - have write permission 415 | - read permissions 416 | - no permission. 417 | 418 | It is a bit trickier in the following case. 419 | 420 | - You are a PR author (and owner of the branch you require to be merged) 421 | 422 | The bot should still let you do these actions 423 | 424 | - You request permission to multiple repo, agreed only if you have 425 | at least write permission to the other repo. 426 | 427 | - You are a maintainer and request access to a repo from which a PR 428 | is coming. 429 | 430 | """ 431 | 432 | # to dispatch to commands 433 | installation_id = payload["installation"]["id"] 434 | org = payload["repository"]["owner"]["login"] 435 | repo = payload["repository"]["name"] 436 | pull_request = payload.get("issue", payload).get("pull_request") 437 | pr_author = None 438 | pr_origin_org_repo = None 439 | allow_edit_from_maintainer = None 440 | session = self.auth.session(installation_id) 441 | if pull_request: 442 | # The PR author _may_ not have access to origin branch 443 | pr_author = payload.get("issue", {"user": {"login": None}})["user"]["login"] 444 | pr = session.ghrequest("GET", pull_request["url"]).json() 445 | pr_origin_org_repo = pr["head"]["repo"]["full_name"] 446 | origin_repo_org = pr["head"]["user"]["login"] 447 | allow_edit_from_maintainer = pr["maintainer_can_modify"] 448 | 449 | # might want to just look at whether the commenter has permission over said branch. 450 | # you _may_ have multiple contributors to a PR. 451 | is_legitimate_author = (pr_author == user) and (pr_author == origin_repo_org) 452 | if is_legitimate_author: 453 | print(user, "is legitimate author of this PR, letting commands go through") 454 | 455 | permission_level = session._get_permission(org, repo, user) 456 | command_args = process_mentioning_comment(body, self.mention_bot_re) 457 | for (command, arguments) in command_args: 458 | print(" :: treating", command, arguments) 459 | add_event( 460 | "dispatch", 461 | { 462 | "mention": { 463 | "user": user, 464 | "organisation": org, 465 | "repository": f"{org}/{repo}", 466 | "command": command, 467 | } 468 | }, 469 | ) 470 | handler = self.actions.get(command.lower(), None) 471 | command = command.lower() 472 | 473 | def user_can(user, command, repo, org, session): 474 | """ 475 | callback to test whether the current user has custom permission set on said repository. 476 | """ 477 | try: 478 | path = ".meeseeksdev.yml" 479 | resp = session.ghrequest( 480 | "GET", 481 | f"https://api.github.com/repos/{org}/{repo}/contents/{path}", 482 | raise_for_status=False, 483 | ) 484 | except Exception: 485 | print(red + "An error occurred getting repository config file." + normal) 486 | import traceback 487 | 488 | traceback.print_exc() 489 | return False, {} 490 | conf = {} 491 | if resp.status_code == 404: 492 | print(yellow + "config file not found" + normal) 493 | elif resp.status_code != 200: 494 | print(red + f"unknown status code {resp.status_code}" + normal) 495 | resp.raise_for_status() 496 | else: 497 | conf = yaml.safe_load(base64.decodebytes(resp.json()["content"].encode())) 498 | print(green + f"should test if {user} can {command} on {repo}/{org}" + normal) 499 | # print(green + json.dumps(conf, indent=2) + normal) 500 | 501 | if user in conf.get("usr_denylist", []): 502 | return False, {} 503 | 504 | user_section = conf.get("users", {}).get(user, {}) 505 | 506 | custom_allowed_commands = user_section.get("can", []) 507 | 508 | print(f"Custom allowed command for {user} are", custom_allowed_commands) 509 | 510 | if command in custom_allowed_commands: 511 | print(yellow + f"would allow {user} to {command}") 512 | if "config" in user_section: 513 | user_section_config = user_section.get("config", {}) 514 | if isinstance(user_section_config, list): 515 | print("pop0 from user_config") 516 | user_section_config = user_section_config[0] 517 | local_config = user_section_config.get(command, None) 518 | if local_config: 519 | print("returning local_config", local_config) 520 | return True, local_config 521 | return True, {} 522 | 523 | everyone_section = conf.get("special", {}).get("everyone", {}) 524 | everyone_allowed_commands = everyone_section.get("can", []) 525 | 526 | print("with everyone taken into account", everyone_allowed_commands) 527 | if command in everyone_allowed_commands: 528 | print(yellow + f"would allow {user} (via everyone) to do {command}") 529 | if "config" in everyone_section: 530 | everyone_section_config = everyone_section.get("config", {}) 531 | if isinstance(everyone_section_config, list): 532 | print("pop0 from user_config") 533 | everyone_section_config = everyone_section_config[0] 534 | local_config = everyone_section_config.get(command, None) 535 | if local_config: 536 | print("returning local_config", local_config) 537 | return True, local_config 538 | return True, {} 539 | 540 | print(yellow + f"would not allow {user} to {command}") 541 | return False, {} 542 | 543 | if handler: 544 | print(" :: testing who can use ", str(handler)) 545 | per_repo_config_allows = None 546 | local_config = {} 547 | try: 548 | per_repo_config_allows, local_config = user_can( 549 | user, command, repo, org, session 550 | ) 551 | except Exception: 552 | print(red + "error runnign user_can" + normal) 553 | import traceback 554 | 555 | traceback.print_exc() 556 | 557 | has_scope = permission_level.value >= handler.scope.value 558 | if has_scope: 559 | local_config = {} 560 | 561 | if (has_scope) or ( 562 | is_legitimate_author 563 | and getattr(handler, "let_author", False) 564 | or per_repo_config_allows 565 | ): 566 | print( 567 | " :: authorisation granted ", 568 | handler.scope, 569 | "custom_rule:", 570 | per_repo_config_allows, 571 | local_config, 572 | ) 573 | is_gen = inspect.isgeneratorfunction(handler) 574 | maybe_gen = handler( 575 | session=session, 576 | payload=payload, 577 | arguments=arguments, 578 | local_config=local_config, 579 | ) 580 | if is_gen: 581 | gen = YieldBreaker(maybe_gen) 582 | for org_repo in gen: 583 | torg, trepo = org_repo.split("/") 584 | target_session = self.auth.get_session(org_repo) 585 | 586 | if target_session: 587 | # TODO, if PR, admin and request is on source repo, allows anyway. 588 | # we may need to also check allow edit from maintainer and provide 589 | # another decorator for safety. 590 | # @access_original_branch. 591 | 592 | if target_session.has_permission( 593 | torg, trepo, user, Permission.write 594 | ) or ( 595 | pr_origin_org_repo == org_repo and allow_edit_from_maintainer 596 | ): 597 | gen.send(target_session) 598 | else: 599 | gen.send(None) 600 | else: 601 | print("org/repo not found", org_repo, self.auth.idmap) 602 | gen.send(None) 603 | else: 604 | try: 605 | comment_url = payload.get("issue", payload.get("pull_request"))[ 606 | "comments_url" 607 | ] 608 | user = payload["comment"]["user"]["login"] 609 | session.post_comment( 610 | comment_url, 611 | f"Awww, sorry {user} you do not seem to be allowed to do that, please ask a repository maintainer.", 612 | ) 613 | except Exception: 614 | import traceback 615 | 616 | traceback.print_exc() 617 | print( 618 | "I Cannot let you do that: requires", 619 | handler.scope.value, 620 | " you have", 621 | permission_level.value, 622 | ) 623 | else: 624 | print("unnknown command", command) 625 | 626 | 627 | class MeeseeksBox: 628 | def __init__(self, commands, config): 629 | add_event("status", {"state": "starting"}) 630 | self.commands = commands 631 | self.application = None 632 | self.config = config 633 | self.port = config.port 634 | self.auth = Authenticator( 635 | self.config.integration_id, 636 | self.config.key, 637 | self.config.personal_account_token, 638 | self.config.personal_account_name, 639 | ) 640 | 641 | def sig_handler(self, sig, frame): 642 | print(yellow, "Caught signal: %s, Shutting down..." % sig, normal) 643 | add_event("status", {"state": "stopping"}) 644 | IOLoop.instance().add_callback_from_signal(self.shutdown) 645 | 646 | def shutdown(self): 647 | print("in shutdown") 648 | self.server.stop() 649 | 650 | io_loop = IOLoop.instance() 651 | 652 | def stop_loop(): 653 | print(red, "stopping now...", normal) 654 | io_loop.stop() 655 | 656 | print(yellow, "stopping soon...", normal) 657 | io_loop.add_timeout(time.time() + 5, stop_loop) 658 | 659 | def start(self): 660 | self.application = tornado.web.Application( 661 | [ 662 | (r"/", MainHandler), 663 | ( 664 | r"/webhook", 665 | WebHookHandler, 666 | { 667 | "actions": self.commands, 668 | "config": self.config, 669 | "auth": self.auth, 670 | }, 671 | ), 672 | ] 673 | ) 674 | 675 | self.server = tornado.httpserver.HTTPServer(self.application) 676 | self.server.listen(self.port) 677 | 678 | # Clear caches once per day. 679 | callback_time_ms = 1000 * 60 * 60 * 24 680 | clear_cache_callback = tornado.ioloop.PeriodicCallback(clear_caches, callback_time_ms) 681 | clear_cache_callback.start() 682 | 683 | loop = IOLoop.instance() 684 | loop.add_callback(self.auth._build_auth_id_mapping) 685 | loop.start() 686 | -------------------------------------------------------------------------------- /meeseeksdev/meeseeksbox/scopes.py: -------------------------------------------------------------------------------- 1 | """ 2 | Define various scopes 3 | """ 4 | 5 | from enum import Enum 6 | 7 | 8 | class Permission(Enum): 9 | none = 0 10 | read = 1 11 | write = 2 12 | admin = 4 13 | 14 | 15 | def admin(function): 16 | function.scope = Permission.admin 17 | return function 18 | 19 | 20 | def read(function): 21 | function.scope = Permission.read 22 | return function 23 | 24 | 25 | def write(function): 26 | function.scope = Permission.write 27 | return function 28 | 29 | 30 | def everyone(function): 31 | function.scope = Permission.none 32 | return function 33 | 34 | 35 | def pr_author(function): 36 | function.let_author = True 37 | return function 38 | -------------------------------------------------------------------------------- /meeseeksdev/meeseeksbox/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Utility functions to work with github. 3 | """ 4 | import datetime 5 | import json 6 | import pipes 7 | import re 8 | import shlex 9 | import subprocess 10 | from typing import Any, Dict, Optional, cast 11 | 12 | import jwt 13 | import requests 14 | 15 | from .scopes import Permission 16 | 17 | green = "\033[0;32m" 18 | yellow = "\033[0;33m" 19 | red = "\033[0;31m" 20 | normal = "\033[0m" 21 | 22 | 23 | API_COLLABORATORS_TEMPLATE = ( 24 | "https://api.github.com/repos/{org}/{repo}/collaborators/{username}/permission" 25 | ) 26 | ACCEPT_HEADER_V3 = "application/vnd.github.v3+json" 27 | ACCEPT_HEADER = "application/vnd.github.machine-man-preview" 28 | ACCEPT_HEADER_KORA = "json,application/vnd.github.korra-preview" 29 | ACCEPT_HEADER_SYMMETRA = "application/vnd.github.symmetra-preview+json" 30 | 31 | """ 32 | Regular expression to relink issues/pr comments correctly. 33 | 34 | Pay attention to not relink things like foo#23 as they already point to a 35 | specific repository. 36 | """ 37 | RELINK_RE = re.compile(r"(?:(?<=[:,\s])|(?<=^))(#\d+)\\b") 38 | 39 | 40 | def add_event(*args): 41 | """Attempt to add an event to keen, print the event otherwise""" 42 | try: 43 | import keen 44 | 45 | keen.add_event(*args) 46 | except Exception: 47 | print("Failed to log keen event:") 48 | print(f" {args}") 49 | 50 | 51 | def run(cmd, **kwargs): 52 | """Print a command and then run it.""" 53 | if isinstance(cmd, str): 54 | cmd = shlex.split(cmd) 55 | print(" ".join(map(pipes.quote, cmd))) 56 | return subprocess.run(cmd, **kwargs) 57 | 58 | 59 | def fix_issue_body( 60 | body, 61 | original_poster, 62 | original_repo, 63 | original_org, 64 | original_number, 65 | migration_requester, 66 | ): 67 | """ 68 | This, for now does only simple fixes, like link to the original issue. 69 | This should be improved to quote mention of people 70 | """ 71 | 72 | body = RELINK_RE.sub(f"{original_org}/{original_repo}\\1", body) 73 | 74 | return f"""{body}\n\n---- 75 | \nOriginally opened as {original_org}/{original_repo}#{original_number} by @{original_poster}, migration requested by @{migration_requester} 76 | """ 77 | 78 | 79 | def fix_comment_body(body, original_poster, original_url, original_org, original_repo): 80 | """ 81 | This, for now does only simple fixes, like link to the original comment. 82 | 83 | This should be improved to quote mention of people 84 | """ 85 | 86 | body = RELINK_RE.sub(f"{original_org}/{original_repo}\\1", body) 87 | 88 | return """[`@{op}` commented]({original_url}): {body}""".format( 89 | op=original_poster, original_url=original_url, body=body 90 | ) 91 | 92 | 93 | class Authenticator: 94 | def __init__( 95 | self, 96 | integration_id: int, 97 | rsadata: Optional[str], 98 | personal_account_token: Optional[str], 99 | personal_account_name: Optional[str], 100 | ): 101 | self.since = int(datetime.datetime.now().timestamp()) 102 | self.duration = 60 * 10 103 | self._token = None 104 | self.integration_id = integration_id 105 | self.rsadata = rsadata 106 | self.personal_account_token = personal_account_token 107 | self.personal_account_name = personal_account_name 108 | self.idmap: Dict[str, str] = {} 109 | self._org_idmap: Dict[str, str] = {} 110 | self._session_class = Session 111 | 112 | def session(self, installation_id: str) -> "Session": 113 | """ 114 | Given and installation id, return a session with the right credentials 115 | """ 116 | # print('spawning session for repo', [(k,v) for k,v in self.idmap.items() if v == installation_id]) 117 | # print('DEBUG: ', self.idmap, installation_id) 118 | return self._session_class( 119 | self.integration_id, 120 | self.rsadata, 121 | installation_id, 122 | self.personal_account_token, 123 | self.personal_account_name, 124 | ) 125 | 126 | def get_session(self, org_repo): 127 | """Given an org and repo, return a session with the right credentials.""" 128 | # First try - see if we already have the auth. 129 | if org_repo in self.idmap: 130 | return self.session(self.idmap[org_repo]) 131 | 132 | # Next try - see if this is a newly authorized repo in an 133 | # org that we've seen. 134 | org, _ = org_repo.split("/") 135 | if org in self._org_idmap: 136 | self._update_installation(self._org_idmap[org]) 137 | if org_repo in self.idmap: 138 | return self.session(self.idmap[org_repo]) 139 | 140 | # TODO: if we decide to allow any org without an allowlist, 141 | # we should make the org list dynamic. We would re-scan 142 | # the list of installations here and update our mappings. 143 | 144 | def list_installations(self) -> Any: 145 | """List the installations for the app.""" 146 | installations = [] 147 | 148 | url = "https://api.github.com/app/installations" 149 | while True: 150 | response = self._integration_authenticated_request("GET", url) 151 | installations.extend(response.json()) 152 | if "next" in response.links: 153 | url = response.links["next"]["url"] 154 | continue 155 | break 156 | 157 | return installations 158 | 159 | def _build_auth_id_mapping(self): 160 | """ 161 | Build an organisation/repo -> installation_id mappingg in order to be able 162 | to do cross repository operations. 163 | """ 164 | if not self.rsadata: 165 | print("Skipping auth_id_mapping build since there is no B64KEY set") 166 | return 167 | 168 | self._installations = self.list_installations() 169 | for installation in self._installations: 170 | self._update_installation(installation) 171 | 172 | def _update_installation(self, installation): 173 | print("Updating installations", installation) 174 | iid = installation["id"] 175 | print("... making a session", iid) 176 | session = self.session(iid) 177 | try: 178 | # Make sure we get all pages. 179 | url = installation["repositories_url"] 180 | while True: 181 | res = session.ghrequest("GET", url) 182 | repositories = res.json() 183 | for repo in repositories["repositories"]: 184 | print( 185 | "Mapping repo to installation:", 186 | repo["full_name"], 187 | repo["owner"]["login"], 188 | iid, 189 | ) 190 | self.idmap[repo["full_name"]] = iid 191 | self._org_idmap[repo["owner"]["login"]] = iid 192 | if "next" in res.links: 193 | url = res.links["next"]["url"] 194 | continue 195 | break 196 | 197 | except Forbidden: 198 | print("Forbidden for", iid) 199 | return 200 | 201 | def _integration_authenticated_request(self, method, url, json=None): 202 | self.since = int(datetime.datetime.now().timestamp()) 203 | payload = dict( 204 | { 205 | "iat": self.since, 206 | "exp": self.since + self.duration, 207 | "iss": self.integration_id, 208 | } 209 | ) 210 | 211 | assert self.rsadata is not None 212 | tok = jwt.encode(payload, key=self.rsadata, algorithm="RS256") 213 | 214 | headers = { 215 | "Authorization": f"Bearer {tok}", 216 | "Accept": ACCEPT_HEADER_V3, 217 | "Host": "api.github.com", 218 | "User-Agent": "python/requests", 219 | } 220 | req = requests.Request(method, url, headers=headers, json=json) 221 | prepared = req.prepare() 222 | with requests.Session() as s: 223 | return s.send(prepared) # type:ignore[attr-defined] 224 | 225 | 226 | class Forbidden(Exception): 227 | pass 228 | 229 | 230 | class Session(Authenticator): 231 | def __init__( 232 | self, 233 | integration_id, 234 | rsadata, 235 | installation_id, 236 | personal_account_token, 237 | personal_account_name, 238 | ): 239 | super().__init__(integration_id, rsadata, personal_account_token, personal_account_name) 240 | self.installation_id = installation_id 241 | 242 | def token(self) -> str: 243 | now = datetime.datetime.now().timestamp() 244 | if (now > self.since + self.duration - 60) or (self._token is None): 245 | self.regen_token() 246 | assert self._token is not None 247 | return self._token 248 | 249 | def regen_token(self) -> None: 250 | method = "POST" 251 | url = f"https://api.github.com/app/installations/{self.installation_id}/access_tokens" 252 | resp = self._integration_authenticated_request(method, url) 253 | if resp.status_code == 403: 254 | raise Forbidden(self.installation_id) 255 | 256 | try: 257 | self._token = json.loads(resp.content.decode())["token"] 258 | except Exception: 259 | raise ValueError(resp.content, url) 260 | 261 | def personal_request( 262 | self, 263 | method: str, 264 | url: str, 265 | json: Optional[dict] = None, 266 | raise_for_status: bool = True, 267 | ) -> requests.Response: 268 | """ 269 | Does a request but using the personal account name and token 270 | """ 271 | if not json: 272 | json = {} 273 | 274 | def prepare(): 275 | headers = { 276 | "Authorization": f"token {self.personal_account_token}", 277 | "Host": "api.github.com", 278 | "User-Agent": "python/requests", 279 | } 280 | req = requests.Request(method, url, headers=headers, json=json) 281 | return req.prepare() 282 | 283 | with requests.Session() as s: 284 | response = s.send(prepare()) # type:ignore[attr-defined] 285 | if response.status_code == 401: 286 | self.regen_token() 287 | response = s.send(prepare()) # type:ignore[attr-defined] 288 | if raise_for_status: 289 | response.raise_for_status() 290 | return response # type:ignore[no-any-return] 291 | 292 | def ghrequest( 293 | self, 294 | method: str, 295 | url: str, 296 | json: Optional[dict] = None, 297 | *, 298 | override_accept_header: Optional[str] = None, 299 | raise_for_status: Optional[bool] = True, 300 | ) -> requests.Response: 301 | accept = ACCEPT_HEADER 302 | if override_accept_header: 303 | accept = override_accept_header 304 | 305 | def prepare(): 306 | atk = self.token() 307 | headers = { 308 | "Authorization": f"Bearer {atk}", 309 | "Accept": accept, 310 | "Host": "api.github.com", 311 | "User-Agent": "python/requests", 312 | } 313 | print(f"Making a {method} call to {url}") 314 | req = requests.Request(method, url, headers=headers, json=json) 315 | return req.prepare() 316 | 317 | with requests.Session() as s: 318 | response = s.send(prepare()) # type:ignore[attr-defined] 319 | if response.status_code == 401: 320 | print("Unauthorized, regen token") 321 | self.regen_token() 322 | response = s.send(prepare()) # type:ignore[attr-defined] 323 | if raise_for_status: 324 | response.raise_for_status() 325 | rate_limit = response.headers.get("X-RateLimit-Limit", -1) 326 | rate_remaining = response.headers.get("X-RateLimit-Limit", -1) 327 | if rate_limit: 328 | repo_name_list = [k for k, v in self.idmap.items() if v == self.installation_id] 329 | repo_name = "no-repo" 330 | if len(repo_name_list) == 1: 331 | repo_name = repo_name_list[0] 332 | elif len(repo_name_list) == 0: 333 | repo_name = "no-matches" 334 | else: 335 | repo_name = "multiple-matches" 336 | 337 | add_event( 338 | "gh-rate", 339 | { 340 | "limit": int(rate_limit), 341 | "rate_remaining": int(rate_remaining), 342 | "installation": repo_name, 343 | }, 344 | ) 345 | return response # type:ignore[no-any-return] 346 | 347 | def _get_permission(self, org: str, repo: str, username: str) -> Permission: 348 | get_collaborators_query = API_COLLABORATORS_TEMPLATE.format( 349 | org=org, repo=repo, username=username 350 | ) 351 | print("_get_permission") 352 | resp = self.ghrequest( 353 | "GET", 354 | get_collaborators_query, 355 | None, 356 | override_accept_header=ACCEPT_HEADER_KORA, 357 | ) 358 | resp.raise_for_status() 359 | permission = resp.json()["permission"] 360 | print("found permission", permission, "for user ", username, "on ", org, repo) 361 | return cast(Permission, getattr(Permission, permission)) 362 | 363 | def has_permission( 364 | self, org: str, repo: str, username: str, level: Optional[Permission] = None 365 | ) -> bool: 366 | """ """ 367 | if not level: 368 | level = Permission.none 369 | 370 | return self._get_permission(org, repo, username).value >= level.value 371 | 372 | def post_comment(self, comment_url: str, body: str) -> None: 373 | self.ghrequest("POST", comment_url, json={"body": body}) 374 | 375 | def get_collaborator_list(self, org: str, repo: str) -> Optional[Any]: 376 | get_collaborators_query = "https://api.github.com/repos/{org}/{repo}/collaborators".format( 377 | org=org, repo=repo 378 | ) 379 | resp = self.ghrequest("GET", get_collaborators_query, None) 380 | if resp.status_code == 200: 381 | return resp.json() 382 | else: 383 | resp.raise_for_status() 384 | return None 385 | 386 | def create_issue( 387 | self, 388 | org: str, 389 | repo: str, 390 | title: str, 391 | body: str, 392 | *, 393 | labels: Optional[list] = None, 394 | assignees: Optional[list] = None, 395 | ) -> requests.Response: 396 | arguments: dict = {"title": title, "body": body} 397 | 398 | if labels: 399 | if type(labels) in (list, tuple): 400 | arguments["labels"] = labels 401 | else: 402 | raise ValueError("Labels must be a list of a tuple") 403 | 404 | if assignees: 405 | if type(assignees) in (list, tuple): 406 | arguments["assignees"] = assignees 407 | else: 408 | raise ValueError("Assignees must be a list or a tuple") 409 | 410 | return self.ghrequest( 411 | "POST", 412 | f"https://api.github.com/repos/{org}/{repo}/issues", 413 | json=arguments, 414 | ) 415 | 416 | 417 | def clear_caches(): 418 | """Clear local caches""" 419 | print("\n\n====Clearing all caches===") 420 | run("pip cache purge") 421 | run("pre-commit clean") 422 | print("====Finished clearing caches===\n\n") 423 | -------------------------------------------------------------------------------- /meeseeksdev/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scientific-python/MeeseeksDev/4eb5d9aedd8abd66df62857dbbafb059574200a2/meeseeksdev/tests/__init__.py -------------------------------------------------------------------------------- /meeseeksdev/tests/test_misc.py: -------------------------------------------------------------------------------- 1 | import re 2 | import textwrap 3 | 4 | from ..meeseeksbox.core import process_mentioning_comment 5 | 6 | 7 | def test1(): 8 | botname = "meeseeksdev" 9 | reg = re.compile("@?" + re.escape(botname) + r"(?:\[bot\])?", re.IGNORECASE) 10 | 11 | assert ( 12 | process_mentioning_comment( 13 | textwrap.dedent( 14 | """ 15 | @meeseeksdev nothing 16 | @meeseeksdev[bot] do nothing 17 | meeseeksdev[bot] do something 18 | @meeseeksdev please nothing 19 | @meeseeksdev run something 20 | 21 | 22 | """ 23 | ), 24 | reg, 25 | ) 26 | == [ 27 | ["nothing", None], 28 | ["do", "nothing"], 29 | ["do", "something"], 30 | ["nothing", None], 31 | ["something", None], 32 | ] 33 | ) 34 | -------------------------------------------------------------------------------- /meeseeksdev/tests/test_webhook.py: -------------------------------------------------------------------------------- 1 | import hmac 2 | 3 | import pytest 4 | import tornado.web 5 | 6 | from ..meeseeksbox.core import Authenticator, Config, WebHookHandler 7 | 8 | commands: dict = {} 9 | 10 | config = Config( 11 | integration_id=100, 12 | key=None, 13 | personal_account_token="foo", 14 | personal_account_name="bar", 15 | forward_staging_url="", 16 | webhook_secret="foo", 17 | ) 18 | 19 | auth = Authenticator( 20 | config.integration_id, 21 | config.key, 22 | config.personal_account_token, 23 | config.personal_account_name, 24 | ) 25 | 26 | application = tornado.web.Application( 27 | [ 28 | ( 29 | r"/", 30 | WebHookHandler, 31 | { 32 | "actions": commands, 33 | "config": config, 34 | "auth": auth, 35 | }, 36 | ), 37 | ] 38 | ) 39 | 40 | 41 | @pytest.fixture 42 | def app(): 43 | return application 44 | 45 | 46 | async def test_get(http_server_client): 47 | response = await http_server_client.fetch("/") 48 | assert response.code == 200 49 | 50 | 51 | async def test_post(http_server_client): 52 | body = "{}" 53 | secret = config.webhook_secret 54 | assert secret is not None 55 | sig = "sha1=" + hmac.new(secret.encode("utf8"), body.encode("utf8"), "sha1").hexdigest() 56 | headers = {"X-Hub-Signature": sig} 57 | response = await http_server_client.fetch("/", method="POST", body=body, headers=headers) 58 | assert response.code == 200 59 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "meeseeksdev", 3 | "version": "1.0.0", 4 | "description": "stub file for node.js buildpack", 5 | "private": true 6 | } 7 | -------------------------------------------------------------------------------- /requirements-test.txt: -------------------------------------------------------------------------------- 1 | -r ./requirements.txt 2 | pytest>=6.0 3 | pytest-tornasync 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | black==24.3.0 2 | cryptography==44.0.1 3 | gitpython==3.1.41 4 | keen==0.7 5 | mock==4.0 6 | pip==23.3 7 | pre-commit==2.17 8 | pyjwt==2.4.0 9 | pyyaml==6.0 10 | requests==2.32.0 11 | there==0.0.9 12 | tornado==6.4.2 13 | yieldbreaker==0.0.1 14 | -------------------------------------------------------------------------------- /runtime.txt: -------------------------------------------------------------------------------- 1 | python-3.12.4 2 | --------------------------------------------------------------------------------