├── .dockerignore ├── .flake8 ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .isort.cfg ├── .pre-commit-config.yaml ├── .towncrier-template.rst ├── Dockerfile ├── HISTORY.rst ├── LICENSE ├── README.rst ├── docker-compose.yml ├── environment.sample ├── newsfragments └── .gitignore ├── pyproject.toml ├── pytest.ini ├── requirements-test.txt ├── requirements.txt ├── setup.cfg ├── setup.py ├── src └── oca_github_bot │ ├── __init__.py │ ├── __main__.py │ ├── build_wheels.py │ ├── commands.py │ ├── config.py │ ├── cron.py │ ├── github.py │ ├── manifest.py │ ├── odoo_client.py │ ├── process.py │ ├── pypi.py │ ├── queue.py │ ├── router.py │ ├── tasks │ ├── __init__.py │ ├── add_pr_comment.py │ ├── delete_branch.py │ ├── heartbeat.py │ ├── main_branch_bot.py │ ├── mention_maintainer.py │ ├── merge_bot.py │ ├── migration_issue_bot.py │ ├── rebase_bot.py │ ├── tag_approved.py │ ├── tag_needs_review.py │ └── tag_ready_to_merge.py │ ├── utils.py │ ├── version_branch.py │ └── webhooks │ ├── __init__.py │ ├── on_command.py │ ├── on_pr_close_delete_branch.py │ ├── on_pr_green_label_needs_review.py │ ├── on_pr_open_label_new_contributor.py │ ├── on_pr_open_mention_maintainer.py │ ├── on_pr_review.py │ ├── on_push_to_main_branch.py │ └── on_status_merge_bot.py ├── tests ├── __init__.py ├── cassettes │ ├── test_create_or_find_branch_milestone.yaml │ ├── test_exists_on_index[not_a_pkg-1.0-py3-none-any.whl-False].yaml │ ├── test_exists_on_index[pip-20.4-py3-none-any.whl-False].yaml │ ├── test_exists_on_index[pip-21.0.1-py3-none-any.whl-True].yaml │ ├── test_find_issue.yaml │ ├── test_set_lines_issue.yaml │ ├── test_twine_publisher_file_exists.yaml │ ├── test_user_can_merge_maintainer.yaml │ ├── test_user_can_merge_not_maintainer.yaml │ ├── test_user_can_merge_not_maintainer_hacker.yaml │ └── test_user_can_merge_team_member.yaml ├── common.py ├── conftest.py ├── test_build_wheels.py ├── test_commands.py ├── test_git.py ├── test_git_push_if_needed.py ├── test_github_failure.py ├── test_manifest.py ├── test_mention_maintainer.py ├── test_merge_bot.py ├── test_migration_issue_bot.py ├── test_on_command.py ├── test_on_pr_close_delete_branch.py ├── test_on_pr_green_label_needs_review.py ├── test_on_push_to_main_branch.py ├── test_on_status_merge_bot.py ├── test_pypi.py ├── test_switchable.py ├── test_utils.py └── test_version_branch.py └── tox.ini /.dockerignore: -------------------------------------------------------------------------------- 1 | data 2 | .tox 3 | .env* 4 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 80 3 | max-complexity = 16 4 | # B = bugbear 5 | # B9 = bugbear opinionated (incl line length) 6 | select = C,E,F,W,B,B9 7 | # E203: whitespace before ':' (black behaviour) 8 | # E501: flake8 line length (covered by bugbear B950) 9 | # W503: line break before binary operator (black behaviour) 10 | ignore = E203,E501,W503 11 | exclude = 12 | ./.git 13 | .eggs/ 14 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | 7 | jobs: 8 | tests: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: actions/setup-python@v5 13 | with: 14 | python-version: "3.12" 15 | - name: Install tox 16 | run: python -m pip install tox 17 | - name: Run tox 18 | run: python -m tox 19 | - uses: codecov/codecov-action@v3 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /data/ 2 | /tmp/ 3 | /environment* 4 | /.vscode 5 | 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Pycharm 60 | .idea 61 | 62 | # Eclipse 63 | .settings 64 | 65 | # Visual Studio cache/options directory 66 | .vs/ 67 | 68 | # OSX Files 69 | .DS_Store 70 | 71 | # Django stuff: 72 | *.log 73 | local_settings.py 74 | db.sqlite3 75 | 76 | # Flask stuff: 77 | instance/ 78 | .webassets-cache 79 | 80 | # Scrapy stuff: 81 | .scrapy 82 | 83 | # Sphinx documentation 84 | docs/_build/ 85 | 86 | # PyBuilder 87 | target/ 88 | 89 | # Jupyter Notebook 90 | .ipynb_checkpoints 91 | 92 | # pyenv 93 | .python-version 94 | 95 | # celery beat schedule file 96 | celerybeat-schedule 97 | 98 | # SageMath parsed files 99 | *.sage.py 100 | 101 | # Environments 102 | .env* 103 | .venv 104 | env/ 105 | venv/ 106 | ENV/ 107 | env.bak/ 108 | venv.bak/ 109 | 110 | # Spyder project settings 111 | .spyderproject 112 | .spyproject 113 | 114 | # Rope project settings 115 | .ropeproject 116 | 117 | # mkdocs documentation 118 | /site 119 | 120 | # mypy 121 | .mypy_cache/ 122 | 123 | # dotenv files 124 | .env* 125 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | ; see https://github.com/ambv/black 3 | multi_line_output=3 4 | include_trailing_comma=True 5 | force_grid_wrap=0 6 | combine_as_imports=True 7 | use_parentheses=True 8 | line_length=88 9 | known_first_party=oca_github_bot 10 | known_third_party = aiohttp,appdirs,celery,gidgethub,github3,lxml,odoorpc,packaging,pytest,requests,setuptools 11 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_language_version: 2 | python: python3 3 | repos: 4 | - repo: https://github.com/psf/black 5 | rev: 24.10.0 6 | hooks: 7 | - id: black 8 | - repo: https://github.com/pre-commit/pre-commit-hooks 9 | rev: v5.0.0 10 | hooks: 11 | - id: trailing-whitespace 12 | - id: end-of-file-fixer 13 | - id: debug-statements 14 | - id: fix-encoding-pragma 15 | args: ["--remove"] 16 | - id: check-case-conflict 17 | - id: check-docstring-first 18 | - id: check-executables-have-shebangs 19 | - id: check-merge-conflict 20 | - id: check-symlinks 21 | - id: check-xml 22 | - id: check-yaml 23 | - id: mixed-line-ending 24 | args: ["--fix=lf"] 25 | - repo: https://github.com/pycqa/flake8 26 | rev: 7.1.1 27 | hooks: 28 | - id: flake8 29 | name: flake8 except __init__.py 30 | args: [--exclude=__init__.py] 31 | additional_dependencies: ["flake8-bugbear==20.1.4"] 32 | - id: flake8 33 | name: flake8 only __init__.py 34 | args: [--extend-ignore=F401] # ignore imported unused in __init__.py 35 | files: __init__.py 36 | - repo: https://github.com/asottile/pyupgrade 37 | rev: v3.18.0 38 | hooks: 39 | - id: pyupgrade 40 | - repo: https://github.com/asottile/seed-isort-config 41 | rev: v2.2.0 42 | hooks: 43 | - id: seed-isort-config 44 | - repo: https://github.com/PyCQA/isort 45 | rev: 5.13.2 46 | hooks: 47 | - id: isort 48 | -------------------------------------------------------------------------------- /.towncrier-template.rst: -------------------------------------------------------------------------------- 1 | {% for section, _ in sections.items() %} 2 | {% if section %}{{section}} 3 | {{ '~' * section|length }} 4 | 5 | {% endif %} 6 | 7 | {% if sections[section] %} 8 | {% for category, val in definitions.items() if category in sections[section]%} 9 | **{{ definitions[category]['name'] }}** 10 | 11 | {% if definitions[category]['showcontent'] %} 12 | {% for text, values in sections[section][category].items() %} 13 | - {{ text }} ({{ values|join(', ') }}) 14 | {% endfor %} 15 | 16 | {% else %} 17 | - {{ sections[section][category]['']|join(', ') }} 18 | 19 | {% endif %} 20 | {% if sections[section][category]|length == 0 %} 21 | No significant changes. 22 | 23 | {% else %} 24 | {% endif %} 25 | 26 | {% endfor %} 27 | {% else %} 28 | No significant changes. 29 | 30 | 31 | {% endif %} 32 | {% endfor %} 33 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:24.04 2 | LABEL maintainer="Odoo Community Association (OCA)" 3 | 4 | ENV LANG=C.UTF-8 \ 5 | LC_ALL=C.UTF-8 \ 6 | DEBIAN_FRONTEND=noninteractive 7 | 8 | ARG PY=3.12 9 | 10 | # binutils is needed for the ar command, used by pypandoc.ensure_pandoc_installed() 11 | RUN set -x \ 12 | && apt-get update \ 13 | && apt-get install -y --no-install-recommends \ 14 | binutils \ 15 | ca-certificates \ 16 | curl \ 17 | git \ 18 | python${PY}-venv \ 19 | rsync \ 20 | openssh-client \ 21 | && rm -rf /var/lib/apt/lists/* 22 | 23 | # The main branch bot needs several other command line tools from in OCA/maintainer-tools 24 | # we install them in a separate virtualenv to avoid polluting our main environment. 25 | 26 | # Other oca maintainer tools that are less sensitive to changes. The README generator is 27 | # not as sensitive as before because it now stores a hash of the fragments in the 28 | # generated README.rst, so it will only regenerate if the fragments have changed. 29 | RUN set -x \ 30 | && python${PY} -m venv /ocamt \ 31 | && /ocamt/bin/pip install --no-cache-dir -U pip wheel 32 | RUN set -x \ 33 | && /ocamt/bin/pip install --no-cache-dir -e git+https://github.com/OCA/maintainer-tools@f9b919b9868143135a9c9cb03021089cabba8223#egg=oca-maintainers-tools \ 34 | && ln -s /ocamt/bin/oca-gen-addons-table /usr/local/bin/ \ 35 | && ln -s /ocamt/bin/oca-gen-addon-readme /usr/local/bin/ \ 36 | && ln -s /ocamt/bin/oca-gen-addon-icon /usr/local/bin/ \ 37 | && ln -s /ocamt/bin/oca-gen-metapackage /usr/local/bin/ \ 38 | && ln -s /ocamt/bin/oca-towncrier /usr/local/bin/ \ 39 | && ln -s /ocamt/bin/setuptools-odoo-make-default /usr/local/bin/ \ 40 | && ln -s /ocamt/bin/whool /usr/local/bin 41 | 42 | # isolate from system python libraries 43 | RUN set -x \ 44 | && python${PY} -m venv /app \ 45 | && /app/bin/pip install --no-cache-dir -U pip wheel 46 | ENV PATH=/app/bin:$PATH 47 | 48 | # install oca_github_bot dependencies, in a separate layer for improved caching 49 | COPY requirements.txt /tmp/requirements.txt 50 | RUN pip install --no-cache-dir -r /tmp/requirements.txt 51 | 52 | # install oca_github_bot app 53 | COPY . /app/src/oca-github-bot 54 | RUN pip install --no-cache-dir -e /app/src/oca-github-bot 55 | 56 | # make work and home directory 57 | RUN mkdir /app/run && chmod ogu+rwx /app/run 58 | ENV HOME=/app/run 59 | WORKDIR /app/run 60 | -------------------------------------------------------------------------------- /HISTORY.rst: -------------------------------------------------------------------------------- 1 | v20240706 2 | ~~~~~~~~~ 3 | 4 | **Bugfixes** 5 | 6 | - Fix regression in nightly main branch bot. (`#279 `_) 7 | - Upgrade to Python 3.12 (`#293 `_) 8 | - Upgrade sentry client 9 | - Upgrade to latest OCA/maintainer-tools 10 | 11 | 12 | v20231216 13 | ~~~~~~~~~ 14 | 15 | **Features** 16 | 17 | - Skip fork repos earlier in main branch bot, for better performance in organisations 18 | with a large number of forks. (`#277 `_) 19 | - Support generating ``pyproject.toml`` instead of ``setup.py`` (`#266 `_) 20 | - Upgraded maintainer-tools (`#275 `_) 21 | - Look for migration issues in all states in ``/ocabot migration`` command (`#216 `_) 22 | 23 | **Bugfixes** 24 | 25 | - Start wheel build from an empty directory, to avoid accidentally importing 26 | python files from the addon root directory during the build process. (`#270 `_) 27 | - Fixed rendering of OdooSeriesNotFound exceptions (`#274 `_) 28 | 29 | 30 | v20231013 31 | ~~~~~~~~~ 32 | 33 | **Features** 34 | 35 | - Add ``MAIN_BRANCH_BOT_MIN_VERSION`` config option to declare the minimum Odoo series 36 | for which the main branch bot actions runs. (`#252 `_) 37 | - Upgrade to latest ``maintainer-tools``, and use ``oca-gen-addon-readme 38 | --if-source-changed`` to avoid full regenaration of all readme when we upgrade 39 | the README generator. (`#256 `_) 40 | 41 | 42 | **Bugfixes** 43 | 44 | - Add binutils to Dockerfile to fix pandoc installer. (`#259 `_) 45 | 46 | 47 | v20230619 48 | ~~~~~~~~~ 49 | 50 | **Bugfixes** 51 | 52 | - Fix merge command regression introduced in previous release. (`#246 `_) 53 | - Sanity check the virtual environment used to build wheels. 54 | 55 | v20230617.1 56 | ~~~~~~~~~~~ 57 | 58 | **Bugfixes** 59 | 60 | - Fix issue where some command errors where not reported as comments on the PR. (`#244 `_) 61 | 62 | 63 | v20230617 64 | ~~~~~~~~~ 65 | 66 | **Bugfixes** 67 | 68 | - ``/ocabot migration``: Automaticaly check line in Migration issue when merging the according Pull Request. (`#192 `_) 69 | - ``/ocabot migration``: A new migration PR can overwrite the PR in the migration issue only if the latter is closed. (`#218 `_) 70 | 71 | v20221019 72 | ~~~~~~~~~ 73 | 74 | **Features** 75 | 76 | - When calling ``/ocabot migration`` if a previous Pull Request was referenced in the migration issue, post an alert on the new Pull Request to mention the previous work. (`#191 `_) 77 | - Refactored wheel builder, adding support for ``pyproject.toml`` in addon directories, 78 | towards removing ``setup`` directories. (`#212 `_) 79 | - Update pinned dependencies. (`#213 `_) 80 | 81 | 82 | **Bugfixes** 83 | 84 | - Search for addons maintainers in all the branches of the current repository. (`#183 `_) 85 | - Make the command ``/ocabot migration`` working when migration issue doesn't contain migration module lines. (`#190 `_) 86 | - Tweak /ocabot usage presentation. (`#199 `_) 87 | 88 | 89 | v20220518 90 | ~~~~~~~~~ 91 | 92 | **Features** 93 | 94 | - Added support for Odoo 15 (via a setuptools-odoo and maintainer-tools update). (`#156 `_) 95 | 96 | **Bugfixes** 97 | 98 | - Fixed the mention to maintainers on new pull requests. Also mention maintainers 99 | when a PR is reopened. (`#166 `_) 100 | - Try to avoid git fetch lock issues and sentry alerts pollution by retrying 101 | automatically. (`#177 `_) 102 | - Reduce error noise by suppressing BranchNotFoundError in then merge branch status 103 | handler. (`#178 `_) 104 | - Consider comment made when reviewing Pull request. It so allow users 105 | to review and launch commands in a single action. (`#182 `_) 106 | 107 | **Misc** 108 | 109 | - Update dependencies, drop support for python 3.6 and 3.7. Test with python 3.10. `#175 110 | `_ 111 | 112 | 113 | v20211206 114 | ~~~~~~~~~ 115 | 116 | **Bugfixes** 117 | 118 | The GitHub token used by the bot could be leaked into GitHub comments on pull requests 119 | in some circumstances. Please upgrade and rotate tokens. 120 | 121 | **Features** 122 | 123 | - Add "/ocabot migration" command, to link a PR to the migration issue and set the 124 | milestone. (`#97 `_) 125 | - Added support for Odoo 15 (via a setuptools-odoo and maintainer-tools update). (`#156 `_) 126 | 127 | **Other** 128 | 129 | - Improved layer caching in the Dockerfile 130 | 131 | 20210813 132 | ~~~~~~~~ 133 | 134 | **Feature** 135 | 136 | - Dockerfile: new version of oca-gen-addons-table 137 | - Improved dry-run mode for the wheel publisher 138 | - Better handling of non-fresh index pages in wheel publisher 139 | - Do not call for maintainers when the PR does not modifies any addon 140 | - Add /ocabot rebase command 141 | 142 | **Other** 143 | 144 | - Use Celery 5 145 | 146 | 20210321 147 | ~~~~~~~~ 148 | 149 | **Feature** 150 | 151 | - Upload wheels to a package index with twine. 152 | - Pre-install setuptools-odoo in the docker image, so wheel builds run faster. 153 | 154 | 20210228 155 | ~~~~~~~~ 156 | 157 | **Features** 158 | 159 | - Add a call to maintainers when a PR is made to addons that have no declared 160 | maintainers. (`#130 `_) 161 | - Refresh all pinned dependencies in requirements.txt. (`#140 `_) 162 | - Ignore check suites that have no check runs. This should cope repos that have 163 | no ``.travis.yml`` but where Travis is enabled at organization level. (`#141 `_) 164 | 165 | 166 | 20210131 167 | ~~~~~~~~ 168 | 169 | **Features** 170 | 171 | - Add the possibility to set multiple github organizations in GITHUB_ORG setting 172 | (for organization wide scheduled tasks) (`#127 `_) 173 | - Build and publish metapackage wheel from ``setup/_metapackage`` in main branch 174 | bot task. (`#133 `_) 175 | 176 | **Bugfixes** 177 | 178 | - ocabot merge: only mention maintainers existing before the PR. (`#131 `_) 179 | 180 | **Miscellaneous** 181 | 182 | - Upgrade ``setuptools-odoo`` to 2.6.3 in Docker image 183 | 184 | 185 | 20200719 186 | ~~~~~~~~ 187 | 188 | **Features** 189 | 190 | - Add more logging of status and check suites results. (`#121 `_) 191 | - Publish wheels also in nobump mode. This exception was probably done with the 192 | goal of saving space, but for migration PRs where people use ``ocabot merge 193 | nobump``, we want to publish too. (`#123 `_) 194 | 195 | 196 | 20200530 197 | ~~~~~~~~ 198 | 199 | **Features** 200 | 201 | - Ignore Dependabot by default in check-suite ignores, along with Codecov. (`#115 `_) 202 | 203 | 204 | **Bugfixes** 205 | 206 | - Update maintainer-tools to get the latest ``oca-gen-addon-tables``. It fixes a 207 | regression where the main branch operations were failing when ``README.md`` is 208 | absent. (`#118 `_) 209 | 210 | 211 | 20200415 212 | ~~~~~~~~ 213 | 214 | **Features** 215 | 216 | - Make ``bumpversion_mode`` option required on ``merge`` command, adding ``nobump`` option that was before implicit. 217 | Bot adds comment on github, if the command is wrong. Message are customizable in the ``environment`` file. (`#90 `_) 218 | - Make ``GITHUB_STATUS_IGNORED`` and ``GITHUB_CHECK_SUITES_IGNORED`` configurable. (`#111 `_) 219 | - Add ``BOT_TASKS_DISABLED``. (`#112 `_) 220 | 221 | 222 | 20200328 223 | ~~~~~~~~ 224 | 225 | **Features** 226 | 227 | - ocabot merge: add a "bot is merging ⏳" PR label during the test 228 | and merge operation. (`#73 `_) 229 | - Add three new settings available in the ``environment`` file that allow to add 230 | extra argument, when calling the libraries ``oca-gen-addons-table``, 231 | ``oca-gen-addon-readme`` and ``oca-gen-addon-icon``. (`#103 232 | `_) 233 | - Make the "ocabot merge" command update ``HISTORY.rst`` from news fragments in 234 | ``readme/newsfragments`` using `towncrier 235 | `_. (`#106 236 | `_) 237 | - Add ``APPROVALS_REQUIRED`` and ``MIN_PR_AGE`` configuration options to 238 | control the conditions to set the ``Approved`` label. (`#107 239 | `_) 240 | 241 | 242 | 20191226 243 | ~~~~~~~~ 244 | 245 | **Bug fixes** 246 | 247 | - do not fail on ``twine check`` when an addon has no ``setup.py`` 248 | `#96 `_ 249 | 250 | 20191126 251 | ~~~~~~~~ 252 | 253 | **Bug fixes** 254 | 255 | - do not mention maintainers when they open PR to module they maintain 256 | `#92 `_ 257 | - do not mention maintainers more than once 258 | `#91 `_ 259 | 260 | 20191027 261 | ~~~~~~~~ 262 | 263 | **Features** 264 | 265 | - mention maintainers in pull requests to their addons 266 | `#77 `_ 267 | 268 | **Bug fixes** 269 | 270 | - main branch bot: do not run on forks on pushes too, not only in cron jobs 271 | 272 | **Misc** 273 | 274 | - prune removed remote branches in git cache 275 | - make ``git_get_modified_addons`` (use rebase instead of merge) 276 | 277 | 20191017 278 | ~~~~~~~~ 279 | 280 | **Bug fixes** 281 | 282 | - Ignore /ocabot merge commands in quoted replies (lines starting with >). 283 | 284 | **Misc** 285 | 286 | - Better logging of subprocess output, for Sentry support. 287 | - Do not change current directory so a multithreaded task worker should be safe. 288 | 289 | 20191004 290 | ~~~~~~~~ 291 | 292 | **Misc** 293 | 294 | - Bump setuptools-odoo version for Odoo 13 support. 295 | 296 | 20190923 297 | ~~~~~~~~ 298 | 299 | **Bug fixes** 300 | 301 | - Do not bump version nor attempt to generate wheels for addons 302 | that are not installable. 303 | 304 | 20190904.1 305 | ~~~~~~~~~~ 306 | 307 | **Features** 308 | 309 | - Improved command parser (#53) 310 | - Call external tools with universal_newlines=True for better 311 | output capture (unicode instead of binary) and, in particular, 312 | better display of errors in merge bot. 313 | - Better detection of modified addons (using diff after rebase instead 314 | of diff to merge base). 315 | - merge bot: allow addon maintainers to merge (#51) 316 | - main branch bot: ignore repos that are forks of other repos when 317 | running the main branch bot actions in the nightly cron 318 | - main branch bot: do not run the organization-wide nightly crons if 319 | GITHUB_ORG is not set 320 | - merge bot: do not rebase anymore, create a merge commit 321 | 322 | **Bug fixes** 323 | 324 | - Do not attempt to build wheels for uninstallable addons. 325 | - Fix issue in detecting modified setup directory. 326 | - When rsyncing wheels to the simple index, use default directory 327 | permissions on the target 328 | 329 | v20190729.1 330 | ~~~~~~~~~~~ 331 | 332 | **Bug fixes** 333 | 334 | - Update OCA/maintainer-tools to correctly pin docutils 0.15.1. 335 | - Fix traceback in on_pr_green_label_needs_review. 336 | 337 | v20190729 338 | ~~~~~~~~~ 339 | 340 | **Features** 341 | 342 | - Build and publish wheels to a PEP 503 simple index. Publishing occurs 343 | on /ocabot merge with version bump, and after the nightly main branch 344 | actions. 345 | - Simplify the docker image, removing gosu. Run under user 1000 in 346 | /var/run by default. Can be influenced using docker --user or similar. 347 | The default docker-compose.yml needs UID and GID environment variables. 348 | 349 | **Bug fixes** 350 | 351 | - Merge bot: fix detection of modified addons in case main branch was modified 352 | since the PR was created. 353 | - Update OCA/maintainer-tools to pin docutils 0.15.1 354 | (see https://github.com/OCA/maintainer-tools/issues/423). 355 | 356 | v20190708 357 | ~~~~~~~~~ 358 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2018 ACSONE SA/NV 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ############## 2 | OCA GitHub bot 3 | ############## 4 | 5 | .. image:: https://results.pre-commit.ci/badge/github/OCA/oca-github-bot/master.svg 6 | :target: https://results.pre-commit.ci/latest/github/OCA/oca-github-bot/master 7 | :alt: pre-commit.ci status 8 | .. image:: https://github.com/OCA/oca-github-bot/actions/workflows/ci.yml/badge.svg 9 | :target: https://github.com/OCA/oca-github-bot/actions/workflows/ci.yml 10 | :alt: GitHub CI status 11 | 12 | The goal of this project is to collect in one place: 13 | 14 | * all operations that react to GitHub events, 15 | * all operations that act on GitHub repos on a scheduled basis. 16 | 17 | This will make it easier to review changes, as well as monitor and manage 18 | these operations, compared to the current situations where these functions 19 | are spread across cron jobs and ad-hoc scripts. 20 | 21 | **Table of contents** 22 | 23 | .. contents:: 24 | :local: 25 | 26 | Features 27 | ======== 28 | 29 | On pull request open 30 | -------------------- 31 | 32 | Mention declared maintainers that addons they maintain are being modified. 33 | 34 | Comment with a call for maintainers if there are no declared maintainer. 35 | 36 | On pull request close 37 | --------------------- 38 | 39 | Auto-delete pull request branch 40 | When a pull request is merged from a branch in the same repo, 41 | the bot deletes the source branch. 42 | 43 | On push to main branches 44 | ------------------------ 45 | 46 | Repo addons table generator in README.md 47 | For addons repositories, update the addons table in README.md. 48 | 49 | Addon README.rst generator 50 | For addons repositories, generate README.rst from readme fragments 51 | in each addon directory, and push changes back to github. 52 | 53 | Addon icon generator 54 | For addons repositories, put default OCA icon in each addon that don't have 55 | yet any icon, and push changes back to github. 56 | 57 | setup.py generator 58 | For addons repositories, run setuptools-odoo-make-defaults, and push 59 | changes back to github. 60 | 61 | These actions are also run nightly on all repos. 62 | 63 | Also nightly, wheels are generated for all addons repositories and rsynced 64 | to a PEP 503 simple index or twine uploaded to compatible indexes. 65 | 66 | On Pull Request review 67 | ---------------------- 68 | 69 | When there are two approvals, set the ``approved`` label. 70 | When the PR is at least 5 days old, set the ``ready to merge`` label. 71 | 72 | On Pull Request CI status 73 | ------------------------- 74 | 75 | When the CI in a Pull Request goes green, set the ``needs review`` label, 76 | unless it has ``wip:`` or ``[wip]`` in it's title. 77 | 78 | Commands 79 | -------- 80 | 81 | One can ask the bot to perform some tasks by entering special commands 82 | as merge request comments. 83 | 84 | ``/ocabot merge`` followed by one of ``major``, ``minor``, ``patch`` or ``nobump`` 85 | can be used to ask the bot to do the following: 86 | 87 | * merge the PR onto a temporary branch created off the target branch 88 | * merge when tests on the rebased branch are green 89 | * optionally bump the version number of the addons modified by the PR 90 | * when the version was bumped, udate the changelog with ``oca-towncrier`` 91 | * run the main branch operations (see above) on it 92 | * when the version was bumped, generate a wheel, rsync it to a PEP 503 93 | simple index root, or upload it to one or more indexes with twine 94 | 95 | ``/ocabot rebase`` can be used to ask the bot to do the following: 96 | 97 | * rebase the PR on the target branch 98 | 99 | ``/ocabot migration``, followed by the module name, performing the following: 100 | 101 | * Look for an issue in that repository with the name "Migration to version 102 | ``{version}``", where ``{version}`` is the name of the target branch. 103 | * Add or edit a line in that issue, linking the module to the pull request 104 | (PR) and the author of it. 105 | * TODO: When the PR is merged, the line gets ticked. 106 | * Put the milestone corresponding to the target branch in the PR. 107 | 108 | TODO (help wanted) 109 | ------------------ 110 | 111 | See our open `issues `_, 112 | pick one and contribute! 113 | 114 | 115 | Developing new features 116 | ======================= 117 | 118 | The easiest is to look at examples. 119 | 120 | New webhooks are added in the `webhooks <./src/oca_github_bot/webhooks>`_ directory. 121 | Webhooks execution time must be very short and they should 122 | delegate the bulk of their work as delayed tasks, which have 123 | the benefit of not overloading the machine and having proper 124 | error handling and monitoring. 125 | 126 | Tasks are in the `tasks <./src/oca_github_bot/tasks>`_ directory. They are `Celery tasks 127 | `_. 128 | 129 | Tasks can be scheduled, in `cron.py <./src/oca_github_bot/cron.py>`_, using the `Celery periodic tasks 130 | `_ mechanism. 131 | 132 | Running it 133 | ========== 134 | 135 | Environment variables 136 | --------------------- 137 | 138 | First create and customize a file named ``.env``, 139 | based on `environment.sample <./environment.sample>`_. 140 | 141 | Tasks performed by the bot can be specified by setting the ``BOT_TASKS`` 142 | variable. This is useful if you want to use this bot for your own GitHub 143 | organisation. 144 | 145 | You can also disable a selection of tasks, using ``BOT_TASKS_DISABLED``. 146 | 147 | Using docker-compose 148 | -------------------- 149 | 150 | ``docker-compose up --build`` will start 151 | 152 | * the bot, listening for webhooks calls on port 8080 153 | * a celery ``worker`` to process long running tasks 154 | * a celery ``beat`` to launch scheduled tasks 155 | * a ``flower`` celery monitoring tool on port 5555 156 | 157 | The bot URL must be exposed on the internet through a reverse 158 | proxy and configured as a GitHub webhook, using the secret configured 159 | in ``GITHUB_SECRET``. 160 | 161 | Development 162 | =========== 163 | 164 | This project uses `black `_ 165 | as code formatting convention, as well as isort and flake8. 166 | To make sure local coding convention are respected before 167 | you commit, install 168 | `pre-commit `_ and 169 | run ``pre-commit install`` after cloning the repository. 170 | 171 | To run tests, type ``tox``. Test are written with pytest. 172 | 173 | Here is a recommended procedure to test locally: 174 | 175 | * Prepare an ``environment`` file by cloning and adapting ``environment.sample``. 176 | * Load ``environment`` in your shell, for instance with bash: 177 | 178 | .. code:: 179 | 180 | set -o allexport 181 | source environment 182 | set +o allexport 183 | 184 | * Launch the ``redis`` message queue: 185 | 186 | .. code:: 187 | 188 | docker run -p 6379:6379 redis 189 | 190 | * Install the `maintainer tools `_ and add the generated binaries to your path: 191 | 192 | .. code:: 193 | 194 | PATH=/path/to/maintainer-tools/env/bin/:$PATH 195 | 196 | * Create a virtual environment and install the project in it: 197 | 198 | .. code:: 199 | 200 | python3 -m venv venv 201 | source venv/bin/activate 202 | pip install -r requirements.txt -e . 203 | 204 | * Then you can debug the two processes in your favorite IDE: 205 | 206 | - the webhook server: ``python -m oca_github_bot`` 207 | - the task worker: ``python -m celery --app=oca_github_bot.queue.app worker --pool=solo --loglevel=INFO`` 208 | 209 | * To expose the webhook server on your local machine to internet, 210 | you can use `ngrok `_ 211 | * Then configure a GitHub webhook in a sandbox project in your organization 212 | so you can start receiving webhook calls to your local machine. 213 | 214 | Releasing 215 | ========= 216 | 217 | To release a new version, follow these steps: 218 | - ``towncrier --version YYYYMMDD`` 219 | - git commit the updated `HISTORY.rst` and removed newfragments 220 | - ``git tag vYYYYMMDD`` 221 | - ``git push --tags`` 222 | 223 | Contributors 224 | ============ 225 | 226 | * Stéphane Bidoul 227 | * Holger Brunn 228 | * Miquel Raïch 229 | * Florian Kantelberg 230 | * Laurent Mignon 231 | * Jose Angel Fentanez 232 | * Simone Rubino 233 | * Sylvain Le Gal (https://twitter.com/legalsylvain) 234 | * Tecnativa - Pedro M. Baeza 235 | * Tecnativa - Víctor Martínez 236 | 237 | Maintainers 238 | =========== 239 | 240 | This module is maintained by the OCA. 241 | 242 | .. image:: https://odoo-community.org/logo.png 243 | :alt: Odoo Community Association 244 | :target: https://odoo-community.org 245 | 246 | OCA, or the Odoo Community Association, is a nonprofit organization whose 247 | mission is to support the collaborative development of Odoo features and 248 | promote its widespread use. 249 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | queue: 4 | image: redis:4-alpine 5 | restart: unless-stopped 6 | volumes: 7 | - ./data/queue:/data 8 | command: redis-server --appendonly yes --auto-aof-rewrite-min-size 64mb --auto-aof-rewrite-percentage 10 9 | bot: 10 | build: . 11 | links: 12 | - queue 13 | ports: 14 | - 127.0.0.1:8080:8080 15 | user: "${UID}:${GID}" 16 | env_file: 17 | - ./.env 18 | volumes: 19 | - ./data/cache:/app/run/.cache 20 | - ./data/simple-index:/app/run/simple-index 21 | restart: unless-stopped 22 | command: python -m oca_github_bot 23 | depends_on: 24 | - queue 25 | worker: 26 | build: . 27 | links: 28 | - queue 29 | user: "${UID}:${GID}" 30 | env_file: 31 | - ./.env 32 | volumes: 33 | - ./data/cache:/app/run/.cache 34 | - ./data/simple-index:/app/run/simple-index 35 | restart: unless-stopped 36 | command: celery --app=oca_github_bot.queue.app worker --concurrency=2 --loglevel=INFO 37 | depends_on: 38 | - queue 39 | beat: 40 | build: . 41 | links: 42 | - queue 43 | user: "${UID}:${GID}" 44 | env_file: 45 | - ./.env 46 | volumes: 47 | - ./data/cache:/app/run/.cache 48 | - ./data/simple-index:/app/run/simple-index 49 | restart: unless-stopped 50 | command: celery --app=oca_github_bot.queue.app beat 51 | depends_on: 52 | - queue 53 | monitor: 54 | build: . 55 | links: 56 | - queue 57 | ports: 58 | - 127.0.0.1:5555:5555 59 | user: "${UID}:${GID}" 60 | env_file: 61 | - ./.env 62 | restart: unless-stopped 63 | command: celery --app=oca_github_bot.queue.app flower 64 | depends_on: 65 | - queue 66 | -------------------------------------------------------------------------------- /environment.sample: -------------------------------------------------------------------------------- 1 | # 2 | # environment variables that are commented out 3 | # have a default value, all others must be provided 4 | # 5 | 6 | # broker URI to use for the celery task queue 7 | #BROKER_URI=redis://queue 8 | 9 | #HTTP_HOST=0.0.0.0 10 | #HTTP_PORT=8080 11 | 12 | # GitHub webhook secret 13 | GITHUB_SECRET= 14 | # GitHub login the bot uses to perform GitHub activites 15 | GITHUB_LOGIN= 16 | # GitHub oauth token 17 | GITHUB_TOKEN= 18 | # Coma separated list of github organisations names (for organization wide scheduled tasks) 19 | #GITHUB_ORG= 20 | # Git name and email used for creating commits (default: user git config, 21 | # needs to be set to to launch the docker composition) 22 | GIT_NAME= 23 | GIT_EMAIL= 24 | 25 | ODOO_URL= 26 | ODOO_DB= 27 | ODOO_LOGIN= 28 | ODOO_PASSWORD= 29 | 30 | #SENTRY_DSN= 31 | 32 | # Number of approvals to have the proposal marked as "Approved" 33 | #APPROVALS_REQUIRED=2 34 | # Number of days before the proposal can be marked as "Approved" 35 | #MIN_PR_AGE=5 36 | 37 | # Coma separated list of task to run 38 | # By default all configured tasks are run. 39 | # Available tasks: 40 | # delete_branch,tag_approved,tag_ready_to_merge,gen_addons_table, 41 | # gen_addons_readme,gen_addons_icon,setuptools_odoo,mention_maintainer, 42 | # merge_bot,rebase_bot,merge_bot_towncrier,tag_needs_review 43 | #BOT_TASKS=all 44 | 45 | # Coma separated list of task to ignore 46 | #BOT_TASKS_DISABLED= 47 | 48 | # Coma separated list of github status to ignore 49 | #GITHUB_STATUS_IGNORED=ci/runbot,codecov/project,codecov/patch,coverage/coveralls 50 | 51 | # Coma separated list of github Check suites to ignore 52 | #GITHUB_CHECK_SUITES_IGNORED=Codecov 53 | 54 | # Root of the PEP 503 simple index where wheels are published with rsync 55 | # (publishing disabled if empty). 56 | SIMPLE_INDEX_ROOT=/app/run/simple-index 57 | # Repositories where wheels are published using twine (publishing disabled if empty). 58 | # This must be a list of tuples with the following items 59 | # - PEP 503 index based url, to check if the file already exists 60 | # - repo url, for twine --repository-url 61 | # - username for twine --username option 62 | # - password for twine --password option 63 | OCABOT_TWINE_REPOSITORIES="[('https://pypi.org/simple','https://upload.pypi.org/legacy/','pypiuser','pypipassword')]" 64 | 65 | # Space separated list of extra arguments, for the calls of the 66 | # following command 67 | # * oca-gen-addons-table 68 | # * oca-gen-addon-readme 69 | # * oca-gen-addon-icon 70 | # Exemple : GEN_ADDON_README_EXTRA_ARGS=--no-gen-html 71 | #GEN_ADDONS_TABLE_EXTRA_ARGS= 72 | #GEN_ADDON_README_EXTRA_ARGS= 73 | #GEN_ADDON_ICON_EXTRA_ARGS= 74 | 75 | # Text displayed on github, as a comment, 76 | # if a user has entered a wrong command 77 | #OCABOT_USAGE= 78 | #OCABOT_EXTRA_DOCUMENTATION= 79 | 80 | # Markdown text to be used to call for maintainers when a PR is made to an 81 | # addon that has no declared maintainers. 82 | #ADOPT_AN_ADDON_MENTION= 83 | 84 | # List of branches the bot will check to verify if user is the maintainer 85 | # of module(s) 86 | MAINTAINER_CHECK_ODOO_RELEASES=8.0,9.0,10.0,11.0,12.0,13.0,14.0,15.0 87 | -------------------------------------------------------------------------------- /newsfragments/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OCA/oca-github-bot/d21feddfe3cf5698e5f322495d2483403e810faa/newsfragments/.gitignore -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.towncrier] 2 | template = ".towncrier-template.rst" 3 | underlines = ["~"] 4 | title_format = "{version}" 5 | issue_format = "`#{issue} `_" 6 | directory = "newsfragments/" 7 | filename = "HISTORY.rst" 8 | 9 | [tool.pip-deepfreeze.sync] 10 | extras = "test" 11 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | asyncio_mode = strict 3 | -------------------------------------------------------------------------------- /requirements-test.txt: -------------------------------------------------------------------------------- 1 | # frozen requirements generated by pip-deepfreeze 2 | coverage==7.5.4 3 | iniconfig==2.0.0 4 | pluggy==1.5.0 5 | pytest==8.2.2 6 | pytest-asyncio==0.23.7 7 | pytest-cov==5.0.0 8 | pytest-mock==3.14.0 9 | pytest-vcr==1.0.2 10 | pyyaml==6.0.1 11 | vcrpy==6.0.1 12 | wrapt==1.16.0 13 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # frozen requirements generated by pip-deepfreeze 2 | aiohttp==3.9.5 3 | aiosignal==1.3.1 4 | amqp==5.2.0 5 | appdirs==1.4.4 6 | attrs==23.2.0 7 | billiard==4.2.0 8 | celery==5.4.0 9 | certifi==2024.7.4 10 | cffi==1.16.0 11 | charset-normalizer==3.3.2 12 | click==8.1.7 13 | click-didyoumean==0.3.1 14 | click-plugins==1.1.1 15 | click-repl==0.3.0 16 | cryptography==43.0.1 17 | docutils==0.21.2 18 | flower==2.0.1 19 | frozenlist==1.4.1 20 | gidgethub==5.3.0 21 | github3-py==4.0.1 22 | humanize==4.9.0 23 | idna==3.7 24 | importlib-metadata==8.0.0 25 | jaraco-classes==3.4.0 26 | jaraco-context==5.3.0 27 | jaraco-functools==4.0.1 28 | jeepney==0.8.0 29 | keyring==25.2.1 30 | kombu==5.3.7 31 | lxml==5.2.2 32 | manifestoo-core==1.6 33 | markdown-it-py==3.0.0 34 | mdurl==0.1.2 35 | more-itertools==10.3.0 36 | multidict==6.0.5 37 | nh3==0.2.17 38 | odoorpc==0.10.1 39 | packaging==24.1 40 | pkginfo==1.10.0 41 | prometheus-client==0.20.0 42 | prompt-toolkit==3.0.47 43 | pycparser==2.22 44 | pygments==2.18.0 45 | pyjwt==2.8.0 46 | python-dateutil==2.9.0.post0 47 | pytz==2024.1 48 | readme-renderer==43.0 49 | redis==5.0.7 50 | requests==2.32.3 51 | requests-toolbelt==1.0.0 52 | rfc3986==2.0.0 53 | rich==13.7.1 54 | secretstorage==3.3.3 55 | sentry-sdk==1.45.0 56 | setuptools==70.2.0 57 | setuptools-odoo==3.3 58 | setuptools-scm==8.1.0 59 | six==1.16.0 60 | tornado==6.4.1 61 | twine==5.1.1 62 | tzdata==2024.1 63 | uritemplate==4.1.1 64 | urllib3==2.2.2 65 | vine==5.1.0 66 | wcwidth==0.2.13 67 | wheel==0.43.0 68 | whool==1.0.1 69 | yarl==1.9.4 70 | zipp==3.19.2 71 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name=oca-github-bot 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from setuptools import find_packages, setup 4 | 5 | here = os.path.abspath(os.path.dirname(__file__)) 6 | with open(os.path.join(here, "README.rst"), encoding="utf-8") as f: 7 | long_description = f.read() 8 | 9 | 10 | setup( 11 | name="oca-github-bot", 12 | use_scm_version=True, 13 | long_description=long_description, 14 | author="Odoo Community Association (OCA)", 15 | author_email="info@odoo-community.org", 16 | url="https://github.com/OCA/oca-github-bot", 17 | python_requires="==3.12.*", 18 | setup_requires=["setuptools_scm"], 19 | packages=find_packages("src"), 20 | package_dir={"": "src"}, 21 | install_requires=[ 22 | # aiohttp ang gitgethub for the webhook app 23 | "aiohttp", 24 | "gidgethub", 25 | "appdirs", 26 | # GitHub client 27 | "github3.py>=1.3.0", 28 | # celery and celery monitoring for the task queue 29 | "flower", 30 | "celery[redis]", 31 | # Odoo 32 | "odoorpc", 33 | # Sentry SDK (<2 because we use an old version of self-hosted Sentry) 34 | "sentry-sdk[celery]<2", 35 | # twine to check and upload wheels 36 | "twine", 37 | # lxml for parsing PyPI index pages 38 | "lxml", 39 | # for setuptools-odoo-make-default 40 | "setuptools-odoo", 41 | "setuptools", 42 | # for whool-init 43 | "whool", 44 | # packaging 45 | "packaging>=22", 46 | ], 47 | extras_require={ 48 | "test": [ 49 | "pytest", 50 | "pytest-asyncio", 51 | "pytest-cov", 52 | "pytest-mock", 53 | "pytest-vcr", 54 | ], 55 | }, 56 | license="MIT", 57 | classifiers=[ 58 | # Trove classifiers 59 | # Full list: https://pypi.python.org/pypi?%3Aaction=list_classifiers 60 | "License :: OSI Approved :: MIT License", 61 | "Programming Language :: Python", 62 | ], 63 | ) 64 | -------------------------------------------------------------------------------- /src/oca_github_bot/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) ACSONE SA/NV 2018 2 | # Distributed under the MIT License (http://opensource.org/licenses/MIT). 3 | 4 | from . import cron, tasks, webhooks 5 | -------------------------------------------------------------------------------- /src/oca_github_bot/__main__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) ACSONE SA/NV 2018 2 | # Distributed under the MIT License (http://opensource.org/licenses/MIT). 3 | 4 | """ OCA GitHub Bot 5 | 6 | This is the main program, which provides the dispatching 7 | mechanisms for webhook calls from github. 8 | """ 9 | import logging 10 | 11 | import aiohttp 12 | from aiohttp import web 13 | from gidgethub import aiohttp as gh_aiohttp, sansio as gh_sansio 14 | 15 | from . import config 16 | from .router import router 17 | 18 | _logger = logging.getLogger(__name__) 19 | 20 | 21 | async def webhook(request): 22 | """This is the main webhook dispatcher 23 | 24 | Handlers are declared with the @router.register decorator. 25 | See https://gidgethub.readthedocs.io/en/latest/routing.html 26 | """ 27 | body = await request.read() 28 | 29 | event = gh_sansio.Event.from_http( 30 | request.headers, body, secret=config.GITHUB_SECRET 31 | ) 32 | async with aiohttp.ClientSession() as session: 33 | gh = gh_aiohttp.GitHubAPI( 34 | session, config.GITHUB_LOGIN, oauth_token=config.GITHUB_TOKEN 35 | ) 36 | await router.dispatch(event, gh) 37 | 38 | return web.Response(status=200) 39 | 40 | 41 | def main(): 42 | # configure logging 43 | logging.basicConfig(level=logging.DEBUG) 44 | # launch webhook app 45 | app = web.Application() 46 | app.router.add_post("/", webhook) 47 | web.run_app(app, host=config.HTTP_HOST, port=config.HTTP_PORT) 48 | 49 | 50 | if __name__ == "__main__": 51 | main() 52 | -------------------------------------------------------------------------------- /src/oca_github_bot/build_wheels.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) ACSONE SA/NV 2019 2 | # Distributed under the MIT License (http://opensource.org/licenses/MIT). 3 | 4 | import atexit 5 | import logging 6 | import os 7 | import shutil 8 | import sys 9 | import tempfile 10 | from pathlib import Path 11 | from typing import Tuple, Union 12 | 13 | from .config import WHEEL_BUILD_TOOLS 14 | from .manifest import addon_dirs_in, get_manifest, get_odoo_series_from_version 15 | from .process import check_call 16 | from .pypi import DistPublisher 17 | 18 | _logger = logging.getLogger(__name__) 19 | 20 | 21 | class Builder: 22 | _builder: Union["Builder", None] = None 23 | 24 | @classmethod 25 | def get(cls) -> "Builder": 26 | if cls._builder is None: 27 | cls._builder = cls() 28 | return cls._builder 29 | 30 | def __init__(self): 31 | self.env_dir = tempfile.mkdtemp() 32 | atexit.register(shutil.rmtree, self.env_dir) 33 | self.env_python = Path(self.env_dir) / "bin" / "python" 34 | check_call( 35 | [sys.executable, "-m", "venv", self.env_dir], 36 | cwd=".", 37 | ) 38 | check_call( 39 | [self.env_python, "-m", "pip", "install", "--upgrade"] + WHEEL_BUILD_TOOLS, 40 | cwd=".", 41 | ) 42 | check_call( 43 | [self.env_python, "-m", "pip", "check"], 44 | cwd=".", 45 | ) 46 | 47 | def build_wheel(self, project_dir: Path, dist_dir: str) -> None: 48 | with tempfile.TemporaryDirectory() as empty_dir: 49 | # Start build from an empty directory, to avoid accidentally importing 50 | # python files from the addon root directory during the build process. 51 | check_call( 52 | [ 53 | self.env_python, 54 | "-P", 55 | "-m", 56 | "build", 57 | "--wheel", 58 | "--outdir", 59 | dist_dir, 60 | "--no-isolation", 61 | project_dir, 62 | ], 63 | cwd=empty_dir, 64 | ) 65 | self._check_wheels(dist_dir) 66 | return True 67 | 68 | def build_wheel_legacy( 69 | self, project_dir: Path, dist_dir: str, python_tag: Union[str, None] = None 70 | ) -> None: 71 | with tempfile.TemporaryDirectory() as bdist_dir: 72 | cmd = [ 73 | self.env_python, 74 | "setup.py", 75 | "bdist_wheel", 76 | "--dist-dir", 77 | dist_dir, 78 | "--bdist-dir", 79 | bdist_dir, 80 | ] 81 | if python_tag: 82 | cmd.extend(["--python-tag", python_tag]) 83 | check_call(cmd, cwd=project_dir) 84 | self._check_wheels(dist_dir) 85 | return True 86 | 87 | def _check_wheels(self, dist_dir: Path) -> None: 88 | wheels = [f for f in os.listdir(dist_dir) if f.endswith(".whl")] 89 | check_call(["twine", "check"] + wheels, cwd=dist_dir) 90 | 91 | def build_addon_wheel(self, addon_dir: Path, dist_dir: str) -> None: 92 | manifest = get_manifest(addon_dir) 93 | if not manifest.get("installable", True): 94 | return False 95 | 96 | series = get_odoo_series_from_version(manifest.get("version", "")) 97 | 98 | if series >= (12, 0) and (addon_dir / "pyproject.toml").is_file(): 99 | return self.build_wheel(addon_dir, dist_dir) 100 | 101 | setup_py_dir = addon_dir / ".." / "setup" / addon_dir.name 102 | if series >= (8, 0) and (setup_py_dir / "setup.py").is_file(): 103 | return self.build_wheel_legacy( 104 | setup_py_dir, dist_dir, python_tag="py2" if series < (11, 0) else "py3" 105 | ) 106 | 107 | return False 108 | 109 | 110 | def build_and_check_wheel(addon_dir: str): 111 | with tempfile.TemporaryDirectory() as dist_dir: 112 | Builder.get().build_addon_wheel(Path(addon_dir), dist_dir) 113 | 114 | 115 | def build_and_publish_wheel( 116 | addon_dir: str, dist_publisher: DistPublisher, dry_run: bool 117 | ): 118 | with tempfile.TemporaryDirectory() as dist_dir: 119 | if Builder.get().build_addon_wheel(Path(addon_dir), dist_dir): 120 | dist_publisher.publish(dist_dir, dry_run) 121 | 122 | 123 | def build_and_publish_wheels( 124 | addons_dir: str, dist_publisher: DistPublisher, dry_run: bool 125 | ): 126 | for addon_dir in addon_dirs_in(addons_dir, installable_only=True): 127 | with tempfile.TemporaryDirectory() as dist_dir: 128 | if Builder.get().build_addon_wheel(Path(addon_dir), dist_dir): 129 | dist_publisher.publish(dist_dir, dry_run) 130 | 131 | 132 | def build_and_publish_metapackage_wheel( 133 | addons_dir: str, 134 | dist_publisher: DistPublisher, 135 | series: Tuple[int, int], 136 | dry_run: bool, 137 | ): 138 | setup_dir = Path(addons_dir) / "setup" / "_metapackage" 139 | setup_file = setup_dir / "setup.py" 140 | if not setup_file.is_file(): 141 | return 142 | with tempfile.TemporaryDirectory() as dist_dir: 143 | # Workaround for recent setuptools not generating long_description 144 | # anymore (before it was generating UNKNOWN), and a long_description 145 | # is required by twine check. We could fix setuptools-odoo-makedefault 146 | # but that would not backfill the legacy. So here we are... 147 | if "long_description" not in setup_file.read_text(): 148 | setup_dir.joinpath("setup.cfg").write_text( 149 | "[metadata]\nlong_description = UNKNOWN\n" 150 | ) 151 | if Builder.get().build_wheel_legacy( 152 | setup_dir, dist_dir, python_tag="py2" if series < (11, 0) else "py3" 153 | ): 154 | dist_publisher.publish(dist_dir, dry_run) 155 | -------------------------------------------------------------------------------- /src/oca_github_bot/commands.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) ACSONE SA/NV 2019 2 | # Distributed under the MIT License (http://opensource.org/licenses/MIT). 3 | 4 | import re 5 | 6 | from .tasks import merge_bot, migration_issue_bot, rebase_bot 7 | 8 | BOT_COMMAND_RE = re.compile( 9 | # Do not start with > (Github comment), not consuming it 10 | r"^(?=[^>])" 11 | # Anything before /ocabot, at least one whitspace after 12 | r".*/ocabot\s+" 13 | # command group: any word is ok 14 | r"(?P\w+)" 15 | # options group: spaces and words, all the times you want (0 is ok too) 16 | r"(?P[ \t\w]*)" 17 | # non-capturing group: 18 | # stop finding options as soon as you find something that is not a word 19 | r"(?:\W|\r?$)", 20 | re.MULTILINE, 21 | ) 22 | 23 | 24 | class CommandError(Exception): 25 | pass 26 | 27 | 28 | class InvalidCommandError(CommandError): 29 | def __init__(self, name): 30 | super().__init__(f"Invalid command: {name}") 31 | 32 | 33 | class OptionsError(CommandError): 34 | pass 35 | 36 | 37 | class InvalidOptionsError(OptionsError): 38 | def __init__(self, name, options): 39 | options_text = " ".join(options) 40 | super().__init__(f"Invalid options for command {name}: {options_text}") 41 | 42 | 43 | class RequiredOptionError(OptionsError): 44 | def __init__(self, name, option, values): 45 | values_text = ", ".join(values) 46 | super().__init__( 47 | f"Required option {option} for command {name}.\n" 48 | f"Possible values : {values_text}" 49 | ) 50 | 51 | 52 | class BotCommand: 53 | def __init__(self, name, options): 54 | self.name = name 55 | self.options = options 56 | self.parse_options(options) 57 | 58 | @classmethod 59 | def create(cls, name, options): 60 | if name == "merge": 61 | return BotCommandMerge(name, options) 62 | elif name == "rebase": 63 | return BotCommandRebase(name, options) 64 | elif name == "migration": 65 | return BotCommandMigrationIssue(name, options) 66 | else: 67 | raise InvalidCommandError(name) 68 | 69 | def parse_options(self, options): 70 | pass 71 | 72 | def delay(self, org, repo, pr, username, dry_run=False): 73 | """Run the command on a given pull request on behalf of a GitHub user""" 74 | raise NotImplementedError() 75 | 76 | 77 | class BotCommandMerge(BotCommand): 78 | bumpversion_mode = None 79 | bumpversion_mode_list = ["major", "minor", "patch", "nobump"] 80 | 81 | def parse_options(self, options): 82 | if not options: 83 | raise RequiredOptionError( 84 | self.name, "bumpversion_mode", self.bumpversion_mode_list 85 | ) 86 | if len(options) == 1 and options[0] in self.bumpversion_mode_list: 87 | self.bumpversion_mode = options[0] 88 | else: 89 | raise InvalidOptionsError(self.name, options) 90 | 91 | def delay(self, org, repo, pr, username, dry_run=False): 92 | merge_bot.merge_bot_start.delay( 93 | org, repo, pr, username, self.bumpversion_mode, dry_run=False 94 | ) 95 | 96 | 97 | class BotCommandRebase(BotCommand): 98 | def parse_options(self, options): 99 | if not options: 100 | return 101 | raise InvalidOptionsError(self.name, options) 102 | 103 | def delay(self, org, repo, pr, username, dry_run=False): 104 | rebase_bot.rebase_bot_start.delay(org, repo, pr, username, dry_run=False) 105 | 106 | 107 | class BotCommandMigrationIssue(BotCommand): 108 | module = None # mandatory str: module name 109 | 110 | def parse_options(self, options): 111 | if len(options) == 1: 112 | self.module = options[0] 113 | else: 114 | raise InvalidOptionsError(self.name, options) 115 | 116 | def delay(self, org, repo, pr, username, dry_run=False): 117 | migration_issue_bot.migration_issue_start.delay( 118 | org, repo, pr, username, module=self.module, dry_run=dry_run 119 | ) 120 | 121 | 122 | def parse_commands(text): 123 | """Parse a text and return an iterator of BotCommand objects.""" 124 | for mo in BOT_COMMAND_RE.finditer(text): 125 | yield BotCommand.create( 126 | mo.group("command"), mo.group("options").strip().split() 127 | ) 128 | -------------------------------------------------------------------------------- /src/oca_github_bot/config.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) ACSONE SA/NV 2018 2 | # Distributed under the MIT License (http://opensource.org/licenses/MIT). 3 | 4 | import ast 5 | import logging 6 | import os 7 | from functools import wraps 8 | 9 | from .pypi import MultiDistPublisher, RsyncDistPublisher, TwineDistPublisher 10 | 11 | _logger = logging.getLogger("oca_gihub_bot.tasks") 12 | 13 | 14 | def switchable(switch_name=None): 15 | def wrap(func): 16 | @wraps(func) 17 | def func_wrapper(*args, **kwargs): 18 | sname = switch_name 19 | if switch_name is None: 20 | sname = func.__name__ 21 | 22 | if ( 23 | BOT_TASKS != ["all"] and sname not in BOT_TASKS 24 | ) or sname in BOT_TASKS_DISABLED: 25 | _logger.debug("Method %s skipped (Disabled by config)", sname) 26 | return 27 | return func(*args, **kwargs) 28 | 29 | return func_wrapper 30 | 31 | return wrap 32 | 33 | 34 | HTTP_HOST = os.environ.get("HTTP_HOST") 35 | HTTP_PORT = int(os.environ.get("HTTP_PORT") or "8080") 36 | 37 | GITHUB_SECRET = os.environ.get("GITHUB_SECRET") 38 | GITHUB_LOGIN = os.environ.get("GITHUB_LOGIN") 39 | GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN") 40 | GITHUB_ORG = ( 41 | os.environ.get("GITHUB_ORG") and os.environ.get("GITHUB_ORG").split(",") or [] 42 | ) 43 | GIT_NAME = os.environ.get("GIT_NAME") 44 | GIT_EMAIL = os.environ.get("GIT_EMAIL") 45 | 46 | ODOO_URL = os.environ.get("ODOO_URL") 47 | ODOO_DB = os.environ.get("ODOO_DB") 48 | ODOO_LOGIN = os.environ.get("ODOO_LOGIN") 49 | ODOO_PASSWORD = os.environ.get("ODOO_PASSWORD") 50 | 51 | BROKER_URI = os.environ.get("BROKER_URI", os.environ.get("REDIS_URI", "redis://queue")) 52 | 53 | SENTRY_DSN = os.environ.get("SENTRY_DSN") 54 | 55 | DRY_RUN = os.environ.get("DRY_RUN", "").lower() in ("1", "true", "yes") 56 | 57 | # Coma separated list of task to run 58 | # By default all configured tasks are run. 59 | # Available tasks: 60 | # delete_branch,tag_approved,tag_ready_to_merge,gen_addons_table, 61 | # gen_addons_readme,gen_addons_icon,setuptools_odoo,merge_bot,tag_needs_review, 62 | # migration_issue_bot,whool_init,gen_metapackage 63 | BOT_TASKS = os.environ.get("BOT_TASKS", "all").split(",") 64 | 65 | BOT_TASKS_DISABLED = os.environ.get("BOT_TASKS_DISABLED", "").split(",") 66 | 67 | GEN_ADDONS_TABLE_EXTRA_ARGS = ( 68 | os.environ.get("GEN_ADDONS_TABLE_EXTRA_ARGS", "") 69 | and os.environ.get("GEN_ADDONS_TABLE_EXTRA_ARGS").split(" ") 70 | or [] 71 | ) 72 | 73 | GEN_ADDON_README_EXTRA_ARGS = ( 74 | os.environ.get("GEN_ADDON_README_EXTRA_ARGS", "") 75 | and os.environ.get("GEN_ADDON_README_EXTRA_ARGS").split(" ") 76 | or [] 77 | ) 78 | 79 | GEN_ADDON_ICON_EXTRA_ARGS = ( 80 | os.environ.get("GEN_ADDON_ICON_EXTRA_ARGS", "") 81 | and os.environ.get("GEN_ADDON_ICON_EXTRA_ARGS").split(" ") 82 | or [] 83 | ) 84 | 85 | GITHUB_STATUS_IGNORED = os.environ.get( 86 | "GITHUB_STATUS_IGNORED", 87 | "ci/runbot,codecov/project,codecov/patch,coverage/coveralls", 88 | ).split(",") 89 | 90 | GITHUB_CHECK_SUITES_IGNORED = os.environ.get( 91 | "GITHUB_CHECK_SUITES_IGNORED", "Codecov,Dependabot" 92 | ).split(",") 93 | 94 | MERGE_BOT_INTRO_MESSAGES = [ 95 | "On my way to merge this fine PR!", 96 | "This PR looks fantastic, let's merge it!", 97 | "Hey, thanks for contributing! Proceeding to merge this for you.", 98 | "What a great day to merge this nice PR. Let's do it!", 99 | ] 100 | 101 | APPROVALS_REQUIRED = int(os.environ.get("APPROVALS_REQUIRED", "2")) 102 | MIN_PR_AGE = int(os.environ.get("MIN_PR_AGE", "5")) 103 | 104 | dist_publisher = MultiDistPublisher() 105 | SIMPLE_INDEX_ROOT = os.environ.get("SIMPLE_INDEX_ROOT") 106 | if SIMPLE_INDEX_ROOT: 107 | dist_publisher.add(RsyncDistPublisher(SIMPLE_INDEX_ROOT)) 108 | if os.environ.get("OCABOT_TWINE_REPOSITORIES"): 109 | for index_url, repository_url, username, password in ast.literal_eval( 110 | os.environ["OCABOT_TWINE_REPOSITORIES"] 111 | ): 112 | dist_publisher.add( 113 | TwineDistPublisher(index_url, repository_url, username, password) 114 | ) 115 | 116 | OCABOT_USAGE = os.environ.get( 117 | "OCABOT_USAGE", 118 | "**Ocabot commands**\n" 119 | "* ``ocabot merge major|minor|patch|nobump``\n" 120 | "* ``ocabot rebase``\n" 121 | "* ``ocabot migration {MODULE_NAME}``", 122 | ) 123 | 124 | OCABOT_EXTRA_DOCUMENTATION = os.environ.get( 125 | "OCABOT_EXTRA_DOCUMENTATION", 126 | "**More information**\n" 127 | " * [ocabot documentation](https://github.com/OCA/oca-github-bot/#commands)\n" 128 | " * [OCA guidelines](https://github.com/OCA/odoo-community.org/blob/master/" 129 | "website/Contribution/CONTRIBUTING.rst), " 130 | 'specially the "Version Numbers" section.', 131 | ) 132 | 133 | ADOPT_AN_ADDON_MENTION = os.environ.get("ADOPT_AN_ADDON_MENTION") 134 | 135 | MAINTAINER_CHECK_ODOO_RELEASES = ( 136 | os.environ.get("MAINTAINER_CHECK_ODOO_RELEASES") 137 | and os.environ.get("MAINTAINER_CHECK_ODOO_RELEASES").split(",") 138 | or [] 139 | ) 140 | 141 | WHEEL_BUILD_TOOLS = os.environ.get( 142 | "WHEEL_BUILD_TOOLS", 143 | "build,pip,setuptools<70,wheel,setuptools-odoo,whool", 144 | ).split(",") 145 | 146 | # minimum Odoo series supported by the bot 147 | MAIN_BRANCH_BOT_MIN_VERSION = os.environ.get("MAIN_BRANCH_BOT_MIN_VERSION", "8.0") 148 | 149 | # First Odoo Series for which the whool_init and gen_metapackage tasks are run on main 150 | # branches. For previous versions, the setuptools_odoo task is run and generates 151 | # setup.py instead of pyproject.toml. 152 | GEN_PYPROJECT_MIN_VERSION = os.environ.get("GEN_PYPROJECT_MIN_VERSION", "17.0") 153 | -------------------------------------------------------------------------------- /src/oca_github_bot/cron.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) ACSONE SA/NV 2018 2 | # Distributed under the MIT License (http://opensource.org/licenses/MIT). 3 | 4 | from celery.schedules import crontab 5 | 6 | from .config import GITHUB_ORG 7 | from .queue import app 8 | 9 | beat_schedule = { 10 | "heartbeat": { 11 | "task": "oca_github_bot.tasks.heartbeat.heartbeat", 12 | "schedule": crontab(minute="*/15"), 13 | } 14 | } 15 | 16 | for org in GITHUB_ORG: 17 | beat_schedule.update( 18 | { 19 | "main_branch_bot_all_repos": { 20 | "task": "oca_github_bot.tasks.main_branch_bot." 21 | "main_branch_bot_all_repos", 22 | "args": (org,), 23 | "kwargs": dict(build_wheels=True), 24 | "schedule": crontab(hour="2", minute="30"), 25 | }, 26 | "tag_ready_to_merge": { 27 | "task": "oca_github_bot.tasks.tag_ready_to_merge.tag_ready_to_merge", 28 | "args": (org,), 29 | "schedule": crontab(minute="0"), 30 | }, 31 | } 32 | ) 33 | 34 | app.conf.beat_schedule = beat_schedule 35 | -------------------------------------------------------------------------------- /src/oca_github_bot/github.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) ACSONE SA/NV 2018-2019 2 | # Distributed under the MIT License (http://opensource.org/licenses/MIT). 3 | 4 | import functools 5 | import logging 6 | import os 7 | import shutil 8 | import tempfile 9 | from contextlib import contextmanager 10 | from pathlib import Path 11 | 12 | import appdirs 13 | import github3 14 | from celery.exceptions import Retry 15 | 16 | from . import config 17 | from .process import CalledProcessError, call, check_call, check_output 18 | from .utils import retry_on_exception 19 | 20 | _logger = logging.getLogger(__name__) 21 | 22 | 23 | @contextmanager 24 | def login(): 25 | """GitHub login as decorator so a pool can be implemented later.""" 26 | yield github3.login(token=config.GITHUB_TOKEN) 27 | 28 | 29 | @contextmanager 30 | def repository(org, repo): 31 | with login() as gh: 32 | yield gh.repository(org, repo) 33 | 34 | 35 | def gh_call(func, *args, **kwargs): 36 | """Intercept GitHub call to wait when the API rate limit is reached.""" 37 | try: 38 | return func(*args, **kwargs) 39 | except github3.exceptions.ForbiddenError as e: 40 | if not e.response.headers.get("X-RateLimit-Remaining", 1): 41 | raise Retry( 42 | message="Retry task after rate limit reset", 43 | exc=e, 44 | when=e.response.headers.get("X-RateLimit-Reset"), 45 | ) 46 | raise 47 | 48 | 49 | def gh_date(d): 50 | return d.isoformat() 51 | 52 | 53 | def gh_datetime(utc_dt): 54 | return utc_dt.isoformat()[:19] + "+00:00" 55 | 56 | 57 | class BranchNotFoundError(RuntimeError): 58 | pass 59 | 60 | 61 | @contextmanager 62 | def temporary_clone(org, repo, branch): 63 | """context manager that clones a git branch into a tremporary directory, 64 | and yields the temp dir name, with cache""" 65 | # init cache directory 66 | cache_dir = appdirs.user_cache_dir("oca-mqt") 67 | repo_cache_dir = os.path.join(cache_dir, "github.com", org.lower(), repo.lower()) 68 | if not os.path.isdir(repo_cache_dir): 69 | os.makedirs(repo_cache_dir) 70 | check_call(["git", "init", "--bare"], cwd=repo_cache_dir) 71 | repo_url = f"https://github.com/{org}/{repo}" 72 | repo_url_with_token = f"https://{config.GITHUB_TOKEN}@github.com/{org}/{repo}" 73 | # fetch all branches into cache 74 | fetch_cmd = [ 75 | "git", 76 | "fetch", 77 | "--quiet", 78 | "--force", 79 | "--prune", 80 | repo_url, 81 | "refs/heads/*:refs/heads/*", 82 | ] 83 | retry_on_exception( 84 | functools.partial(check_call, fetch_cmd, cwd=repo_cache_dir), 85 | "error: cannot lock ref", 86 | sleep_time=10.0, 87 | ) 88 | # check if branch exist 89 | branches = check_output(["git", "branch"], cwd=repo_cache_dir) 90 | branches = [b.strip() for b in branches.split()] 91 | if branch not in branches: 92 | raise BranchNotFoundError() 93 | # clone to temp dir, with --reference to cache 94 | tempdir = tempfile.mkdtemp() 95 | try: 96 | clone_cmd = [ 97 | "git", 98 | "clone", 99 | "--quiet", 100 | "--reference", 101 | repo_cache_dir, 102 | "--branch", 103 | branch, 104 | "--", 105 | repo_url, 106 | tempdir, 107 | ] 108 | check_call(clone_cmd, cwd=".") 109 | if config.GIT_NAME: 110 | check_call(["git", "config", "user.name", config.GIT_NAME], cwd=tempdir) 111 | if config.GIT_EMAIL: 112 | check_call(["git", "config", "user.email", config.GIT_EMAIL], cwd=tempdir) 113 | check_call( 114 | ["git", "remote", "set-url", "--push", "origin", repo_url_with_token], 115 | cwd=tempdir, 116 | ) 117 | yield tempdir 118 | finally: 119 | shutil.rmtree(tempdir) 120 | 121 | 122 | def git_push_if_needed(remote, branch, cwd=None): 123 | """ 124 | Push current HEAD to remote branch. 125 | 126 | Return True if push succeeded, False if there was nothing to push. 127 | Raises a celery Retry exception in case of non-fast-forward push. 128 | """ 129 | r = call(["git", "diff", "--quiet", "--exit-code", remote + "/" + branch], cwd=cwd) 130 | if r == 0: 131 | return False 132 | try: 133 | check_call(["git", "push", remote, branch], cwd=cwd, log_error=False) 134 | except CalledProcessError as e: 135 | if "non-fast-forward" in e.output: 136 | raise Retry( 137 | exc=e, 138 | message="Retrying because a non-fast-forward git push was attempted.", 139 | ) 140 | else: 141 | _logger.error( 142 | f"command {e.cmd} failed with return code {e.returncode} " 143 | f"and output:\n{e.output}" 144 | ) 145 | raise 146 | return True 147 | 148 | 149 | def github_user_can_push(gh_repo, username): 150 | for collaborator in gh_call(gh_repo.collaborators): 151 | if username == collaborator.login and collaborator.permissions.get("push"): 152 | return True 153 | return False 154 | 155 | 156 | def git_get_head_sha(cwd): 157 | """Get the sha of the git HEAD in current directory""" 158 | return check_output(["git", "rev-parse", "HEAD"], cwd=cwd).strip() 159 | 160 | 161 | def git_get_current_branch(cwd): 162 | return check_output(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=cwd).strip() 163 | 164 | 165 | def git_commit_if_needed(glob_pattern, msg, cwd): 166 | files = [p.absolute() for p in Path(cwd).glob(glob_pattern)] 167 | if not files: 168 | return # no match nothing to commit 169 | check_call(["git", "add", *files], cwd=cwd) 170 | if call(["git", "diff", "--cached", "--quiet", "--exit-code"], cwd=cwd) == 0: 171 | return # nothing added 172 | return check_call(["git", "commit", "-m", msg], cwd=cwd) 173 | -------------------------------------------------------------------------------- /src/oca_github_bot/manifest.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) ACSONE SA/NV 2018 2 | # Distributed under the MIT License (http://opensource.org/licenses/MIT). 3 | 4 | import ast 5 | import logging 6 | import os 7 | import re 8 | from typing import Tuple 9 | 10 | import requests 11 | 12 | from . import config 13 | from .github import git_get_current_branch, github_user_can_push 14 | from .process import check_call, check_output 15 | 16 | MANIFEST_NAMES = ("__manifest__.py", "__openerp__.py", "__terp__.py") 17 | VERSION_RE = re.compile( 18 | r"^(?P\d+\.\d+)\.(?P\d+)\.(?P\d+)\.(?P\d+)$" 19 | ) 20 | BRANCH_RE = re.compile(r"^(?P\d+\.\d+)$") 21 | MANIFEST_VERSION_RE = re.compile( 22 | r"(?P
[\"']version[\"']\s*:\s*[\"'])(?P[\d\.]+)(?P[\"'])"
 23 | )
 24 | 
 25 | _logger = logging.getLogger(__name__)
 26 | 
 27 | 
 28 | class NoManifestFound(Exception):
 29 |     pass
 30 | 
 31 | 
 32 | class OdooSeriesNotDetected(Exception):
 33 |     def __init__(self, msg=None):
 34 |         super().__init__(msg or "Odoo series could not be detected")
 35 | 
 36 | 
 37 | def is_addons_dir(addons_dir, installable_only=False):
 38 |     """Test if an directory contains Odoo addons."""
 39 |     return any(addon_dirs_in(addons_dir, installable_only))
 40 | 
 41 | 
 42 | def is_addon_dir(addon_dir, installable_only=False):
 43 |     """Test if a directory contains an Odoo addon."""
 44 |     if not installable_only:
 45 |         return bool(get_manifest_path(addon_dir))
 46 |     else:
 47 |         try:
 48 |             return get_manifest(addon_dir).get("installable", True)
 49 |         except NoManifestFound:
 50 |             return False
 51 | 
 52 | 
 53 | def addon_dirs_in(addons_dir, installable_only=False):
 54 |     """Enumerate addon directories"""
 55 |     for d in os.listdir(addons_dir):
 56 |         addon_dir = os.path.join(addons_dir, d)
 57 |         if is_addon_dir(addon_dir, installable_only):
 58 |             yield addon_dir
 59 | 
 60 | 
 61 | def get_addon_name(addon_dir):
 62 |     return os.path.basename(os.path.abspath(addon_dir))
 63 | 
 64 | 
 65 | def get_manifest_file_name(addon_dir):
 66 |     """Return the name of the manifest file, without path"""
 67 |     for manifest_name in MANIFEST_NAMES:
 68 |         manifest_path = os.path.join(addon_dir, manifest_name)
 69 |         if os.path.exists(manifest_path):
 70 |             return manifest_name
 71 |     return None
 72 | 
 73 | 
 74 | def get_manifest_path(addon_dir):
 75 |     for manifest_name in MANIFEST_NAMES:
 76 |         manifest_path = os.path.join(addon_dir, manifest_name)
 77 |         if os.path.exists(manifest_path):
 78 |             return manifest_path
 79 |     return None
 80 | 
 81 | 
 82 | def parse_manifest(manifest: bytes) -> dict:
 83 |     return ast.literal_eval(manifest.decode("utf-8"))
 84 | 
 85 | 
 86 | def get_manifest(addon_dir):
 87 |     manifest_path = get_manifest_path(addon_dir)
 88 |     if not manifest_path:
 89 |         raise NoManifestFound(f"no manifest found in {addon_dir}")
 90 |     with open(manifest_path, "rb") as f:
 91 |         return parse_manifest(f.read())
 92 | 
 93 | 
 94 | def set_manifest_version(addon_dir, version):
 95 |     manifest_path = get_manifest_path(addon_dir)
 96 |     with open(manifest_path) as f:
 97 |         manifest = f.read()
 98 |     manifest = MANIFEST_VERSION_RE.sub(r"\g
" + version + r"\g", manifest)
 99 |     with open(manifest_path, "w") as f:
100 |         f.write(manifest)
101 | 
102 | 
103 | def is_maintainer(username, addon_dirs):
104 |     for addon_dir in addon_dirs:
105 |         try:
106 |             manifest = get_manifest(addon_dir)
107 |         except NoManifestFound:
108 |             return False
109 |         maintainers = manifest.get("maintainers", [])
110 |         if username not in maintainers:
111 |             return False
112 |     return True
113 | 
114 | 
115 | def bump_version(version, mode):
116 |     mo = VERSION_RE.match(version)
117 |     if not mo:
118 |         raise RuntimeError(f"{version} does not match the expected version pattern.")
119 |     series = mo.group("series")
120 |     major = mo.group("major")
121 |     minor = mo.group("minor")
122 |     patch = mo.group("patch")
123 |     if mode == "major":
124 |         major = int(major) + 1
125 |         minor = 0
126 |         patch = 0
127 |     elif mode == "minor":
128 |         minor = int(minor) + 1
129 |         patch = 0
130 |     elif mode == "patch":
131 |         patch = int(patch) + 1
132 |     else:
133 |         raise RuntimeError("Unexpected bumpversion mode f{mode}")
134 |     return f"{series}.{major}.{minor}.{patch}"
135 | 
136 | 
137 | def bump_manifest_version(addon_dir, mode, git_commit=False):
138 |     version = get_manifest(addon_dir)["version"]
139 |     version = bump_version(version, mode)
140 |     set_manifest_version(addon_dir, version)
141 |     if git_commit:
142 |         addon_name = get_addon_name(addon_dir)
143 |         check_call(
144 |             [
145 |                 "git",
146 |                 "commit",
147 |                 "-m",
148 |                 f"[BOT] {addon_name} {version}",
149 |                 "--",
150 |                 get_manifest_file_name(addon_dir),
151 |             ],
152 |             cwd=addon_dir,
153 |         )
154 | 
155 | 
156 | def git_modified_addons(addons_dir, ref):
157 |     """
158 |     List addons that have been modified in the current branch compared to
159 |     ref, after simulating a merge in ref.
160 |     Deleted addons are not returned.
161 | 
162 |     Returns a tuple with a set of modified addons, and a flag telling
163 |     if something else than addons has been modified.
164 |     """
165 |     modified = set()
166 |     current_branch = git_get_current_branch(cwd=addons_dir)
167 |     check_call(["git", "checkout", ref], cwd=addons_dir)
168 |     check_call(["git", "checkout", "-B", "tmp-git-modified-addons"], cwd=addons_dir)
169 |     check_call(["git", "merge", current_branch], cwd=addons_dir)
170 |     diffs = check_output(["git", "diff", "--name-only", ref, "--"], cwd=addons_dir)
171 |     check_call(["git", "checkout", current_branch], cwd=addons_dir)
172 |     other_changes = False
173 |     for diff in diffs.split("\n"):
174 |         if not diff:
175 |             continue
176 |         if "/" not in diff:
177 |             # file at repo root modified
178 |             other_changes = True
179 |             continue
180 |         parts = diff.split("/")
181 |         if parts[0] == "setup" and len(parts) > 1:
182 |             addon_name = parts[1]
183 |             if is_addon_dir(
184 |                 os.path.join(addons_dir, "setup", addon_name, "odoo_addons", addon_name)
185 |             ) or is_addon_dir(
186 |                 os.path.join(
187 |                     addons_dir, "setup", addon_name, "odoo", "addons", addon_name
188 |                 )
189 |             ):
190 |                 modified.add(addon_name)
191 |             else:
192 |                 other_changes = True
193 |         else:
194 |             addon_name = parts[0]
195 |             if is_addon_dir(os.path.join(addons_dir, addon_name)):
196 |                 modified.add(addon_name)
197 |             else:
198 |                 other_changes = True
199 |     return modified, other_changes
200 | 
201 | 
202 | def git_modified_addon_dirs(addons_dir, ref):
203 |     modified_addons, other_changes = git_modified_addons(addons_dir, ref)
204 |     return (
205 |         [os.path.join(addons_dir, addon) for addon in modified_addons],
206 |         other_changes,
207 |         modified_addons,
208 |     )
209 | 
210 | 
211 | def get_odoo_series_from_version(version):
212 |     mo = VERSION_RE.match(version)
213 |     if not mo:
214 |         raise OdooSeriesNotDetected()
215 |     series = mo.group("series")
216 |     if not series:
217 |         raise OdooSeriesNotDetected()
218 |     return tuple(int(s) for s in series.split("."))
219 | 
220 | 
221 | def get_odoo_series_from_branch(branch) -> Tuple[int, int]:
222 |     mo = BRANCH_RE.match(branch)
223 |     if not mo:
224 |         raise OdooSeriesNotDetected()
225 |     series = mo.group("series")
226 |     if not series:
227 |         raise OdooSeriesNotDetected()
228 |     return tuple(int(s) for s in series.split("."))
229 | 
230 | 
231 | def user_can_push(gh, org, repo, username, addons_dir, target_branch):
232 |     """
233 |     Check if the user is maintainer of all modified addons.
234 | 
235 |     Assuming, addons_dir is a git clone of an addons repository,
236 |     return true if username is declared in the maintainers key
237 |     on the target branch, for all addons modified in the current branch
238 |     compared to the target_branch.
239 |     If the username is not declared as maintainer on the current branch,
240 |     check if the user is maintainer in other branches.
241 |     """
242 |     gh_repo = gh.repository(org, repo)
243 |     if github_user_can_push(gh_repo, username):
244 |         return True
245 |     modified_addon_dirs, other_changes, modified_addons = git_modified_addon_dirs(
246 |         addons_dir, target_branch
247 |     )
248 |     if other_changes or not modified_addon_dirs:
249 |         return False
250 |     # if we are modifying addons only, then the user must be maintainer of
251 |     # all of them on the target branch
252 |     current_branch = git_get_current_branch(cwd=addons_dir)
253 |     try:
254 |         check_call(["git", "checkout", target_branch], cwd=addons_dir)
255 |         result = is_maintainer(username, modified_addon_dirs)
256 |     finally:
257 |         check_call(["git", "checkout", current_branch], cwd=addons_dir)
258 | 
259 |     if result:
260 |         return True
261 | 
262 |     other_branches = config.MAINTAINER_CHECK_ODOO_RELEASES
263 |     if target_branch in other_branches:
264 |         other_branches.remove(target_branch)
265 | 
266 |     return is_maintainer_other_branches(
267 |         org, repo, username, modified_addons, other_branches
268 |     )
269 | 
270 | 
271 | def is_maintainer_other_branches(org, repo, username, modified_addons, other_branches):
272 |     for addon in modified_addons:
273 |         is_maintainer = False
274 |         for branch in other_branches:
275 |             manifest_file = (
276 |                 "__openerp__.py" if float(branch) < 10.0 else "__manifest__.py"
277 |             )
278 |             url = (
279 |                 f"https://github.com/{org}/{repo}/raw/{branch}/{addon}/{manifest_file}"
280 |             )
281 |             _logger.debug("Looking for maintainers in %s" % url)
282 |             r = requests.get(
283 |                 url, allow_redirects=True, headers={"Cache-Control": "no-cache"}
284 |             )
285 |             if r.ok:
286 |                 manifest = parse_manifest(r.content)
287 |                 if username in manifest.get("maintainers", []):
288 |                     is_maintainer = True
289 |                     break
290 | 
291 |         if not is_maintainer:
292 |             return False
293 |     return True
294 | 


--------------------------------------------------------------------------------
/src/oca_github_bot/odoo_client.py:
--------------------------------------------------------------------------------
 1 | # Copyright (c) ACSONE SA/NV 2018
 2 | # Distributed under the MIT License (http://opensource.org/licenses/MIT).
 3 | 
 4 | from contextlib import contextmanager
 5 | from urllib.parse import urlparse
 6 | 
 7 | import odoorpc
 8 | 
 9 | from .config import ODOO_DB, ODOO_LOGIN, ODOO_PASSWORD, ODOO_URL
10 | 
11 | 
12 | @contextmanager
13 | def login():
14 |     url = urlparse(ODOO_URL)
15 |     if url.scheme == "https":
16 |         protocol = "jsonrpc+ssl"
17 |     elif url.scheme == "http":
18 |         protocol = "jsonrpc"
19 |     if ":" in url.netloc:
20 |         host, port = url.netloc.split(":")
21 |         port = int(port)
22 |     else:
23 |         host = url.netloc
24 |         if url.scheme == "https":
25 |             port = 443
26 |         elif url.scheme == "http":
27 |             port = 80
28 |     odoo = odoorpc.ODOO(host, protocol=protocol, port=port)
29 |     odoo.login(ODOO_DB, ODOO_LOGIN, ODOO_PASSWORD)
30 |     yield odoo
31 | 


--------------------------------------------------------------------------------
/src/oca_github_bot/process.py:
--------------------------------------------------------------------------------
 1 | # Copyright (c) ACSONE SA/NV 2018
 2 | # Distributed under the MIT License (http://opensource.org/licenses/MIT).
 3 | 
 4 | import logging
 5 | import subprocess
 6 | from subprocess import CalledProcessError  # noqa
 7 | 
 8 | _logger = logging.getLogger(__name__)
 9 | 
10 | 
11 | def call(cmd, cwd):
12 |     return subprocess.call(cmd, cwd=cwd)
13 | 
14 | 
15 | def check_call(cmd, cwd, log_error=True, extra_cmd_args=False, env=None):
16 |     if extra_cmd_args:
17 |         cmd += extra_cmd_args
18 |     cp = subprocess.run(
19 |         cmd,
20 |         universal_newlines=True,
21 |         stdout=subprocess.PIPE,
22 |         stderr=subprocess.STDOUT,
23 |         cwd=cwd,
24 |         env=env,
25 |     )
26 |     if cp.returncode and log_error:
27 |         _logger.error(
28 |             f"command {cp.args} in {cwd} failed with return code {cp.returncode} "
29 |             f"and output:\n{cp.stdout}"
30 |         )
31 |     cp.check_returncode()
32 | 
33 | 
34 | def check_output(cmd, cwd, log_error=True):
35 |     cp = subprocess.run(
36 |         cmd,
37 |         universal_newlines=True,
38 |         stdout=subprocess.PIPE,
39 |         stderr=subprocess.STDOUT,
40 |         cwd=cwd,
41 |     )
42 |     if cp.returncode and log_error:
43 |         _logger.error(
44 |             f"command {cp.args} failed with return code {cp.returncode} "
45 |             f"and output:\n{cp.stdout}"
46 |         )
47 |     cp.check_returncode()
48 |     return cp.stdout
49 | 


--------------------------------------------------------------------------------
/src/oca_github_bot/pypi.py:
--------------------------------------------------------------------------------
  1 | # Copyright (c) ACSONE SA/NV 2021
  2 | # Distributed under the MIT License (http://opensource.org/licenses/MIT).
  3 | """Utilities to work with PEP 503 package indexes."""
  4 | import logging
  5 | import os
  6 | from io import StringIO
  7 | from pathlib import PosixPath
  8 | from subprocess import CalledProcessError
  9 | from typing import Iterator, Optional, Tuple
 10 | from urllib.parse import urljoin, urlparse
 11 | 
 12 | import requests
 13 | from lxml import etree
 14 | 
 15 | from .process import check_call
 16 | 
 17 | _logger = logging.getLogger(__name__)
 18 | 
 19 | 
 20 | def files_on_index(
 21 |     index_url: str, project_name: str
 22 | ) -> Iterator[Tuple[str, Optional[Tuple[str, str]]]]:
 23 |     """Iterate files available on an index for a given project name."""
 24 |     project_name = project_name.replace("_", "-")
 25 |     base_url = urljoin(index_url, project_name + "/")
 26 | 
 27 |     r = requests.get(base_url)
 28 |     if r.status_code == 404:
 29 |         # project not found on this index
 30 |         return
 31 |     r.raise_for_status()
 32 |     parser = etree.HTMLParser()
 33 |     tree = etree.parse(StringIO(r.text), parser)
 34 |     for a in tree.iterfind(".//a"):
 35 |         parsed_url = urlparse(a.get("href"))
 36 |         p = PosixPath(parsed_url.path)
 37 |         if parsed_url.fragment:
 38 |             hash_type, hash_value = parsed_url.fragment.split("=", 2)[:2]
 39 |             yield p.name, (hash_type, hash_value)
 40 |         else:
 41 |             yield p.name, None
 42 | 
 43 | 
 44 | def exists_on_index(index_url: str, filename: str) -> bool:
 45 |     """Check if a distribution exists on a package index."""
 46 |     project_name = filename.split("-", 1)[0]
 47 |     for filename_on_index, _ in files_on_index(index_url, project_name):
 48 |         if filename_on_index == filename:
 49 |             return True
 50 |     return False
 51 | 
 52 | 
 53 | class DistPublisher:
 54 |     def publish(self, dist_dir: str, dry_run: bool) -> None:
 55 |         raise NotImplementedError()
 56 | 
 57 | 
 58 | class MultiDistPublisher(DistPublisher):
 59 |     def __init__(self):
 60 |         self._dist_publishers = []
 61 | 
 62 |     def add(self, dist_publisher: DistPublisher) -> None:
 63 |         self._dist_publishers.append(dist_publisher)
 64 | 
 65 |     def publish(self, dist_dir: str, dry_run: bool) -> None:
 66 |         for dist_publisher in self._dist_publishers:
 67 |             dist_publisher.publish(dist_dir, dry_run)
 68 | 
 69 | 
 70 | class TwineDistPublisher:
 71 |     def __init__(
 72 |         self,
 73 |         index_url: str,
 74 |         repository_url: str,
 75 |         username: str,
 76 |         password: str,
 77 |     ):
 78 |         self._index_url = index_url
 79 |         self._repository_url = repository_url
 80 |         self._username = username
 81 |         self._password = password
 82 | 
 83 |     def publish(self, dist_dir: str, dry_run: bool) -> None:
 84 |         for filename in os.listdir(dist_dir):
 85 |             if exists_on_index(self._index_url, filename):
 86 |                 _logger.info(
 87 |                     f"Not uploading {filename} that already exists "
 88 |                     f"on {self._repository_url}."
 89 |                 )
 90 |                 continue
 91 |             _logger.info(f"Uploading {filename} to {self._repository_url}")
 92 |             cmd = [
 93 |                 "twine",
 94 |                 "upload",
 95 |                 "--disable-progress-bar",
 96 |                 "--non-interactive",
 97 |                 "--repository-url",
 98 |                 self._repository_url,
 99 |                 "-u",
100 |                 self._username,
101 |                 filename,
102 |             ]
103 |             if dry_run:
104 |                 _logger.info("DRY-RUN" + " ".join(cmd))
105 |             else:
106 |                 _logger.info(" ".join(cmd))
107 |                 try:
108 |                     check_call(
109 |                         cmd,
110 |                         cwd=dist_dir,
111 |                         env=dict(os.environ, TWINE_PASSWORD=self._password),
112 |                     )
113 |                 except CalledProcessError as e:
114 |                     if (
115 |                         "File already exists" in e.output
116 |                         or "This filename has already been used" in e.output
117 |                     ):
118 |                         # in case exist_on_index() received an outdated index page
119 |                         _logger.warning(
120 |                             f"Could not upload {filename} that already exists "
121 |                             f"on {self._repository_url}."
122 |                         )
123 |                     else:
124 |                         raise
125 | 
126 | 
127 | class RsyncDistPublisher(DistPublisher):
128 |     def __init__(self, rsync_target):
129 |         self._rsync_target = rsync_target
130 | 
131 |     def publish(self, dist_dir: str, dry_run: bool) -> None:
132 |         pkgname = _find_pkgname_in_dist_dir(dist_dir)
133 |         # --ignore-existing: never overwrite an existing package
134 |         # os.path.join: make sure directory names end with /
135 |         cmd = [
136 |             "rsync",
137 |             "-rv",
138 |             "--ignore-existing",
139 |             "--no-perms",
140 |             "--chmod=ugo=rwX",
141 |             os.path.join(dist_dir, ""),
142 |             os.path.join(self._rsync_target, pkgname, ""),
143 |         ]
144 |         if dry_run:
145 |             _logger.info("DRY-RUN" + " ".join(cmd))
146 |         else:
147 |             _logger.info(" ".join(cmd))
148 |             check_call(cmd, cwd=".")
149 | 
150 | 
151 | def _find_pkgname_in_dist_dir(dist_dir: str) -> str:
152 |     """Find the package name by looking at .whl files"""
153 |     pkgname = None
154 |     for f in os.listdir(dist_dir):
155 |         if f.endswith(".whl"):
156 |             new_pkgname = f.split("-")[0].replace("_", "-")
157 |             if pkgname and new_pkgname != pkgname:
158 |                 raise RuntimeError(f"Multiple packages names in {dist_dir}")
159 |             pkgname = new_pkgname
160 |     if not pkgname:
161 |         raise RuntimeError(f"Package name not found in {dist_dir}")
162 |     return pkgname
163 | 


--------------------------------------------------------------------------------
/src/oca_github_bot/queue.py:
--------------------------------------------------------------------------------
 1 | # Copyright (c) ACSONE SA/NV 2018
 2 | # Distributed under the MIT License (http://opensource.org/licenses/MIT).
 3 | 
 4 | import celery
 5 | from celery.utils.log import get_task_logger
 6 | 
 7 | from . import config
 8 | 
 9 | app = celery.Celery(
10 |     broker=config.BROKER_URI,
11 |     broker_connection_retry_on_startup=True,
12 |     broker_conn_retry=True,
13 | )
14 | 
15 | getLogger = get_task_logger
16 | 
17 | task = app.task
18 | 
19 | 
20 | if config.SENTRY_DSN:
21 |     import sentry_sdk
22 | 
23 |     sentry_sdk.init(dsn=config.SENTRY_DSN)
24 | 


--------------------------------------------------------------------------------
/src/oca_github_bot/router.py:
--------------------------------------------------------------------------------
1 | # Copyright (c) ACSONE SA/NV 2018
2 | # Distributed under the MIT License (http://opensource.org/licenses/MIT).
3 | 
4 | from gidgethub import routing
5 | 
6 | router = routing.Router()
7 | 


--------------------------------------------------------------------------------
/src/oca_github_bot/tasks/__init__.py:
--------------------------------------------------------------------------------
 1 | # Copyright (c) ACSONE SA/NV 2018
 2 | # Distributed under the MIT License (http://opensource.org/licenses/MIT).
 3 | 
 4 | from . import (
 5 |     heartbeat,
 6 |     main_branch_bot,
 7 |     mention_maintainer,
 8 |     migration_issue_bot,
 9 |     tag_approved,
10 |     tag_needs_review,
11 |     tag_ready_to_merge,
12 | )
13 | 


--------------------------------------------------------------------------------
/src/oca_github_bot/tasks/add_pr_comment.py:
--------------------------------------------------------------------------------
 1 | # Copyright (c) GRAP SCIC SA 2020 (http://www.grap.coop)
 2 | # Distributed under the MIT License (http://opensource.org/licenses/MIT).
 3 | 
 4 | from .. import github
 5 | from ..queue import task
 6 | 
 7 | 
 8 | @task()
 9 | def add_pr_comment(org, repo, pr, message):
10 |     with github.login() as gh:
11 |         gh_pr = gh.pull_request(org, repo, pr)
12 |         github.gh_call(gh_pr.create_comment, message)
13 | 


--------------------------------------------------------------------------------
/src/oca_github_bot/tasks/delete_branch.py:
--------------------------------------------------------------------------------
 1 | # Copyright (c) ACSONE SA/NV 2018
 2 | # Distributed under the MIT License (http://opensource.org/licenses/MIT).
 3 | 
 4 | from .. import github
 5 | from ..config import switchable
 6 | from ..github import gh_call
 7 | from ..queue import getLogger, task
 8 | 
 9 | _logger = getLogger(__name__)
10 | 
11 | 
12 | @task()
13 | @switchable()
14 | def delete_branch(org, repo, branch, dry_run=False):
15 |     with github.repository(org, repo) as gh_repo:
16 |         gh_branch = gh_call(gh_repo.ref, f"heads/{branch}")
17 |         if dry_run:
18 |             _logger.info(f"DRY-RUN delete branch {branch} in {org}/{repo}")
19 |         else:
20 |             _logger.info(f"deleting branch {branch} in {org}/{repo}")
21 |             gh_call(gh_branch.delete)
22 | 


--------------------------------------------------------------------------------
/src/oca_github_bot/tasks/heartbeat.py:
--------------------------------------------------------------------------------
 1 | # Copyright (c) ACSONE SA/NV 2018
 2 | # Distributed under the MIT License (http://opensource.org/licenses/MIT).
 3 | 
 4 | from ..queue import getLogger, task
 5 | 
 6 | _logger = getLogger(__name__)
 7 | 
 8 | 
 9 | @task()
10 | def heartbeat():
11 |     _logger.info("heartbeat")
12 | 


--------------------------------------------------------------------------------
/src/oca_github_bot/tasks/main_branch_bot.py:
--------------------------------------------------------------------------------
  1 | # Copyright (c) ACSONE SA/NV 2018
  2 | # Distributed under the MIT License (http://opensource.org/licenses/MIT).
  3 | 
  4 | from .. import github, manifest
  5 | from ..build_wheels import build_and_publish_metapackage_wheel, build_and_publish_wheels
  6 | from ..config import (
  7 |     GEN_ADDON_ICON_EXTRA_ARGS,
  8 |     GEN_ADDON_README_EXTRA_ARGS,
  9 |     GEN_ADDONS_TABLE_EXTRA_ARGS,
 10 |     GEN_PYPROJECT_MIN_VERSION,
 11 |     dist_publisher,
 12 |     switchable,
 13 | )
 14 | from ..github import git_commit_if_needed, git_push_if_needed, temporary_clone
 15 | from ..manifest import get_odoo_series_from_branch
 16 | from ..process import check_call
 17 | from ..queue import getLogger, task
 18 | from ..version_branch import is_main_branch_bot_branch, is_supported_main_branch
 19 | 
 20 | _logger = getLogger(__name__)
 21 | 
 22 | 
 23 | @switchable("gen_addons_table")
 24 | def _gen_addons_table(org, repo, branch, cwd):
 25 |     _logger.info("oca-gen-addons-table in %s/%s@%s", org, repo, branch)
 26 |     gen_addons_table_cmd = ["oca-gen-addons-table", "--commit"]
 27 |     check_call(
 28 |         gen_addons_table_cmd, cwd=cwd, extra_cmd_args=GEN_ADDONS_TABLE_EXTRA_ARGS
 29 |     )
 30 | 
 31 | 
 32 | @switchable("gen_addons_readme")
 33 | def _gen_addons_readme(org, repo, branch, cwd):
 34 |     _logger.info("oca-gen-addon-readme in %s/%s@%s", org, repo, branch)
 35 |     gen_addon_readme_cmd = [
 36 |         "oca-gen-addon-readme",
 37 |         "--if-source-changed",
 38 |         "--org-name",
 39 |         org,
 40 |         "--repo-name",
 41 |         repo,
 42 |         "--branch",
 43 |         branch,
 44 |         "--addons-dir",
 45 |         cwd,
 46 |         "--commit",
 47 |     ]
 48 |     check_call(
 49 |         gen_addon_readme_cmd, cwd=cwd, extra_cmd_args=GEN_ADDON_README_EXTRA_ARGS
 50 |     )
 51 | 
 52 | 
 53 | @switchable("gen_addons_icon")
 54 | def _gen_addons_icon(org, repo, branch, cwd):
 55 |     _logger.info("oca-gen-addon-icon in %s/%s@%s", org, repo, branch)
 56 |     gen_addon_icon_cmd = ["oca-gen-addon-icon", "--addons-dir", cwd, "--commit"]
 57 |     check_call(gen_addon_icon_cmd, cwd=cwd)
 58 | 
 59 | 
 60 | @switchable("setuptools_odoo")
 61 | def _setuptools_odoo_make_default(org, repo, branch, cwd):
 62 |     _logger.info("setuptools-odoo-make-default in %s/%s@%s\n", org, repo, branch)
 63 |     make_default_setup_cmd = [
 64 |         "setuptools-odoo-make-default",
 65 |         "--addons-dir",
 66 |         cwd,
 67 |         "--metapackage",
 68 |         org.lower() + "-" + repo,
 69 |         "--clean",
 70 |         "--commit",
 71 |     ]
 72 |     check_call(
 73 |         make_default_setup_cmd, cwd=cwd, extra_cmd_args=GEN_ADDON_ICON_EXTRA_ARGS
 74 |     )
 75 | 
 76 | 
 77 | @switchable("whool_init")
 78 | def _whool_init(org, repo, branch, cwd):
 79 |     _logger.info(
 80 |         "generate pyproejct.toml with whool init in %s/%s@%s\n", org, repo, branch
 81 |     )
 82 |     whool_init_cmd = ["whool", "init"]
 83 |     check_call(whool_init_cmd, cwd=cwd)
 84 |     git_commit_if_needed("*/pyproject.toml", "[BOT] add pyproject.toml", cwd=cwd)
 85 | 
 86 | 
 87 | @switchable("gen_metapackage")
 88 | def _gen_metapackage(org, repo, branch, cwd):
 89 |     if not is_supported_main_branch(branch, min_version="15.0"):
 90 |         # We don't support branches < 15 because I don't want to worry about
 91 |         # the package name prefix. From 15.0 on, the package name is always prefixed
 92 |         # with "odoo-addons-".
 93 |         _logger.warning("gen_metapackage not supported for branch %s", branch)
 94 |         return
 95 |     _logger.info("oca-gen-metapackage in %s/%s@%s\n", org, repo, branch)
 96 |     gen_metapackage_cmd = ["oca-gen-metapackage", f"odoo-addons-{org.lower()}-{repo}"]
 97 |     check_call(gen_metapackage_cmd, cwd=cwd)
 98 |     git_commit_if_needed(
 99 |         "setup/_metapackage", "[BOT] add or update setup/_metapackage", cwd=cwd
100 |     )
101 | 
102 | 
103 | def main_branch_bot_actions(org, repo, branch, cwd):
104 |     """
105 |     Run main branch bot actions on a local git checkout.
106 | 
107 |     'cwd' is the directory containing the root of a local git checkout.
108 |     """
109 |     _logger.info(f"main_branch_bot {org}/{repo}@{branch}")
110 |     # update addons table in README.md
111 |     _gen_addons_table(org, repo, branch, cwd)
112 |     # generate README.rst
113 |     _gen_addons_readme(org, repo, branch, cwd)
114 |     # generate icon
115 |     _gen_addons_icon(org, repo, branch, cwd)
116 |     if is_supported_main_branch(branch, min_version=GEN_PYPROJECT_MIN_VERSION):
117 |         # generate pyproject.toml for addons and metapackage
118 |         _whool_init(org, repo, branch, cwd)
119 |         _gen_metapackage(org, repo, branch, cwd)
120 |     else:
121 |         # generate/clean default setup.py and metapackage
122 |         _setuptools_odoo_make_default(org, repo, branch, cwd)
123 | 
124 | 
125 | @task()
126 | def main_branch_bot(org, repo, branch, build_wheels, dry_run=False):
127 |     if not is_main_branch_bot_branch(branch):
128 |         return
129 |     with github.repository(org, repo) as gh_repo:
130 |         if gh_repo.fork:
131 |             return
132 |     with temporary_clone(org, repo, branch) as clone_dir:
133 |         if not manifest.is_addons_dir(clone_dir):
134 |             return
135 |         main_branch_bot_actions(org, repo, branch, clone_dir)
136 |         # push changes to git, if any
137 |         if dry_run:
138 |             _logger.info(f"DRY-RUN git push in {org}/{repo}@{branch}")
139 |         else:
140 |             _logger.info(f"git push in {org}/{repo}@{branch}")
141 |             git_push_if_needed("origin", branch, cwd=clone_dir)
142 |         if build_wheels:
143 |             build_and_publish_wheels(clone_dir, dist_publisher, dry_run)
144 |             build_and_publish_metapackage_wheel(
145 |                 clone_dir,
146 |                 dist_publisher,
147 |                 get_odoo_series_from_branch(branch),
148 |                 dry_run,
149 |             )
150 | 
151 | 
152 | @task()
153 | def main_branch_bot_all_repos(org, build_wheels, dry_run=False):
154 |     with github.login() as gh:
155 |         for repo in gh.repositories_by(org):
156 |             if repo.fork:
157 |                 continue
158 |             for branch in repo.branches():
159 |                 if not is_main_branch_bot_branch(branch.name):
160 |                     continue
161 |                 main_branch_bot.delay(
162 |                     org, repo.name, branch.name, build_wheels, dry_run
163 |                 )
164 | 


--------------------------------------------------------------------------------
/src/oca_github_bot/tasks/mention_maintainer.py:
--------------------------------------------------------------------------------
 1 | # Copyright 2019 Simone Rubino - Agile Business Group
 2 | # Distributed under the MIT License (http://opensource.org/licenses/MIT).
 3 | 
 4 | from .. import config, github
 5 | from ..config import switchable
 6 | from ..manifest import (
 7 |     addon_dirs_in,
 8 |     get_manifest,
 9 |     git_modified_addon_dirs,
10 |     is_addon_dir,
11 | )
12 | from ..process import check_call
13 | from ..queue import getLogger, task
14 | 
15 | _logger = getLogger(__name__)
16 | 
17 | 
18 | @task()
19 | @switchable("mention_maintainer")
20 | def mention_maintainer(org, repo, pr, dry_run=False):
21 |     with github.login() as gh:
22 |         gh_pr = gh.pull_request(org, repo, pr)
23 |         target_branch = gh_pr.base.ref
24 |         with github.temporary_clone(org, repo, target_branch) as clonedir:
25 |             # Get maintainers existing before the PR changes
26 |             addon_dirs = addon_dirs_in(clonedir, installable_only=True)
27 |             maintainers_dict = get_maintainers(addon_dirs)
28 | 
29 |             # Get list of addons modified in the PR.
30 |             pr_branch = f"tmp-pr-{pr}"
31 |             check_call(
32 |                 ["git", "fetch", "origin", f"refs/pull/{pr}/head:{pr_branch}"],
33 |                 cwd=clonedir,
34 |             )
35 |             check_call(["git", "checkout", pr_branch], cwd=clonedir)
36 |             modified_addon_dirs, _, _ = git_modified_addon_dirs(clonedir, target_branch)
37 | 
38 |             # Remove not installable addons
39 |             # (case where an addon becomes no more installable).
40 |             modified_addon_dirs = [
41 |                 d for d in modified_addon_dirs if is_addon_dir(d, installable_only=True)
42 |             ]
43 | 
44 |         modified_addons_maintainers = set()
45 |         for modified_addon in modified_addon_dirs:
46 |             addon_maintainers = maintainers_dict.get(modified_addon, list())
47 |             modified_addons_maintainers.update(addon_maintainers)
48 | 
49 |         pr_opener = gh_pr.user.login
50 |         if modified_addon_dirs and not modified_addons_maintainers:
51 |             all_mentions_comment = get_adopt_mention(pr_opener)
52 |         else:
53 |             modified_addons_maintainers.discard(pr_opener)
54 |             all_mentions_comment = get_mention(modified_addons_maintainers)
55 | 
56 |         if not all_mentions_comment:
57 |             return
58 | 
59 |         if dry_run:
60 |             _logger.info(f"DRY-RUN Comment {all_mentions_comment}")
61 |         else:
62 |             _logger.info(f"Comment {all_mentions_comment}")
63 |             return github.gh_call(gh_pr.create_comment, all_mentions_comment)
64 | 
65 | 
66 | def get_mention(maintainers):
67 |     """Get a comment mentioning all the `maintainers`."""
68 |     maintainers_mentions = list(map(lambda m: "@" + m, maintainers))
69 |     mentions_comment = ""
70 |     if maintainers_mentions:
71 |         mentions_comment = (
72 |             "Hi " + ", ".join(maintainers_mentions) + ",\n"
73 |             "some modules you are maintaining are being modified, "
74 |             "check this out!"
75 |         )
76 |     return mentions_comment
77 | 
78 | 
79 | def get_adopt_mention(pr_opener):
80 |     """Get a comment inviting to adopt the module."""
81 |     if config.ADOPT_AN_ADDON_MENTION:
82 |         return config.ADOPT_AN_ADDON_MENTION.format(pr_opener=pr_opener)
83 |     return None
84 | 
85 | 
86 | def get_maintainers(addon_dirs):
87 |     """Get maintainer for each addon in `addon_dirs`.
88 | 
89 |     :return: Dictionary {'addon_dir': }
90 |     """
91 |     addon_maintainers_dict = dict()
92 |     for addon_dir in addon_dirs:
93 |         maintainers = get_manifest(addon_dir).get("maintainers", [])
94 |         addon_maintainers_dict.setdefault(addon_dir, maintainers)
95 |     return addon_maintainers_dict
96 | 


--------------------------------------------------------------------------------
/src/oca_github_bot/tasks/merge_bot.py:
--------------------------------------------------------------------------------
  1 | # Copyright (c) ACSONE SA/NV 2019
  2 | # Distributed under the MIT License (http://opensource.org/licenses/MIT).
  3 | 
  4 | import contextlib
  5 | import random
  6 | from enum import Enum
  7 | 
  8 | from .. import github
  9 | from ..build_wheels import build_and_publish_wheel
 10 | from ..config import (
 11 |     GITHUB_CHECK_SUITES_IGNORED,
 12 |     GITHUB_STATUS_IGNORED,
 13 |     MERGE_BOT_INTRO_MESSAGES,
 14 |     dist_publisher,
 15 |     switchable,
 16 | )
 17 | from ..manifest import (
 18 |     bump_manifest_version,
 19 |     bump_version,
 20 |     get_manifest,
 21 |     git_modified_addon_dirs,
 22 |     is_addon_dir,
 23 |     user_can_push,
 24 | )
 25 | from ..process import CalledProcessError, call, check_call
 26 | from ..queue import getLogger, task
 27 | from ..utils import cmd_to_str, hide_secrets
 28 | from ..version_branch import make_merge_bot_branch, parse_merge_bot_branch
 29 | from .main_branch_bot import main_branch_bot_actions
 30 | from .migration_issue_bot import _mark_migration_done_in_migration_issue
 31 | 
 32 | _logger = getLogger(__name__)
 33 | 
 34 | LABEL_MERGED = "merged 🎉"
 35 | LABEL_MERGING = "bot is merging ⏳"
 36 | 
 37 | 
 38 | class MergeStrategy(Enum):
 39 |     rebase_autosquash = 1
 40 |     merge = 2
 41 | 
 42 | 
 43 | def _git_delete_branch(remote, branch, cwd):
 44 |     try:
 45 |         # delete merge bot branch
 46 |         check_call(["git", "push", remote, f":{branch}"], cwd=cwd, log_error=False)
 47 |     except CalledProcessError as e:
 48 |         if "unable to delete" in e.output:
 49 |             # remote branch may not exist on remote
 50 |             pass
 51 |         else:
 52 |             _logger.error(
 53 |                 f"command {e.cmd} failed with return code {e.returncode} "
 54 |                 f"and output:\n{e.output}"
 55 |             )
 56 |             raise
 57 | 
 58 | 
 59 | def _remove_merging_label(github, gh_pr, dry_run=False):
 60 |     gh_issue = github.gh_call(gh_pr.issue)
 61 |     labels = [label.name for label in gh_issue.labels()]
 62 |     if LABEL_MERGING in labels:
 63 |         if dry_run:
 64 |             _logger.info(f"DRY-RUN remove {LABEL_MERGING} label from PR {gh_pr.url}")
 65 |         else:
 66 |             _logger.info(f"remove {LABEL_MERGING} label from PR {gh_pr.url}")
 67 |             github.gh_call(gh_issue.remove_label, LABEL_MERGING)
 68 | 
 69 | 
 70 | def _get_merge_bot_intro_message():
 71 |     i = random.randint(0, len(MERGE_BOT_INTRO_MESSAGES) - 1)
 72 |     return MERGE_BOT_INTRO_MESSAGES[i]
 73 | 
 74 | 
 75 | @switchable("merge_bot_towncrier")
 76 | def _merge_bot_towncrier(org, repo, target_branch, addon_dirs, bumpversion_mode, cwd):
 77 |     for addon_dir in addon_dirs:
 78 |         # Run oca-towncrier: this updates and git add readme/HISTORY.rst
 79 |         # if readme/newsfragments contains files and does nothing otherwise.
 80 |         _logger.info(f"oca-towncrier {org}/{repo}@{target_branch} for {addon_dirs}")
 81 |         version = bump_version(get_manifest(addon_dir)["version"], bumpversion_mode)
 82 |         check_call(
 83 |             [
 84 |                 "oca-towncrier",
 85 |                 "--org",
 86 |                 org,
 87 |                 "--repo",
 88 |                 repo,
 89 |                 "--addon-dir",
 90 |                 addon_dir,
 91 |                 "--version",
 92 |                 version,
 93 |                 "--commit",
 94 |             ],
 95 |             cwd=cwd,
 96 |         )
 97 | 
 98 | 
 99 | def _merge_bot_merge_pr(org, repo, merge_bot_branch, cwd, dry_run=False):
100 |     pr, target_branch, username, bumpversion_mode = parse_merge_bot_branch(
101 |         merge_bot_branch
102 |     )
103 |     # first check if the merge bot branch is still on top of the target branch
104 |     check_call(["git", "checkout", target_branch], cwd=cwd)
105 |     r = call(
106 |         ["git", "merge-base", "--is-ancestor", target_branch, merge_bot_branch], cwd=cwd
107 |     )
108 |     if r != 0:
109 |         _logger.info(
110 |             f"{merge_bot_branch} can't be fast forwarded on {target_branch}, retrying."
111 |         )
112 |         intro_message = (
113 |             f"It looks like something changed on `{target_branch}` in the meantime.\n"
114 |             f"Let me try again (no action is required from you)."
115 |         )
116 |         merge_bot_start(
117 |             org,
118 |             repo,
119 |             pr,
120 |             username,
121 |             bumpversion_mode,
122 |             dry_run=dry_run,
123 |             intro_message=intro_message,
124 |         )
125 |         return False
126 |     # Get modified addons list on the PR and not on the merge bot branch
127 |     # because travis .pot generation may sometimes touch
128 |     # other addons unrelated to the PR and we don't want to bump
129 |     # version on those. This is also the least surprising approach, bumping
130 |     # version only on addons visibly modified on the PR, and not on
131 |     # other addons that may be modified by the bot for reasons unrelated
132 |     # to the PR.
133 |     check_call(["git", "fetch", "origin", f"refs/pull/{pr}/head:tmp-pr-{pr}"], cwd=cwd)
134 |     check_call(["git", "checkout", f"tmp-pr-{pr}"], cwd=cwd)
135 |     modified_addon_dirs, _, _ = git_modified_addon_dirs(cwd, target_branch)
136 |     check_call(["git", "checkout", merge_bot_branch], cwd=cwd)
137 | 
138 |     head_sha = github.git_get_head_sha(cwd)
139 | 
140 |     # list installable modified addons only
141 |     modified_installable_addon_dirs = [
142 |         d for d in modified_addon_dirs if is_addon_dir(d, installable_only=True)
143 |     ]
144 | 
145 |     # Update HISTORY.rst using towncrier, before generating README.rst.
146 |     # We don't do this if nobump is specified, because updating the changelog
147 |     # is something we only do when "releasing", and patch|minor|major is
148 |     # the way to mean "release" in OCA.
149 |     if bumpversion_mode != "nobump":
150 |         _merge_bot_towncrier(
151 |             org,
152 |             repo,
153 |             target_branch,
154 |             modified_installable_addon_dirs,
155 |             bumpversion_mode,
156 |             cwd,
157 |         )
158 | 
159 |     # bump manifest version of modified installable addons
160 |     if bumpversion_mode != "nobump":
161 |         for addon_dir in modified_installable_addon_dirs:
162 |             # bumpversion is last commit (after readme generation etc
163 |             # and before building wheel),
164 |             # so setuptools-odoo and whool generate a round version number
165 |             # (without .dev suffix).
166 |             bump_manifest_version(addon_dir, bumpversion_mode, git_commit=True)
167 | 
168 |     # run the main branch bot actions only if there are modified addon directories,
169 |     # so we don't run them when the merge bot branch for non-addons repos
170 |     if modified_addon_dirs:
171 |         # this includes setup.py and README.rst generation
172 |         main_branch_bot_actions(org, repo, target_branch, cwd=cwd)
173 | 
174 |     # squash post merge commits into one (bumpversion, readme generator, etc),
175 |     # to avoid a proliferation of automated actions commits
176 |     if github.git_get_head_sha(cwd) != head_sha:
177 |         check_call(["git", "reset", "--soft", head_sha], cwd=cwd)
178 |         check_call(["git", "commit", "-m", "[BOT] post-merge updates"], cwd=cwd)
179 | 
180 |     # We publish to PyPI before merging, because we don't want to merge
181 |     # if PyPI rejects the upload for any reason. There is a possibility
182 |     # that the upload succeeds and then the merge fails, but that should be
183 |     # exceptional, and it is better than the contrary.
184 |     for addon_dir in modified_installable_addon_dirs:
185 |         build_and_publish_wheel(addon_dir, dist_publisher, dry_run)
186 | 
187 |     if dry_run:
188 |         _logger.info(f"DRY-RUN git push in {org}/{repo}@{target_branch}")
189 |     else:
190 |         _logger.info(f"git push in {org}/{repo}@{target_branch}")
191 |         check_call(
192 |             ["git", "push", "origin", f"{merge_bot_branch}:{target_branch}"], cwd=cwd
193 |         )
194 | 
195 |     # TODO wlc unlock modified_addons
196 | 
197 |     # delete merge bot branch
198 |     _git_delete_branch("origin", merge_bot_branch, cwd=cwd)
199 | 
200 |     # notify sucessful merge in PR comments and labels
201 |     with github.login() as gh:
202 |         gh_pr = gh.pull_request(org, repo, pr)
203 |         gh_repo = gh.repository(org, repo)
204 |         merge_sha = github.git_get_head_sha(cwd=cwd)
205 |         github.gh_call(
206 |             gh_pr.create_comment,
207 |             f"Congratulations, your PR was merged at {merge_sha}. "
208 |             f"Thanks a lot for contributing to {org}. ❤️",
209 |         )
210 |         gh_issue = github.gh_call(gh_pr.issue)
211 |         _remove_merging_label(github, gh_pr, dry_run=dry_run)
212 |         if dry_run:
213 |             _logger.info(f"DRY-RUN add {LABEL_MERGED} label to PR {gh_pr.url}")
214 |         else:
215 |             _logger.info(f"add {LABEL_MERGED} label to PR {gh_pr.url}")
216 |             github.gh_call(gh_issue.add_labels, LABEL_MERGED)
217 |         github.gh_call(gh_pr.close)
218 | 
219 |         # Check line in migration issue if required
220 |         _mark_migration_done_in_migration_issue(gh_repo, target_branch, gh_pr)
221 |     return True
222 | 
223 | 
224 | def _prepare_merge_bot_branch(
225 |     merge_bot_branch, target_branch, pr_branch, pr, username, merge_strategy, cwd
226 | ):
227 |     if merge_strategy == MergeStrategy.merge:
228 |         # nothing to do on the pr branch
229 |         pass
230 |     elif merge_strategy == MergeStrategy.rebase_autosquash:
231 |         # rebase the pr branch onto the target branch
232 |         check_call(["git", "checkout", pr_branch], cwd=cwd)
233 |         check_call(["git", "rebase", "--autosquash", "-i", target_branch], cwd=cwd)
234 |     # create the merge commit
235 |     check_call(["git", "checkout", "-B", merge_bot_branch, target_branch], cwd=cwd)
236 |     msg = f"Merge PR #{pr} into {target_branch}\n\nSigned-off-by {username}"
237 |     check_call(["git", "merge", "--no-ff", "-m", msg, pr_branch], cwd=cwd)
238 | 
239 | 
240 | @task()
241 | @switchable("merge_bot")
242 | def merge_bot_start(
243 |     org,
244 |     repo,
245 |     pr,
246 |     username,
247 |     bumpversion_mode,
248 |     dry_run=False,
249 |     intro_message=None,
250 |     merge_strategy=MergeStrategy.merge,
251 | ):
252 |     with github.login() as gh:
253 |         gh_pr = gh.pull_request(org, repo, pr)
254 |         target_branch = gh_pr.base.ref
255 |         merge_bot_branch = make_merge_bot_branch(
256 |             pr, target_branch, username, bumpversion_mode
257 |         )
258 |         pr_branch = f"tmp-pr-{pr}"
259 |         try:
260 |             with github.temporary_clone(org, repo, target_branch) as clone_dir:
261 |                 # create merge bot branch from PR and rebase it on target branch
262 |                 check_call(
263 |                     ["git", "fetch", "origin", f"pull/{pr}/head:{pr_branch}"],
264 |                     cwd=clone_dir,
265 |                 )
266 |                 check_call(["git", "checkout", pr_branch], cwd=clone_dir)
267 |                 if not user_can_push(gh, org, repo, username, clone_dir, target_branch):
268 |                     github.gh_call(
269 |                         gh_pr.create_comment,
270 |                         f"Sorry @{username} you are not allowed to merge.\n\n"
271 |                         f"To do so you must either have push permissions on "
272 |                         f"the repository, or be a declared maintainer of all "
273 |                         f"modified addons.\n\n"
274 |                         f"If you wish to adopt an addon and become it's "
275 |                         f"[maintainer]"
276 |                         f"(https://odoo-community.org/page/maintainer-role), "
277 |                         f"open a pull request to add "
278 |                         f"your GitHub login to the `maintainers` key of its "
279 |                         f"manifest.",
280 |                     )
281 |                     return
282 |                 # TODO for each modified addon, wlc lock / commit / push
283 |                 # TODO then pull target_branch again
284 |                 _prepare_merge_bot_branch(
285 |                     merge_bot_branch,
286 |                     target_branch,
287 |                     pr_branch,
288 |                     pr,
289 |                     username,
290 |                     merge_strategy,
291 |                     cwd=clone_dir,
292 |                 )
293 |                 # push and let tests run again; delete on origin
294 |                 # to be sure GitHub sees it as a new branch and relaunches all checks
295 |                 _git_delete_branch("origin", merge_bot_branch, cwd=clone_dir)
296 |                 check_call(["git", "push", "origin", merge_bot_branch], cwd=clone_dir)
297 |                 if not intro_message:
298 |                     intro_message = _get_merge_bot_intro_message()
299 |                 github.gh_call(
300 |                     gh_pr.create_comment,
301 |                     f"{intro_message}\n"
302 |                     f"Prepared branch [{merge_bot_branch}]"
303 |                     f"(https://github.com/{org}/{repo}/commits/{merge_bot_branch}), "
304 |                     f"awaiting test results.",
305 |                 )
306 |         except CalledProcessError as e:
307 |             cmd = cmd_to_str(e.cmd)
308 |             github.gh_call(
309 |                 gh_pr.create_comment,
310 |                 hide_secrets(
311 |                     f"@{username} The merge process could not start, because "
312 |                     f"command `{cmd}` failed with output:\n```\n{e.output}\n```"
313 |                 ),
314 |             )
315 |             raise
316 |         except Exception as e:
317 |             github.gh_call(
318 |                 gh_pr.create_comment,
319 |                 hide_secrets(
320 |                     f"@{username} The merge process could not start, because "
321 |                     f"of exception {e}."
322 |                 ),
323 |             )
324 |             raise
325 |         else:
326 |             gh_issue = github.gh_call(gh_pr.issue)
327 |             _logger.info(f"add {LABEL_MERGING} label to PR {gh_pr.url}")
328 |             github.gh_call(gh_issue.add_labels, LABEL_MERGING)
329 | 
330 | 
331 | def _get_commit_success(org, repo, pr, gh_commit):
332 |     """Test commit status, using both status and check suites APIs"""
333 |     success = None  # None means don't know / in progress
334 |     gh_status = github.gh_call(gh_commit.status)
335 |     for status in gh_status.statuses:
336 |         if status.context in GITHUB_STATUS_IGNORED:
337 |             # ignore
338 |             _logger.info(
339 |                 f"Ignoring status {status.context} for PR #{pr} of {org}/{repo}"
340 |             )
341 |             continue
342 |         if status.state == "success":
343 |             _logger.info(
344 |                 f"Successful status {status.context} for PR #{pr} of {org}/{repo}"
345 |             )
346 |             success = True
347 |         elif status.state == "pending":
348 |             # in progress
349 |             _logger.info(
350 |                 f"Pending status {status.context} for PR #{pr} of {org}/{repo}"
351 |             )
352 |             return None
353 |         else:
354 |             _logger.info(
355 |                 f"Unsuccessful status {status.context} {status.state} for "
356 |                 f"PR #{pr} of {org}/{repo}"
357 |             )
358 |             return False
359 |     gh_check_suites = github.gh_call(gh_commit.check_suites)
360 |     for check_suite in gh_check_suites:
361 |         if check_suite.app.name in GITHUB_CHECK_SUITES_IGNORED:
362 |             # ignore
363 |             _logger.info(
364 |                 f"Ignoring check suite {check_suite.app.name} for "
365 |                 f"PR #{pr} of {org}/{repo}"
366 |             )
367 |             continue
368 |         if check_suite.conclusion == "success":
369 |             _logger.info(
370 |                 f"Successful check suite {check_suite.app.name} for "
371 |                 f"PR #{pr} of {org}/{repo}"
372 |             )
373 |             success = True
374 |         elif not check_suite.conclusion:
375 |             # not complete
376 |             check_runs = list(github.gh_call(check_suite.check_runs))
377 |             if not check_runs:
378 |                 _logger.info(
379 |                     f"Ignoring check suite {check_suite.app.name} "
380 |                     f"that has no check runs for "
381 |                     f"PR #{pr} of {org}/{repo}"
382 |                 )
383 |                 continue
384 |             _logger.info(
385 |                 f"Pending check suite {check_suite.app.name} for "
386 |                 f"PR #{pr} of {org}/{repo}"
387 |             )
388 |             return None
389 |         else:
390 |             _logger.info(
391 |                 f"Unsuccessful check suite {check_suite.app.name} "
392 |                 f"{check_suite.conclusion} for PR #{pr} of {org}/{repo}"
393 |             )
394 |             return False
395 |     return success
396 | 
397 | 
398 | @task()
399 | @switchable("merge_bot")
400 | def merge_bot_status(org, repo, merge_bot_branch, sha):
401 |     with contextlib.suppress(github.BranchNotFoundError):
402 |         with github.temporary_clone(org, repo, merge_bot_branch) as clone_dir:
403 |             head_sha = github.git_get_head_sha(cwd=clone_dir)
404 |             if head_sha != sha:
405 |                 # the branch has evolved, this means that this status
406 |                 # does not correspond to the last commit of the bot, ignore it
407 |                 return
408 |             pr, _, username, _ = parse_merge_bot_branch(merge_bot_branch)
409 |             with github.login() as gh:
410 |                 gh_repo = gh.repository(org, repo)
411 |                 gh_pr = gh.pull_request(org, repo, pr)
412 |                 gh_commit = github.gh_call(gh_repo.commit, sha)
413 |                 success = _get_commit_success(org, repo, pr, gh_commit)
414 |                 if success is None:
415 |                     # checks in progress
416 |                     return
417 |                 elif success:
418 |                     try:
419 |                         _merge_bot_merge_pr(org, repo, merge_bot_branch, clone_dir)
420 |                     except CalledProcessError as e:
421 |                         cmd = cmd_to_str(e.cmd)
422 |                         github.gh_call(
423 |                             gh_pr.create_comment,
424 |                             hide_secrets(
425 |                                 f"@{username} The merge process could not be "
426 |                                 f"finalized, because "
427 |                                 f"command `{cmd}` failed with output:\n```\n"
428 |                                 f"{e.output}\n```"
429 |                             ),
430 |                         )
431 |                         _remove_merging_label(github, gh_pr)
432 |                         raise
433 |                     except Exception as e:
434 |                         github.gh_call(
435 |                             gh_pr.create_comment,
436 |                             hide_secrets(
437 |                                 f"@{username} The merge process could not be "
438 |                                 f"finalized because an exception was raised: {e}."
439 |                             ),
440 |                         )
441 |                         _remove_merging_label(github, gh_pr)
442 |                         raise
443 |                 else:
444 |                     github.gh_call(
445 |                         gh_pr.create_comment,
446 |                         f"@{username} your merge command was aborted due to failed "
447 |                         f"check(s), which you can inspect on "
448 |                         f"[this commit of {merge_bot_branch}]"
449 |                         f"(https://github.com/{org}/{repo}/commits/{sha}).\n\n"
450 |                         f"After fixing the problem, you can re-issue a merge command. "
451 |                         f"Please refrain from merging manually as it will most "
452 |                         f"probably make the target branch red.",
453 |                     )
454 |                     check_call(
455 |                         ["git", "push", "origin", f":{merge_bot_branch}"], cwd=clone_dir
456 |                     )
457 |                     _remove_merging_label(github, gh_pr)
458 | 


--------------------------------------------------------------------------------
/src/oca_github_bot/tasks/migration_issue_bot.py:
--------------------------------------------------------------------------------
  1 | # Copyright 2019 Tecnativa - Pedro M. Baeza
  2 | # Copyright 2021 Tecnativa - Víctor Martínez
  3 | # Distributed under the MIT License (http://opensource.org/licenses/MIT).
  4 | 
  5 | import re
  6 | 
  7 | from .. import github
  8 | from ..config import switchable
  9 | from ..manifest import user_can_push
 10 | from ..process import check_call
 11 | from ..queue import task
 12 | from ..utils import hide_secrets
 13 | 
 14 | 
 15 | def _create_or_find_branch_milestone(gh_repo, branch):
 16 |     for milestone in gh_repo.milestones():
 17 |         if milestone.title == branch:
 18 |             return milestone
 19 |     return gh_repo.create_milestone(branch)
 20 | 
 21 | 
 22 | def _find_issue(gh_repo, milestone, target_branch):
 23 |     issue_title = f"Migration to version {target_branch}"
 24 |     issue = False
 25 |     for i in gh_repo.issues(milestone=milestone.number, state="all"):
 26 |         if i.title == issue_title:
 27 |             issue = i
 28 |             break
 29 |     return issue
 30 | 
 31 | 
 32 | def _check_line_issue(gh_pr_number, issue_body):
 33 |     lines = []
 34 |     regex = r"\#%s\b" % gh_pr_number
 35 |     for line in issue_body.split("\n"):
 36 |         if re.findall(regex, line):
 37 |             checked_line = line.replace("[ ]", "[x]", 1)
 38 |             lines.append(checked_line)
 39 |             continue
 40 |         lines.append(line)
 41 |     return "\n".join(lines)
 42 | 
 43 | 
 44 | def _set_lines_issue(gh_pr_user_login, gh_pr_number, issue_body, module):
 45 |     lines = []
 46 |     added = False
 47 |     module_list = False
 48 |     old_pr_number = False
 49 |     new_line = f"- [ ] {module} - By @{gh_pr_user_login} - #{gh_pr_number}"
 50 |     for line in issue_body.split("\n"):
 51 |         if added:  # Bypass the checks for faster completion
 52 |             lines.append(line)
 53 |             continue
 54 |         groups = re.match(rf"^- \[( |x)\] \b{module}\b", line)
 55 |         if groups:  # Line found
 56 |             # Get the Old PR value
 57 |             regex = r"\#(\d*)"
 58 |             old_pr_result = re.findall(regex, line)
 59 |             if old_pr_result:
 60 |                 old_pr_number = int(old_pr_result[0])
 61 |             # Respect check mark status if existing
 62 |             new_line = new_line[:3] + groups[1] + new_line[4:]
 63 |             lines.append(new_line)
 64 |             added = True
 65 |             continue
 66 |         else:
 67 |             splits = re.split(r"- \[[ |x]\] ([0-9a-zA-Z_]*)", line)
 68 |             if len(splits) >= 2:
 69 |                 # Flag for detecting if we have passed already module list
 70 |                 module_list = True
 71 |                 line_module = splits[1]
 72 |                 if line_module > module:
 73 |                     lines.append(new_line)
 74 |                     added = True
 75 |             elif module_list:
 76 |                 lines.append(new_line)
 77 |                 added = True
 78 |         lines.append(line)
 79 | 
 80 |     # make the addition working on an empty migration issue
 81 |     if not added:
 82 |         lines.append(new_line)
 83 |     return "\n".join(lines), old_pr_number
 84 | 
 85 | 
 86 | def _mark_migration_done_in_migration_issue(gh_repo, target_branch, gh_pr):
 87 |     milestone = _create_or_find_branch_milestone(gh_repo, target_branch)
 88 |     migration_issue = _find_issue(gh_repo, milestone, target_branch)
 89 |     if migration_issue:
 90 |         new_body = _check_line_issue(gh_pr.number, migration_issue.body)
 91 |         migration_issue.edit(body=new_body)
 92 | 
 93 | 
 94 | @task()
 95 | @switchable("migration_issue_bot")
 96 | def migration_issue_start(org, repo, pr, username, module=None, dry_run=False):
 97 |     with github.login() as gh:
 98 |         gh_pr = gh.pull_request(org, repo, pr)
 99 |         gh_repo = gh.repository(org, repo)
100 |         target_branch = gh_pr.base.ref
101 |         pr_branch = f"tmp-pr-{pr}"
102 |         try:
103 |             with github.temporary_clone(org, repo, target_branch) as clone_dir:
104 |                 # Create merge bot branch from PR and rebase it on target branch
105 |                 # This only serves for checking permissions
106 |                 check_call(
107 |                     ["git", "fetch", "origin", f"pull/{pr}/head:{pr_branch}"],
108 |                     cwd=clone_dir,
109 |                 )
110 |                 check_call(["git", "checkout", pr_branch], cwd=clone_dir)
111 |                 if not user_can_push(gh, org, repo, username, clone_dir, target_branch):
112 |                     github.gh_call(
113 |                         gh_pr.create_comment,
114 |                         f"Sorry @{username} you are not allowed to mark the addon to "
115 |                         f"be migrated.\n\n"
116 |                         f"To do so you must either have push permissions on "
117 |                         f"the repository, or be a declared maintainer of all "
118 |                         f"modified addons.\n\n"
119 |                         f"If you wish to adopt an addon and become it's "
120 |                         f"[maintainer]"
121 |                         f"(https://odoo-community.org/page/maintainer-role), "
122 |                         f"open a pull request to add "
123 |                         f"your GitHub login to the `maintainers` key of its "
124 |                         f"manifest.",
125 |                     )
126 |                     return
127 |             # Assign milestone to PR
128 |             milestone = _create_or_find_branch_milestone(gh_repo, target_branch)
129 |             gh_pr.issue().edit(milestone=milestone.number)
130 |             # Find issue
131 |             issue = _find_issue(gh_repo, milestone, target_branch)
132 |             if not issue:
133 |                 issue_title = f"Migration to version {target_branch}"
134 |                 github.gh_call(
135 |                     gh_pr.create_comment,
136 |                     f"There's no issue in this repo with the title '{issue_title}' "
137 |                     f"and the milestone {target_branch}, so not possible to add "
138 |                     f"the comment.",
139 |                 )
140 |                 return
141 |             # Change issue to add the PR in the module list
142 |             new_body, old_pr_number = _set_lines_issue(
143 |                 gh_pr.user.login, gh_pr.number, issue.body, module
144 |             )
145 |             if old_pr_number and old_pr_number != pr:
146 |                 old_pr = gh.pull_request(org, repo, old_pr_number)
147 |                 if old_pr.state == "closed":
148 |                     issue.edit(body=new_body)
149 |                 else:
150 |                     github.gh_call(
151 |                         gh_pr.create_comment,
152 |                         f"The migration issue (#{issue.number})"
153 |                         f" has not been updated to reference the current pull request"
154 |                         f" because a previous pull request (#{old_pr_number})"
155 |                         f" is not closed.\n"
156 |                         f"Perhaps you should check that there is no duplicate work.\n"
157 |                         f"CC @{old_pr.user.login}",
158 |                     )
159 |             else:
160 |                 issue.edit(body=new_body)
161 |         except Exception as e:
162 |             github.gh_call(
163 |                 gh_pr.create_comment,
164 |                 hide_secrets(
165 |                     f"@{username} The migration issue commenter process could not "
166 |                     f"start, because of exception {e}."
167 |                 ),
168 |             )
169 |             raise
170 | 


--------------------------------------------------------------------------------
/src/oca_github_bot/tasks/rebase_bot.py:
--------------------------------------------------------------------------------
  1 | # Copyright (c) ForgeFlow, S.L. 2021
  2 | # Distributed under the MIT License (http://opensource.org/licenses/MIT).
  3 | 
  4 | from .. import config, github
  5 | from ..config import switchable
  6 | from ..manifest import user_can_push
  7 | from ..process import CalledProcessError, check_call
  8 | from ..queue import getLogger, task
  9 | from ..utils import cmd_to_str, hide_secrets
 10 | 
 11 | _logger = getLogger(__name__)
 12 | 
 13 | 
 14 | @task()
 15 | @switchable("rebase_bot")
 16 | def rebase_bot_start(org, repo, pr, username, dry_run=False):
 17 |     with github.login() as gh:
 18 |         gh_pr = gh.pull_request(org, repo, pr)
 19 |         target_branch = gh_pr.base.ref
 20 |         remote = gh_pr.head._repo_owner
 21 |         pr_branch = f"tmp-pr-{pr}"
 22 |         try:
 23 |             with github.temporary_clone(org, repo, target_branch) as clone_dir:
 24 |                 if not remote:
 25 |                     github.gh_call(
 26 |                         gh_pr.create_comment,
 27 |                         f"Sorry @{username}, impossible to rebase because "
 28 |                         f"unknown remote.",
 29 |                     )
 30 |                     return
 31 |                 check_call(
 32 |                     ["git", "fetch", "origin", f"pull/{pr}/head:{pr_branch}"],
 33 |                     cwd=clone_dir,
 34 |                 )
 35 |                 check_call(["git", "checkout", pr_branch], cwd=clone_dir)
 36 |                 if not user_can_push(gh, org, repo, username, clone_dir, target_branch):
 37 |                     github.gh_call(
 38 |                         gh_pr.create_comment,
 39 |                         f"Sorry @{username} you are not allowed to rebase.\n\n"
 40 |                         f"To do so you must either have push permissions on "
 41 |                         f"the repository, or be a declared maintainer of all "
 42 |                         f"modified addons.\n\n"
 43 |                         f"If you wish to adopt an addon and become it's "
 44 |                         f"[maintainer]"
 45 |                         f"(https://odoo-community.org/page/maintainer-role), "
 46 |                         f"open a pull request to add "
 47 |                         f"your GitHub login to the `maintainers` key of its "
 48 |                         f"manifest.",
 49 |                     )
 50 |                     return
 51 |                 # do rebase
 52 |                 check_call(["git", "fetch", "origin", target_branch], cwd=clone_dir)
 53 |                 check_call(["git", "rebase", f"origin/{target_branch}"], cwd=clone_dir)
 54 |                 # push rebase
 55 |                 true_pr_branch = gh_pr.head.ref
 56 |                 if dry_run:
 57 |                     _logger.info(
 58 |                         f"DRY-RUN git push in {remote}/{repo}@{true_pr_branch}"
 59 |                     )
 60 |                 else:
 61 |                     _logger.info(f"git push in {remote}/{repo}@{true_pr_branch}")
 62 |                     check_call(
 63 |                         [
 64 |                             "git",
 65 |                             "remote",
 66 |                             "add",
 67 |                             remote,
 68 |                             f"https://github.com/{remote}/{repo}.git",
 69 |                         ],
 70 |                         cwd=clone_dir,
 71 |                     )
 72 |                     check_call(
 73 |                         [
 74 |                             "git",
 75 |                             "remote",
 76 |                             "set-url",
 77 |                             "--push",
 78 |                             remote,
 79 |                             f"https://{config.GITHUB_TOKEN}@github.com/{remote}/{repo}",
 80 |                         ],
 81 |                         cwd=clone_dir,
 82 |                     )
 83 |                     check_call(
 84 |                         [
 85 |                             "git",
 86 |                             "push",
 87 |                             "--force",
 88 |                             remote,
 89 |                             f"{pr_branch}:{true_pr_branch}",
 90 |                         ],
 91 |                         cwd=clone_dir,
 92 |                         log_error=False,
 93 |                     )
 94 |                 github.gh_call(
 95 |                     gh_pr.create_comment,
 96 |                     f"Congratulations, PR rebased to [{target_branch}]"
 97 |                     f"(https://github.com/{org}/{repo}/commits/{target_branch}).",
 98 |                 )
 99 |         except CalledProcessError as e:
100 |             cmd = cmd_to_str(e.cmd)
101 |             github.gh_call(
102 |                 gh_pr.create_comment,
103 |                 hide_secrets(
104 |                     f"@{username} The rebase process failed, because "
105 |                     f"command `{cmd}` failed with output:\n```\n{e.output}\n```"
106 |                 ),
107 |             )
108 |             raise
109 |         except Exception as e:
110 |             github.gh_call(
111 |                 gh_pr.create_comment,
112 |                 hide_secrets(
113 |                     f"@{username} The rebase process failed, because of exception {e}."
114 |                 ),
115 |             )
116 |             raise
117 | 


--------------------------------------------------------------------------------
/src/oca_github_bot/tasks/tag_approved.py:
--------------------------------------------------------------------------------
 1 | # Copyright (c) ACSONE SA/NV 2018
 2 | # Distributed under the MIT License (http://opensource.org/licenses/MIT).
 3 | 
 4 | from collections import defaultdict
 5 | 
 6 | from .. import github
 7 | from ..config import APPROVALS_REQUIRED, switchable
 8 | from ..github import gh_call
 9 | from ..queue import getLogger, task
10 | from .tag_ready_to_merge import LABEL_READY_TO_MERGE, tag_ready_to_merge
11 | 
12 | _logger = getLogger(__name__)
13 | 
14 | LABEL_APPROVED = "approved"
15 | 
16 | 
17 | @task()
18 | @switchable()
19 | def tag_approved(org, repo, pr, dry_run=False):
20 |     """Add the ``approved`` tag to the given PR if conditions are met.
21 | 
22 |     Remove it if conditions are not met.
23 |     """
24 |     with github.repository(org, repo) as gh_repo:
25 |         gh_pr = gh_call(gh_repo.pull_request, pr)
26 |         if not gh_pr.mergeable:
27 |             # TODO does this exclude merged and closed pr's?
28 |             # TODO remove approved and ready to merge labels here?
29 |             _logger.info(f"{gh_pr.url} is not mergeable, exiting")
30 |             return
31 |         reviews = list(gh_call(gh_pr.reviews))
32 |         # obtain last review state for each reviewer
33 |         review_state_by_user = {}  # login: last review state
34 |         for review in reviews:
35 |             if review.state == "COMMENTED":
36 |                 # ignore "commented" review, as they don't change
37 |                 # the review status
38 |                 continue
39 |             review_state_by_user[review.user.login] = review.state
40 |         # list users by review status
41 |         review_users_by_state = defaultdict(set)
42 |         for login, state in review_state_by_user.items():
43 |             review_users_by_state[state].add(login)
44 |         gh_issue = gh_call(gh_pr.issue)
45 |         labels = [label.name for label in gh_issue.labels()]
46 |         if (
47 |             len(review_users_by_state["APPROVED"]) >= APPROVALS_REQUIRED
48 |             and not review_users_by_state["CHANGES_REQUESTED"]
49 |         ):
50 |             if LABEL_APPROVED not in labels:
51 |                 if dry_run:
52 |                     _logger.info(
53 |                         f"DRY-RUN add {LABEL_APPROVED} label to PR {gh_pr.url}"
54 |                     )
55 |                 else:
56 |                     _logger.info(f"add {LABEL_APPROVED} label to PR {gh_pr.url}")
57 |                     gh_call(gh_issue.add_labels, LABEL_APPROVED)
58 |             tag_ready_to_merge.delay(org)
59 |         else:
60 |             # remove approved and ready to merge labels
61 |             if LABEL_APPROVED in labels:
62 |                 if dry_run:
63 |                     _logger.info(
64 |                         f"DRY-RUN remove {LABEL_APPROVED} label from PR {gh_pr.url}"
65 |                     )
66 |                 else:
67 |                     _logger.info(f"remove {LABEL_APPROVED} label from PR {gh_pr.url}")
68 |                     gh_call(gh_issue.remove_label, LABEL_APPROVED)
69 |             if LABEL_READY_TO_MERGE in labels:
70 |                 if dry_run:
71 |                     _logger.info(
72 |                         f"DRY-RUN remove {LABEL_READY_TO_MERGE} "
73 |                         f"label from PR {gh_pr.url}"
74 |                     )
75 |                 else:
76 |                     _logger.info(
77 |                         f"remove {LABEL_READY_TO_MERGE} label from PR {gh_pr.url}"
78 |                     )
79 |                     gh_call(gh_issue.remove_label, LABEL_READY_TO_MERGE)
80 | 


--------------------------------------------------------------------------------
/src/oca_github_bot/tasks/tag_needs_review.py:
--------------------------------------------------------------------------------
 1 | # Distributed under the MIT License (http://opensource.org/licenses/MIT).
 2 | 
 3 | from ..config import switchable
 4 | from ..github import gh_call, repository
 5 | from ..queue import getLogger, task
 6 | 
 7 | _logger = getLogger(__name__)
 8 | 
 9 | LABEL_NEEDS_REVIEW = "needs review"
10 | LABEL_WIP = "work in progress"
11 | 
12 | 
13 | @task()
14 | @switchable()
15 | def tag_needs_review(org, pr, repo, status, dry_run=False):
16 |     """On a successful execution of the CI tests, adds the `needs review`
17 |     label to the pull request if it doesn't have `wip:` at the
18 |     begining of the title (case insensitive). Removes the tag if the CI
19 |     fails.
20 |     """
21 |     with repository(org, repo) as gh_repo:
22 |         gh_pr = gh_call(gh_repo.pull_request, pr)
23 |         gh_issue = gh_call(gh_pr.issue)
24 |         labels = [label.name for label in gh_issue.labels()]
25 |         has_wip = (
26 |             gh_pr.title.lower().startswith(("wip:", "[wip]")) or LABEL_WIP in labels
27 |         )
28 |         if status == "success" and not has_wip:
29 |             if dry_run:
30 |                 _logger.info(f"DRY-RUN add {LABEL_NEEDS_REVIEW} label")
31 |             else:
32 |                 gh_call(gh_issue.add_labels, LABEL_NEEDS_REVIEW)
33 | 


--------------------------------------------------------------------------------
/src/oca_github_bot/tasks/tag_ready_to_merge.py:
--------------------------------------------------------------------------------
 1 | # Copyright (c) ACSONE SA/NV 2018
 2 | # Distributed under the MIT License (http://opensource.org/licenses/MIT).
 3 | 
 4 | from datetime import datetime, timedelta
 5 | 
 6 | from .. import github
 7 | from ..config import MIN_PR_AGE, switchable
 8 | from ..github import gh_call, gh_datetime
 9 | from ..queue import getLogger, task
10 | 
11 | _logger = getLogger(__name__)
12 | 
13 | 
14 | LABEL_READY_TO_MERGE = "ready to merge"
15 | READY_TO_MERGE_COMMENT = (
16 |     "This PR has the `approved` label and "
17 |     "has been created more than 5 days ago. "
18 |     "It should therefore be ready to merge by a maintainer "
19 |     "(or a PSC member if the concerned addon has "
20 |     "no declared maintainer). 🤖"
21 | )
22 | 
23 | 
24 | @task()
25 | @switchable()
26 | def tag_ready_to_merge(org, repo=None, dry_run=False):
27 |     """Add the ``ready to merge`` tag to all PRs where conditions are met."""
28 |     with github.login() as gh:
29 |         max_created = datetime.utcnow() - timedelta(days=MIN_PR_AGE)
30 |         query = [
31 |             "type:pr",
32 |             "state:open",
33 |             "status:success",
34 |             "label:approved",
35 |             '-label:"ready to merge"',
36 |             f"created:<{gh_datetime(max_created)}",
37 |         ]
38 |         if repo:
39 |             query.append(f"repo:{org}/{repo}")
40 |         else:
41 |             query.append(f"org:{org}")
42 |         for issue in gh.search_issues(" ".join(query)):
43 |             if dry_run:
44 |                 _logger.info(
45 |                     f"DRY-RUN add {LABEL_READY_TO_MERGE} "
46 |                     f"label to PR {issue.html_url}"
47 |                 )
48 |             else:
49 |                 _logger.info(f"add {LABEL_READY_TO_MERGE} label to PR {issue.html_url}")
50 |                 gh_issue = issue.issue
51 |                 gh_call(gh_issue.add_labels, LABEL_READY_TO_MERGE)
52 |                 gh_pr = gh_issue.pull_request()
53 |                 gh_call(gh_pr.create_comment, READY_TO_MERGE_COMMENT)
54 | 


--------------------------------------------------------------------------------
/src/oca_github_bot/utils.py:
--------------------------------------------------------------------------------
 1 | # Copyright (c) ACSONE SA/NV 2021
 2 | # Distributed under the MIT License (http://opensource.org/licenses/MIT).
 3 | 
 4 | import re
 5 | import shlex
 6 | import time
 7 | from typing import Sequence
 8 | 
 9 | from . import config
10 | 
11 | 
12 | def hide_secrets(s: str) -> str:
13 |     # TODO do we want to hide other secrets ?
14 |     return s.replace(config.GITHUB_TOKEN, "***")
15 | 
16 | 
17 | def retry_on_exception(
18 |     func, exception_regex: str, max_retries: int = 3, sleep_time: float = 5.0
19 | ):
20 |     """Retry a function call if it raises an exception matching a regex."""
21 |     counter = 0
22 |     while True:
23 |         try:
24 |             return func()
25 |         except Exception as e:
26 |             if not re.search(exception_regex, str(e)):
27 |                 raise
28 |             if counter >= max_retries:
29 |                 raise
30 |             counter += 1
31 |             time.sleep(sleep_time)
32 | 
33 | 
34 | def cmd_to_str(cmd: Sequence[str]) -> str:
35 |     return shlex.join(str(c) for c in cmd)
36 | 


--------------------------------------------------------------------------------
/src/oca_github_bot/version_branch.py:
--------------------------------------------------------------------------------
 1 | # Copyright (c) ACSONE SA/NV 2018-2019
 2 | # Distributed under the MIT License (http://opensource.org/licenses/MIT).
 3 | 
 4 | import re
 5 | 
 6 | from packaging import version
 7 | 
 8 | from . import config
 9 | 
10 | ODOO_VERSION_RE = re.compile(r"^(?P\d+)\.(?P\d+)$")
11 | MERGE_BOT_BRANCH_RE = re.compile(
12 |     r"(?P\S+)"
13 |     r"-ocabot-merge"
14 |     r"-pr-(?P\d+)"
15 |     r"-by-(?P\S+)"
16 |     r"-bump-(?P(no|patch|minor|major))"
17 | )
18 | 
19 | 
20 | def is_supported_main_branch(branch_name, min_version=None):
21 |     if not ODOO_VERSION_RE.match(branch_name):
22 |         return False
23 |     branch_version = version.parse(branch_name)
24 |     if min_version and branch_version < version.parse(min_version):
25 |         return False
26 |     return True
27 | 
28 | 
29 | def is_main_branch_bot_branch(branch_name):
30 |     return is_supported_main_branch(
31 |         branch_name, min_version=config.MAIN_BRANCH_BOT_MIN_VERSION
32 |     )
33 | 
34 | 
35 | def is_protected_branch(branch_name):
36 |     if branch_name == "master":
37 |         return True
38 |     return bool(ODOO_VERSION_RE.match(branch_name))
39 | 
40 | 
41 | def is_merge_bot_branch(branch):
42 |     return branch and bool(MERGE_BOT_BRANCH_RE.match(branch))
43 | 
44 | 
45 | def parse_merge_bot_branch(branch):
46 |     mo = MERGE_BOT_BRANCH_RE.match(branch)
47 |     bumpversion_mode = mo.group("bumpversion_mode")
48 |     if bumpversion_mode == "no":
49 |         bumpversion_mode = "nobump"
50 |     return (
51 |         mo.group("pr"),
52 |         mo.group("target_branch"),
53 |         mo.group("username"),
54 |         bumpversion_mode,
55 |     )
56 | 
57 | 
58 | def make_merge_bot_branch(pr, target_branch, username, bumpversion_mode):
59 |     if not bumpversion_mode:
60 |         bumpversion_mode = "no"
61 |     return f"{target_branch}-ocabot-merge-pr-{pr}-by-{username}-bump-{bumpversion_mode}"
62 | 
63 | 
64 | def search_merge_bot_branch(text):
65 |     mo = MERGE_BOT_BRANCH_RE.search(text)
66 |     if mo:
67 |         return mo.group(0)
68 | 


--------------------------------------------------------------------------------
/src/oca_github_bot/webhooks/__init__.py:
--------------------------------------------------------------------------------
 1 | # Copyright (c) ACSONE SA/NV 2018
 2 | # Distributed under the MIT License (http://opensource.org/licenses/MIT).
 3 | 
 4 | from . import (
 5 |     on_command,
 6 |     on_pr_close_delete_branch,
 7 |     on_pr_green_label_needs_review,
 8 |     on_pr_open_label_new_contributor,
 9 |     on_pr_open_mention_maintainer,
10 |     on_pr_review,
11 |     on_push_to_main_branch,
12 |     on_status_merge_bot,
13 | )
14 | 


--------------------------------------------------------------------------------
/src/oca_github_bot/webhooks/on_command.py:
--------------------------------------------------------------------------------
 1 | # Copyright (c) initOS GmbH 2019
 2 | # Distributed under the MIT License (http://opensource.org/licenses/MIT).
 3 | 
 4 | from ..commands import CommandError, parse_commands
 5 | from ..config import OCABOT_EXTRA_DOCUMENTATION, OCABOT_USAGE
 6 | from ..router import router
 7 | from ..tasks.add_pr_comment import add_pr_comment
 8 | 
 9 | 
10 | @router.register("issue_comment", action="created")
11 | async def on_command(event, gh, *args, **kwargs):
12 |     """On pull request review, tag if approved or ready to merge."""
13 |     if not event.data["issue"].get("pull_request"):
14 |         # ignore issue comments
15 |         return
16 |     org, repo = event.data["repository"]["full_name"].split("/")
17 |     pr = event.data["issue"]["number"]
18 |     username = event.data["comment"]["user"]["login"]
19 |     text = event.data["comment"]["body"]
20 |     await _on_command(org, repo, pr, username, text)
21 | 
22 | 
23 | async def _on_command(org, repo, pr, username, text):
24 |     try:
25 |         for command in parse_commands(text):
26 |             command.delay(org, repo, pr, username)
27 |     except CommandError as e:
28 |         # Add a comment on the current PR, if
29 |         # the command was misunderstood by the bot
30 |         add_pr_comment.delay(
31 |             org,
32 |             repo,
33 |             pr,
34 |             f"Hi @{username}. Your command failed:\n\n"
35 |             f"``{e}``.\n\n"
36 |             f"{OCABOT_USAGE}\n\n"
37 |             f"{OCABOT_EXTRA_DOCUMENTATION}",
38 |         )
39 | 


--------------------------------------------------------------------------------
/src/oca_github_bot/webhooks/on_pr_close_delete_branch.py:
--------------------------------------------------------------------------------
 1 | # Copyright (c) ACSONE SA/NV 2018
 2 | # Distributed under the MIT License (http://opensource.org/licenses/MIT).
 3 | 
 4 | import logging
 5 | 
 6 | from ..router import router
 7 | from ..tasks.delete_branch import delete_branch
 8 | from ..version_branch import is_protected_branch
 9 | 
10 | _logger = logging.getLogger(__name__)
11 | 
12 | 
13 | @router.register("pull_request", action="closed")
14 | async def on_pr_close_delete_branch(event, gh, *args, **kwargs):
15 |     """
16 |     Whenever a PR is closed, delete the branch
17 |     Only delete the branch if it's not from a forked repo
18 | 
19 |     This is mostly for demonstration purposes.
20 |     """
21 |     forked = event.data["pull_request"]["head"]["repo"]["fork"]
22 |     merged = event.data["pull_request"]["merged"]
23 |     branch = event.data["pull_request"]["head"]["ref"]
24 |     org, repo = event.data["repository"]["full_name"].split("/")
25 | 
26 |     if not forked and merged and not is_protected_branch(branch):
27 |         delete_branch.delay(org, repo, branch)
28 | 


--------------------------------------------------------------------------------
/src/oca_github_bot/webhooks/on_pr_green_label_needs_review.py:
--------------------------------------------------------------------------------
 1 | # Distributed under the MIT License (http://opensource.org/licenses/MIT).
 2 | 
 3 | from ..router import router
 4 | from ..tasks.tag_needs_review import tag_needs_review
 5 | 
 6 | 
 7 | @router.register("check_suite", action="completed")
 8 | async def on_pr_green_label_needs_review(event, gh, *args, **kwargs):
 9 |     """Add the `needs review` label to the pull requests after the successful
10 |     execution of the CI
11 |     """
12 |     status = event.data["check_suite"]["conclusion"]
13 |     org, repo = event.data["repository"]["full_name"].split("/")
14 |     for pr in event.data["check_suite"]["pull_requests"]:
15 |         tag_needs_review.delay(org, pr["number"], repo, status)
16 | 


--------------------------------------------------------------------------------
/src/oca_github_bot/webhooks/on_pr_open_label_new_contributor.py:
--------------------------------------------------------------------------------
 1 | # Copyright (c) ACSONE SA/NV 2018
 2 | # Distributed under the MIT License (http://opensource.org/licenses/MIT).
 3 | 
 4 | import logging
 5 | 
 6 | from ..router import router
 7 | 
 8 | _logger = logging.getLogger(__name__)
 9 | 
10 | 
11 | @router.register("pull_request", action="closed")
12 | async def on_pr_open_label_new_contributor(event, gh, *args, **kwargs):
13 |     """
14 |     Whenever a PR is opened, set label "new contributor"
15 |     if the author has less than 4 commits in OCA repositories.
16 |     """
17 |     # TODO
18 |     pass
19 | 


--------------------------------------------------------------------------------
/src/oca_github_bot/webhooks/on_pr_open_mention_maintainer.py:
--------------------------------------------------------------------------------
 1 | # Copyright 2019 Simone Rubino - Agile Business Group
 2 | # Distributed under the MIT License (http://opensource.org/licenses/MIT).
 3 | 
 4 | import logging
 5 | 
 6 | from ..router import router
 7 | from ..tasks.mention_maintainer import mention_maintainer
 8 | 
 9 | _logger = logging.getLogger(__name__)
10 | 
11 | 
12 | @router.register("pull_request", action="opened")
13 | @router.register("pull_request", action="reopened")
14 | async def on_pr_open_mention_maintainer(event, *args, **kwargs):
15 |     """
16 |     Whenever a PR is opened, mention the maintainers of modified addons.
17 |     """
18 |     org, repo = event.data["repository"]["full_name"].split("/")
19 |     pr = event.data["pull_request"]["number"]
20 |     mention_maintainer.delay(org, repo, pr)
21 | 


--------------------------------------------------------------------------------
/src/oca_github_bot/webhooks/on_pr_review.py:
--------------------------------------------------------------------------------
 1 | # Copyright (c) ACSONE SA/NV 2018
 2 | # Distributed under the MIT License (http://opensource.org/licenses/MIT).
 3 | 
 4 | from ..router import router
 5 | from ..tasks.tag_approved import tag_approved
 6 | from .on_command import _on_command
 7 | 
 8 | 
 9 | @router.register("pull_request_review")
10 | async def on_pr_review(event, gh, *args, **kwargs):
11 |     """On pull request review, tag if approved or ready to merge."""
12 |     org, repo = event.data["repository"]["full_name"].split("/")
13 |     pr = event.data["pull_request"]["number"]
14 |     username = event.data["review"]["user"]["login"]
15 |     text = event.data["review"]["body"]
16 |     tag_approved.delay(org, repo, pr)
17 |     await _on_command(org, repo, pr, username, text)
18 | 


--------------------------------------------------------------------------------
/src/oca_github_bot/webhooks/on_push_to_main_branch.py:
--------------------------------------------------------------------------------
 1 | # Copyright (c) ACSONE SA/NV 2018
 2 | # Distributed under the MIT License (http://opensource.org/licenses/MIT).
 3 | 
 4 | import logging
 5 | 
 6 | from ..router import router
 7 | from ..tasks.main_branch_bot import main_branch_bot
 8 | from ..version_branch import is_main_branch_bot_branch
 9 | 
10 | _logger = logging.getLogger(__name__)
11 | 
12 | 
13 | @router.register("push")
14 | async def on_push_to_main_branch(event, gh, *args, **kwargs):
15 |     """
16 |     On push to main branches, run the main branch bot task.
17 |     """
18 |     org, repo = event.data["repository"]["full_name"].split("/")
19 |     branch = event.data["ref"].split("/")[-1]
20 | 
21 |     if not is_main_branch_bot_branch(branch):
22 |         return
23 | 
24 |     main_branch_bot.delay(org, repo, branch, build_wheels=False)
25 | 


--------------------------------------------------------------------------------
/src/oca_github_bot/webhooks/on_status_merge_bot.py:
--------------------------------------------------------------------------------
 1 | # Copyright (c) initOS GmbH 2019
 2 | # Copyright (c) ACSONE SA/NV 2019
 3 | # Distributed under the MIT License (http://opensource.org/licenses/MIT).
 4 | 
 5 | from ..config import GITHUB_CHECK_SUITES_IGNORED, GITHUB_STATUS_IGNORED
 6 | from ..router import router
 7 | from ..tasks.merge_bot import merge_bot_status
 8 | from ..version_branch import is_merge_bot_branch, search_merge_bot_branch
 9 | 
10 | 
11 | @router.register("check_suite")
12 | async def on_check_suite_merge_bot(event, gh, *args, **kwargs):
13 |     org, repo = event.data["repository"]["full_name"].split("/")
14 |     branch_name = event.data["check_suite"]["head_branch"]
15 |     sha = event.data["check_suite"]["head_sha"]
16 |     status = event.data["check_suite"]["status"]
17 |     app = event.data["check_suite"]["app"]["name"]
18 | 
19 |     if app in GITHUB_CHECK_SUITES_IGNORED:
20 |         return
21 |     if status != "completed":
22 |         return
23 |     if not is_merge_bot_branch(branch_name):
24 |         return
25 | 
26 |     merge_bot_status.delay(org, repo, branch_name, sha)
27 | 
28 | 
29 | @router.register("check_run")
30 | async def on_check_run_merge_bot(event, gh, *args, **kwargs):
31 |     # This one is a hack to work around a strange behaviour of GitHub/Travis.
32 |     # Normally, we get a check_suite call when all checks are done.
33 |     # But when the merge bot branch is created on a commit that is
34 |     # already in the PR (i.e. when the rebase of the PR was a fast forward),
35 |     # we only get a check_run call, and no check_suite. Moreover we have
36 |     # no clean reference to the head branch in payload, so we attempt to
37 |     # extract it from the some text present in the payload.
38 |     org, repo = event.data["repository"]["full_name"].split("/")
39 |     branch_name = event.data["check_run"]["check_suite"]["head_branch"]
40 |     sha = event.data["check_run"]["check_suite"]["head_sha"]
41 |     status = event.data["check_run"]["status"]
42 |     app = event.data["check_run"]["check_suite"]["app"]["name"]
43 |     output = event.data["check_run"]["output"]["text"]
44 | 
45 |     if app in GITHUB_CHECK_SUITES_IGNORED:
46 |         return
47 |     if status != "completed":
48 |         return
49 |     if is_merge_bot_branch(branch_name):
50 |         # we should get the corresponding check suite call later
51 |         # so we do nothing with this check_run call
52 |         return
53 |     if not output:
54 |         return
55 |     branch_name = search_merge_bot_branch(output)
56 |     if not branch_name:
57 |         # this check_run is not for a merge bot branch
58 |         return
59 | 
60 |     merge_bot_status.delay(org, repo, branch_name, sha)
61 | 
62 | 
63 | @router.register("status")
64 | async def on_status_merge_bot(event, gh, *args, **kwargs):
65 |     org, repo = event.data["repository"]["full_name"].split("/")
66 |     sha = event.data["sha"]
67 |     state = event.data["state"]
68 |     branches = event.data.get("branches", [])
69 |     context = event.data["context"]
70 | 
71 |     if context in GITHUB_STATUS_IGNORED:
72 |         return
73 |     if state == "pending":
74 |         return
75 |     for branch in branches:
76 |         branch_name = branch["name"]
77 |         if is_merge_bot_branch(branch_name):
78 |             break
79 |     else:
80 |         return
81 | 
82 |     merge_bot_status.delay(org, repo, branch_name, sha)
83 | 


--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/OCA/oca-github-bot/d21feddfe3cf5698e5f322495d2483403e810faa/tests/__init__.py


--------------------------------------------------------------------------------
/tests/cassettes/test_exists_on_index[not_a_pkg-1.0-py3-none-any.whl-False].yaml:
--------------------------------------------------------------------------------
 1 | interactions:
 2 | - request:
 3 |     body: null
 4 |     headers:
 5 |       Accept:
 6 |       - '*/*'
 7 |       Accept-Encoding:
 8 |       - gzip, deflate
 9 |       Connection:
10 |       - keep-alive
11 |       User-Agent:
12 |       - python-requests/2.32.3
13 |     method: GET
14 |     uri: https://pypi.org/simple/not-a-pkg/
15 |   response:
16 |     body:
17 |       string: 404 Not Found
18 |     headers:
19 |       Accept-Ranges:
20 |       - bytes
21 |       Connection:
22 |       - keep-alive
23 |       Content-Length:
24 |       - '13'
25 |       Content-Security-Policy:
26 |       - default-src 'none'; sandbox allow-top-navigation
27 |       Content-Type:
28 |       - text/plain; charset=UTF-8
29 |       Date:
30 |       - Tue, 25 Jun 2024 21:52:36 GMT
31 |       Permissions-Policy:
32 |       - publickey-credentials-create=(self),publickey-credentials-get=(self),accelerometer=(),ambient-light-sensor=(),autoplay=(),battery=(),camera=(),display-capture=(),document-domain=(),encrypted-media=(),execution-while-not-rendered=(),execution-while-out-of-viewport=(),fullscreen=(),gamepad=(),geolocation=(),gyroscope=(),hid=(),identity-credentials-get=(),idle-detection=(),local-fonts=(),magnetometer=(),microphone=(),midi=(),otp-credentials=(),payment=(),picture-in-picture=(),screen-wake-lock=(),serial=(),speaker-selection=(),storage-access=(),usb=(),web-share=(),xr-spatial-tracking=()
33 |       Referrer-Policy:
34 |       - origin-when-cross-origin
35 |       Strict-Transport-Security:
36 |       - max-age=31536000; includeSubDomains; preload
37 |       Vary:
38 |       - Accept-Encoding
39 |       X-Cache:
40 |       - MISS, MISS
41 |       X-Cache-Hits:
42 |       - 0, 0
43 |       X-Content-Type-Options:
44 |       - nosniff
45 |       X-Frame-Options:
46 |       - deny
47 |       X-Permitted-Cross-Domain-Policies:
48 |       - none
49 |       X-Served-By:
50 |       - cache-iad-kiad7000068-IAD, cache-bru1480058-BRU
51 |       X-Timer:
52 |       - S1719352357.609813,VS0,VE113
53 |       X-XSS-Protection:
54 |       - 1; mode=block
55 |     status:
56 |       code: 404
57 |       message: Not Found
58 | version: 1
59 | 


--------------------------------------------------------------------------------
/tests/common.py:
--------------------------------------------------------------------------------
 1 | # Copyright (c) ACSONE SA/NV 2018
 2 | # Distributed under the MIT License (http://opensource.org/licenses/MIT).
 3 | 
 4 | import subprocess
 5 | from contextlib import contextmanager
 6 | 
 7 | from oca_github_bot import config
 8 | 
 9 | 
10 | class EventMock:
11 |     __slots__ = ["data"]
12 | 
13 |     def __init__(self, data):
14 |         self.data = data
15 | 
16 | 
17 | def make_addon(git_clone, addon_name, **manifest_keys):
18 |     addon_dir = git_clone / addon_name
19 |     addon_dir.mkdir()
20 |     manifest = dict(name=addon_name, **manifest_keys)
21 |     (addon_dir / "__manifest__.py").write_text(repr(manifest))
22 |     (addon_dir / "__init__.py").write_text("")
23 |     return str(addon_dir)
24 | 
25 | 
26 | def commit_addon(git_clone, addon_name):
27 |     addon_dir = git_clone / addon_name
28 |     subprocess.check_call(["git", "add", addon_dir], cwd=git_clone)
29 |     subprocess.check_call(
30 |         ["git", "commit", "-m", f"[BOT] add {addon_name}"], cwd=git_clone
31 |     )
32 | 
33 | 
34 | @contextmanager
35 | def set_config(**kwargs):
36 |     saved = {}
37 |     for key in kwargs:
38 |         saved[key] = getattr(config, key)
39 |         setattr(config, key, kwargs[key])
40 |     try:
41 |         yield
42 |     finally:
43 |         for key in saved:
44 |             setattr(config, key, kwargs[key])
45 | 


--------------------------------------------------------------------------------
/tests/conftest.py:
--------------------------------------------------------------------------------
 1 | # Copyright (c) ACSONE SA/NV 2018
 2 | # Distributed under the MIT License (http://opensource.org/licenses/MIT).
 3 | 
 4 | import subprocess
 5 | 
 6 | import github3
 7 | import pytest
 8 | 
 9 | from oca_github_bot import config
10 | 
11 | 
12 | @pytest.fixture
13 | def git_clone(tmp_path):
14 |     """
15 |     A pytest fixture that yields a tmp_path containing a git clone
16 |     of a dummy repository.
17 |     """
18 |     remote = tmp_path / "remote"
19 |     remote.mkdir()
20 |     subprocess.check_call(
21 |         ["git", "init", "--bare", "--initial-branch=master"], cwd=remote
22 |     )
23 |     clone = tmp_path / "clone"
24 |     subprocess.check_call(["git", "clone", str(remote), "clone"], cwd=tmp_path)
25 |     subprocess.check_call(["git", "config", "user.name", "test"], cwd=clone)
26 |     subprocess.check_call(["git", "config", "commit.gpgsign", "false"], cwd=clone)
27 |     subprocess.check_call(
28 |         ["git", "config", "user.email", "test@example.com"], cwd=clone
29 |     )
30 |     somefile = clone / "somefile"
31 |     with somefile.open("w"):
32 |         pass
33 |     subprocess.check_call(["git", "add", "somefile"], cwd=clone)
34 |     subprocess.check_call(["git", "commit", "-m", "[BOT] add somefile"], cwd=clone)
35 |     subprocess.check_call(["git", "push", "origin", "master"], cwd=clone)
36 |     yield clone
37 | 
38 | 
39 | @pytest.fixture(scope="module")
40 | def vcr_config():
41 |     return {
42 |         # Replace the Authorization request header with "DUMMY" in cassettes
43 |         "filter_headers": [("authorization", "DUMMY")]
44 |     }
45 | 
46 | 
47 | @pytest.fixture
48 | def gh():
49 |     """
50 |     github3 test fixture, using the configured GitHub token (if set, to
51 |     record vcr cassettes) or a DUMMY token (if not set, when playing back
52 |     cassettes).
53 |     """
54 |     return github3.login(token=(config.GITHUB_TOKEN or "DUMMY"))
55 | 


--------------------------------------------------------------------------------
/tests/test_build_wheels.py:
--------------------------------------------------------------------------------
  1 | # Copyright (c) ACSONE SA/NV 2019
  2 | # Distributed under the MIT License (http://opensource.org/licenses/MIT).
  3 | 
  4 | import os
  5 | import subprocess
  6 | import textwrap
  7 | 
  8 | import pytest
  9 | 
 10 | from oca_github_bot.build_wheels import (
 11 |     build_and_publish_metapackage_wheel,
 12 |     build_and_publish_wheels,
 13 | )
 14 | from oca_github_bot.pypi import RsyncDistPublisher
 15 | 
 16 | 
 17 | def _init_git_repo(cwd):
 18 |     subprocess.check_call(["git", "init"], cwd=cwd)
 19 |     subprocess.check_call(["git", "config", "user.name", "test"], cwd=cwd)
 20 |     subprocess.check_call(["git", "config", "commit.gpgsign", "false"], cwd=cwd)
 21 |     subprocess.check_call(["git", "config", "user.email", "test@example.com"], cwd=cwd)
 22 | 
 23 | 
 24 | def _make_addon(
 25 |     addons_dir, addon_name, series, metapackage=None, setup_py=True, pyproject=False
 26 | ):
 27 |     addon_dir = addons_dir / addon_name
 28 |     addon_dir.mkdir()
 29 |     manifest_path = addon_dir / "__manifest__.py"
 30 |     manifest_path.write_text(
 31 |         repr(
 32 |             {
 33 |                 "name": addon_name,
 34 |                 "version": series + ".1.0.0",
 35 |                 "description": "...",
 36 |             }
 37 |         )
 38 |     )
 39 |     (addon_dir / "__init__.py").write_text("")
 40 |     if pyproject:
 41 |         (addon_dir / "pyproject.toml").write_text(
 42 |             textwrap.dedent(
 43 |                 """\
 44 |                 [build-system]
 45 |                 requires = ["whool"]
 46 |                 build-backend = "whool.buildapi"
 47 |                 """
 48 |             )
 49 |         )
 50 |     subprocess.check_call(["git", "add", addon_name], cwd=addons_dir)
 51 |     subprocess.check_call(
 52 |         ["git", "commit", "-m", "[BOT] add " + addon_name], cwd=addons_dir
 53 |     )
 54 |     subprocess.check_call(["git", "clean", "-ffdx", "--", "setup"], cwd=addons_dir)
 55 |     if setup_py:
 56 |         cmd = ["setuptools-odoo-make-default", "-d", str(addons_dir), "--commit"]
 57 |         if metapackage:
 58 |             cmd.extend(["--metapackage", metapackage])
 59 |         subprocess.check_call(cmd)
 60 | 
 61 | 
 62 | @pytest.mark.parametrize("setup_py", [True, False])
 63 | def test_build_and_publish_wheels(setup_py, tmp_path):
 64 |     addons_dir = tmp_path / "addons_dir"
 65 |     addons_dir.mkdir()
 66 |     _init_git_repo(addons_dir)
 67 |     simple_index_root = tmp_path / "simple_index"
 68 |     simple_index_root.mkdir()
 69 |     dist_publisher = RsyncDistPublisher(simple_index_root)
 70 |     # build with no addons
 71 |     build_and_publish_wheels(addons_dir, dist_publisher, dry_run=False)
 72 |     assert not os.listdir(simple_index_root)
 73 |     # build with one addon
 74 |     _make_addon(addons_dir, "addon1", "12.0", setup_py=setup_py, pyproject=not setup_py)
 75 |     build_and_publish_wheels(str(addons_dir), dist_publisher, dry_run=False)
 76 |     wheel_dirs = os.listdir(simple_index_root)
 77 |     assert len(wheel_dirs) == 1
 78 |     assert wheel_dirs[0] == "odoo12-addon-addon1"
 79 |     wheels = os.listdir(simple_index_root / "odoo12-addon-addon1")
 80 |     assert len(wheels) == 1
 81 |     assert wheels[0].startswith("odoo12_addon_addon1")
 82 |     assert wheels[0].endswith(".whl")
 83 |     assert "-py3-" in wheels[0]
 84 |     # build with two addons, don't use pyproject.toml for this version
 85 |     _make_addon(addons_dir, "addon2", "10.0", setup_py=True, pyproject=False)
 86 |     build_and_publish_wheels(str(addons_dir), dist_publisher, dry_run=False)
 87 |     wheel_dirs = sorted(os.listdir(simple_index_root))
 88 |     assert len(wheel_dirs) == 2
 89 |     assert wheel_dirs[0] == "odoo10-addon-addon2"
 90 |     wheels = os.listdir(simple_index_root / "odoo10-addon-addon2")
 91 |     assert len(wheels) == 1
 92 |     assert wheels[0].startswith("odoo10_addon_addon2")
 93 |     assert wheels[0].endswith(".whl")
 94 |     assert "-py2-" in wheels[0]
 95 |     # test tag for Odoo 11, don't use pyproject.toml for this version
 96 |     _make_addon(addons_dir, "addon3", "11.0", setup_py=True, pyproject=False)
 97 |     build_and_publish_wheels(str(addons_dir), dist_publisher, dry_run=False)
 98 |     wheel_dirs = sorted(os.listdir(simple_index_root))
 99 |     assert len(wheel_dirs) == 3
100 |     assert wheel_dirs[1] == "odoo11-addon-addon3"
101 |     wheels = os.listdir(simple_index_root / "odoo11-addon-addon3")
102 |     assert len(wheels) == 1
103 |     assert "-py2.py3-" in wheels[0]
104 |     # test Odoo 15+ default addon naming scheme
105 |     _make_addon(addons_dir, "addon4", "15.0", setup_py=setup_py, pyproject=not setup_py)
106 |     build_and_publish_wheels(str(addons_dir), dist_publisher, dry_run=False)
107 |     wheel_dirs = sorted(os.listdir(simple_index_root))
108 |     assert len(wheel_dirs) == 4
109 |     assert wheel_dirs[0] == "odoo-addon-addon4"
110 |     wheels = os.listdir(simple_index_root / "odoo-addon-addon4")
111 |     assert len(wheels) == 1
112 |     assert wheels[0].startswith("odoo_addon_addon4")
113 |     assert wheels[0].endswith(".whl")
114 |     assert "-py3-" in wheels[0]
115 | 
116 | 
117 | def test_build_and_publish_metapackage(tmp_path):
118 |     addons_dir = tmp_path / "addons_dir"
119 |     addons_dir.mkdir()
120 |     _init_git_repo(addons_dir)
121 |     simple_index_root = tmp_path / "simple_index"
122 |     simple_index_root.mkdir()
123 |     dist_publisher = RsyncDistPublisher(simple_index_root)
124 |     # build with one addon
125 |     _make_addon(addons_dir, "addon1", "12.0", metapackage="test")
126 |     build_and_publish_metapackage_wheel(
127 |         str(addons_dir), dist_publisher, (12, 0), dry_run=False
128 |     )
129 |     wheels = os.listdir(simple_index_root / "odoo12-addons-test")
130 |     assert len(wheels) == 1
131 |     assert wheels[0].startswith("odoo12_addons_test")
132 |     assert wheels[0].endswith(".whl")
133 |     assert "-py3-" in wheels[0]
134 | 


--------------------------------------------------------------------------------
/tests/test_commands.py:
--------------------------------------------------------------------------------
  1 | # Copyright (c) ACSONE SA/NV 2019
  2 | # Distributed under the MIT License (http://opensource.org/licenses/MIT).
  3 | 
  4 | import pytest
  5 | 
  6 | from oca_github_bot.commands import (
  7 |     InvalidCommandError,
  8 |     InvalidOptionsError,
  9 |     OptionsError,
 10 |     RequiredOptionError,
 11 |     parse_commands,
 12 | )
 13 | 
 14 | 
 15 | def test_parse_command_not_a_command():
 16 |     with pytest.raises(InvalidCommandError):
 17 |         list(parse_commands("/ocabot not_a_command"))
 18 | 
 19 | 
 20 | def test_parse_command_multi():
 21 |     cmds = list(
 22 |         parse_commands(
 23 |             """
 24 |                 ...
 25 |                 /ocabot merge major
 26 |                 /ocabot   merge   patch
 27 |                 /ocabot merge patch
 28 |                 /ocabot merge nobump, please
 29 |                 /ocabot merge  minor, please
 30 |                 /ocabot merge minor, please
 31 |                 /ocabot merge nobump.
 32 |                 /ocabot merge patch. blah
 33 |                 /ocabot merge minor # ignored
 34 |                 /ocabot rebase, please
 35 |                 ...
 36 |             """
 37 |         )
 38 |     )
 39 |     assert [(cmd.name, cmd.options) for cmd in cmds] == [
 40 |         ("merge", ["major"]),
 41 |         ("merge", ["patch"]),
 42 |         ("merge", ["patch"]),
 43 |         ("merge", ["nobump"]),
 44 |         ("merge", ["minor"]),
 45 |         ("merge", ["minor"]),
 46 |         ("merge", ["nobump"]),
 47 |         ("merge", ["patch"]),
 48 |         ("merge", ["minor"]),
 49 |         ("rebase", []),
 50 |     ]
 51 | 
 52 | 
 53 | def test_parse_command_2():
 54 |     cmds = list(
 55 |         parse_commands(
 56 |             "Great contribution, thanks!\r\n\r\n"
 57 |             "/ocabot merge nobump\r\n\r\n"
 58 |             "Please forward port it to 12.0."
 59 |         )
 60 |     )
 61 |     assert [(cmd.name, cmd.options) for cmd in cmds] == [("merge", ["nobump"])]
 62 | 
 63 | 
 64 | def test_parse_command_merge():
 65 |     cmds = list(parse_commands("/ocabot merge major"))
 66 |     assert len(cmds) == 1
 67 |     assert cmds[0].name == "merge"
 68 |     assert cmds[0].bumpversion_mode == "major"
 69 |     cmds = list(parse_commands("/ocabot merge minor"))
 70 |     assert len(cmds) == 1
 71 |     assert cmds[0].name == "merge"
 72 |     assert cmds[0].bumpversion_mode == "minor"
 73 |     cmds = list(parse_commands("/ocabot merge patch"))
 74 |     assert len(cmds) == 1
 75 |     assert cmds[0].name == "merge"
 76 |     assert cmds[0].bumpversion_mode == "patch"
 77 |     cmds = list(parse_commands("/ocabot merge nobump"))
 78 |     assert len(cmds) == 1
 79 |     assert cmds[0].name == "merge"
 80 |     assert cmds[0].bumpversion_mode == "nobump"
 81 |     with pytest.raises(RequiredOptionError):
 82 |         list(parse_commands("/ocabot merge"))
 83 |     with pytest.raises(InvalidOptionsError):
 84 |         list(parse_commands("/ocabot merge nobump brol"))
 85 |     with pytest.raises(OptionsError):
 86 |         list(parse_commands("/ocabot merge brol"))
 87 | 
 88 | 
 89 | def test_parse_command_rebase():
 90 |     cmds = list(parse_commands("/ocabot rebase"))
 91 |     assert len(cmds) == 1
 92 |     assert cmds[0].name == "rebase"
 93 |     with pytest.raises(InvalidOptionsError):
 94 |         list(parse_commands("/ocabot rebase brol"))
 95 | 
 96 | 
 97 | def test_parse_command_comment():
 98 |     body = """
 99 | > {merge_command}
100 | > Some comment {merge_command}
101 | >> Double comment! {merge_command}
102 | This is the one {merge_command} patch
103 |     """.format(
104 |         merge_command="/ocabot merge"
105 |     )
106 |     command = list(parse_commands(body))
107 |     assert len(command) == 1
108 |     command = command[0]
109 |     assert command.name == "merge"
110 |     assert command.bumpversion_mode == "patch"
111 | 


--------------------------------------------------------------------------------
/tests/test_git.py:
--------------------------------------------------------------------------------
 1 | # Copyright (c) ACSONE SA/NV 2018
 2 | # Distributed under the MIT License (http://opensource.org/licenses/MIT).
 3 | 
 4 | import re
 5 | import subprocess
 6 | from pathlib import Path
 7 | 
 8 | from oca_github_bot.github import (
 9 |     git_commit_if_needed,
10 |     git_get_current_branch,
11 |     git_get_head_sha,
12 | )
13 | 
14 | 
15 | def test_git_get_head_sha(git_clone):
16 |     sha = git_get_head_sha(git_clone)
17 |     assert re.match("^[a-f0-9]{40}$", sha)
18 | 
19 | 
20 | def test_git_get_current_branch(git_clone):
21 |     assert git_get_current_branch(git_clone) == "master"
22 |     subprocess.check_call(["git", "checkout", "-b", "abranch"], cwd=git_clone)
23 |     assert git_get_current_branch(git_clone) == "abranch"
24 | 
25 | 
26 | def test_git_commit_if_needed_no_change(tmp_path: Path) -> None:
27 |     subprocess.check_call(["git", "init"], cwd=tmp_path)
28 |     subprocess.check_call(
29 |         ["git", "config", "user.email", "test@example.com"], cwd=tmp_path
30 |     )
31 |     subprocess.check_call(["git", "config", "user.name", "test"], cwd=tmp_path)
32 |     toto = tmp_path / "toto"
33 |     toto.touch()
34 |     git_commit_if_needed("toto", "initial commit", cwd=tmp_path)
35 |     head_sha = git_get_head_sha(tmp_path)
36 |     # no change
37 |     git_commit_if_needed("toto", "no commit", cwd=tmp_path)
38 |     assert git_get_head_sha(tmp_path) == head_sha
39 |     # change in existing file
40 |     toto.write_text("toto")
41 |     git_commit_if_needed("toto", "toto changed", cwd=tmp_path)
42 |     head_sha2 = git_get_head_sha(tmp_path)
43 |     assert head_sha2 != head_sha
44 |     # add subdirectory
45 |     subdir = tmp_path / "subdir"
46 |     subdir.mkdir()
47 |     titi = subdir / "titi"
48 |     titi.touch()
49 |     git_commit_if_needed("subdir", "titi added", cwd=tmp_path)
50 |     head_sha3 = git_get_head_sha(tmp_path)
51 |     assert head_sha3 != head_sha2
52 |     # add glob
53 |     subdir.joinpath("pyproject.toml").touch()
54 |     git_commit_if_needed("*/pyproject.toml", "pyproject.toml added", cwd=tmp_path)
55 |     head_sha4 = git_get_head_sha(tmp_path)
56 |     assert head_sha4 != head_sha3
57 | 


--------------------------------------------------------------------------------
/tests/test_git_push_if_needed.py:
--------------------------------------------------------------------------------
 1 | # Copyright (c) ACSONE SA/NV 2018
 2 | # Distributed under the MIT License (http://opensource.org/licenses/MIT).
 3 | 
 4 | import subprocess
 5 | 
 6 | import pytest
 7 | from celery.exceptions import Retry
 8 | 
 9 | from oca_github_bot.github import git_push_if_needed
10 | 
11 | 
12 | def test_git_push_if_needed(git_clone):
13 |     assert not git_push_if_needed("origin", "master", cwd=git_clone)
14 |     afile = git_clone / "afile"
15 |     with afile.open("w"):
16 |         pass
17 |     subprocess.check_call(["git", "add", "afile"], cwd=git_clone)
18 |     subprocess.check_call(["git", "commit", "-m", "[BOT] add afile"], cwd=git_clone)
19 |     assert git_push_if_needed("origin", "master", cwd=git_clone)
20 |     assert not git_push_if_needed("origin", "master", cwd=git_clone)
21 |     subprocess.check_call(["git", "reset", "--hard", "HEAD^"], cwd=git_clone)
22 |     with pytest.raises(Retry):
23 |         git_push_if_needed("origin", "master", cwd=git_clone)
24 | 


--------------------------------------------------------------------------------
/tests/test_github_failure.py:
--------------------------------------------------------------------------------
 1 | import celery
 2 | import pytest
 3 | import requests
 4 | from github3.exceptions import ForbiddenError
 5 | 
 6 | from oca_github_bot.github import gh_call
 7 | 
 8 | 
 9 | def _fail_just_like_that():
10 |     response = requests.Response()
11 |     raise ForbiddenError(response)
12 | 
13 | 
14 | def _fail_ratelimit():
15 |     response = requests.Response()
16 |     response.headers.update({"X-RateLimit-Remaining": 0, "X-RateLimit-Reset": -1})
17 |     raise ForbiddenError(response)
18 | 
19 | 
20 | def test_github_failure(mocker):
21 |     with pytest.raises(celery.exceptions.Retry):
22 |         # this will restart the task
23 |         gh_call(_fail_ratelimit)
24 |     with pytest.raises(ForbiddenError):
25 |         # an unhandled error
26 |         gh_call(_fail_just_like_that)
27 | 


--------------------------------------------------------------------------------
/tests/test_manifest.py:
--------------------------------------------------------------------------------
  1 | # Copyright (c) ACSONE SA/NV 2018
  2 | # Distributed under the MIT License (http://opensource.org/licenses/MIT).
  3 | 
  4 | import subprocess
  5 | 
  6 | import pytest
  7 | 
  8 | from oca_github_bot.github import git_get_head_sha
  9 | from oca_github_bot.manifest import (
 10 |     NoManifestFound,
 11 |     OdooSeriesNotDetected,
 12 |     bump_manifest_version,
 13 |     bump_version,
 14 |     get_manifest,
 15 |     get_manifest_path,
 16 |     get_odoo_series_from_branch,
 17 |     get_odoo_series_from_version,
 18 |     git_modified_addon_dirs,
 19 |     git_modified_addons,
 20 |     is_addon_dir,
 21 |     is_addons_dir,
 22 |     is_maintainer,
 23 |     is_maintainer_other_branches,
 24 |     set_manifest_version,
 25 | )
 26 | 
 27 | 
 28 | def test_is_addons_dir_empty(tmpdir):
 29 |     tmpdir.mkdir("addon")
 30 |     assert not is_addons_dir(str(tmpdir))
 31 | 
 32 | 
 33 | def test_is_addons_dir_one_addon(tmpdir):
 34 |     p = tmpdir.mkdir("addon").join("__manifest__.py")
 35 |     p.write("{'name': 'addon'}")
 36 |     assert is_addons_dir(str(tmpdir))
 37 | 
 38 | 
 39 | def test_is_addon_dir(tmp_path):
 40 |     assert not is_addon_dir(tmp_path)
 41 |     m = tmp_path / "__manifest__.py"
 42 |     m.write_text("{'name': 'addon'}")
 43 |     assert is_addon_dir(tmp_path)
 44 |     m.write_text("{'name': 'addon', 'installable': False}")
 45 |     assert is_addon_dir(tmp_path)
 46 |     m.write_text("{'name': 'addon', 'installable': False}")
 47 |     assert not is_addon_dir(tmp_path, installable_only=True)
 48 |     m.write_text("{'name': 'addon', 'installable': True}")
 49 |     assert is_addon_dir(tmp_path, installable_only=True)
 50 | 
 51 | 
 52 | def test_is_addons_dir(tmp_path):
 53 |     addon1 = tmp_path / "addon1"
 54 |     addon1.mkdir()
 55 |     assert not is_addons_dir(tmp_path)
 56 |     m = addon1 / "__manifest__.py"
 57 |     m.write_text("{'name': 'addon'}")
 58 |     assert is_addons_dir(tmp_path)
 59 |     m.write_text("{'name': 'addon', 'installable': False}")
 60 |     assert is_addons_dir(tmp_path)
 61 |     m.write_text("{'name': 'addon', 'installable': False}")
 62 |     assert not is_addons_dir(tmp_path, installable_only=True)
 63 |     m.write_text("{'name': 'addon', 'installable': True}")
 64 |     assert is_addons_dir(tmp_path, installable_only=True)
 65 | 
 66 | 
 67 | def test_get_manifest_path(tmp_path):
 68 |     addon_dir = tmp_path / "addon"
 69 |     addon_dir.mkdir()
 70 |     assert not get_manifest_path(addon_dir)
 71 |     with pytest.raises(NoManifestFound):
 72 |         get_manifest(addon_dir)
 73 |     manifest_path = addon_dir / "__manifest__.py"
 74 |     with manifest_path.open("w") as f:
 75 |         f.write("{'name': 'the addon'}")
 76 |     assert get_manifest_path(addon_dir) == str(manifest_path)
 77 |     assert get_manifest(addon_dir)["name"] == "the addon"
 78 | 
 79 | 
 80 | def test_set_addon_version(tmp_path):
 81 |     addon_dir = tmp_path / "addon"
 82 |     addon_dir.mkdir()
 83 |     manifest_path = addon_dir / "__manifest__.py"
 84 |     with manifest_path.open("w") as f:
 85 |         f.write("{'name': 'thé addon', 'version': '1.0.0'}")
 86 |     set_manifest_version(addon_dir, "2.0.1")
 87 |     m = get_manifest(addon_dir)
 88 |     assert m["version"] == "2.0.1"
 89 |     assert m["name"] == "thé addon"
 90 | 
 91 | 
 92 | def test_bump_version():
 93 |     assert bump_version("12.0.1.0.0", "major") == "12.0.2.0.0"
 94 |     assert bump_version("12.0.1.1.1", "major") == "12.0.2.0.0"
 95 |     assert bump_version("12.0.1.0.0", "minor") == "12.0.1.1.0"
 96 |     assert bump_version("12.0.1.0.1", "minor") == "12.0.1.1.0"
 97 |     assert bump_version("12.0.1.0.0", "patch") == "12.0.1.0.1"
 98 |     with pytest.raises(RuntimeError):
 99 |         bump_version("1.0", "major")
100 |     with pytest.raises(RuntimeError):
101 |         bump_version("1.0.1", "major")
102 |     with pytest.raises(RuntimeError):
103 |         bump_version("12.0.1.0.0", "none")
104 | 
105 | 
106 | def test_bump_manifest_version(tmp_path):
107 |     addon_dir = tmp_path / "addon"
108 |     addon_dir.mkdir()
109 |     manifest_path = addon_dir / "__manifest__.py"
110 |     with manifest_path.open("w") as f:
111 |         f.write("{'name': 'the addon', 'version': '12.0.1.0.0'}")
112 |     bump_manifest_version(addon_dir, "minor")
113 |     m = get_manifest(addon_dir)
114 |     assert m["version"] == "12.0.1.1.0"
115 | 
116 | 
117 | def test_git_modified_addons(git_clone):
118 |     # create an addon, commit it, and check it is modified
119 |     addon_dir = git_clone / "addon"
120 |     addon_dir.mkdir()
121 |     manifest_path = addon_dir / "__manifest__.py"
122 |     manifest_path.write_text("{'name': 'the addon'}")
123 |     subprocess.check_call(["git", "add", "."], cwd=git_clone)
124 |     subprocess.check_call(["git", "commit", "-m", "[BOT] add addon"], cwd=git_clone)
125 |     assert git_modified_addons(git_clone, "origin/master") == ({"addon"}, False)
126 |     # push and check addon is not modified
127 |     subprocess.check_call(["git", "push", "origin", "master"], cwd=git_clone)
128 |     assert git_modified_addons(git_clone, "origin/master") == (set(), False)
129 |     # same test with the setup dir
130 |     setup_dir = git_clone / "setup" / "addon"
131 |     setup_dir.mkdir(parents=True)
132 |     (setup_dir / "setup.py").write_text("")
133 |     subprocess.check_call(["git", "add", "setup"], cwd=git_clone)
134 |     subprocess.check_call(
135 |         ["git", "commit", "-m", "[BOT] add addon setup"], cwd=git_clone
136 |     )
137 |     assert git_modified_addons(git_clone, "origin/master") == (set(), True)
138 |     (setup_dir / "odoo" / "addons").mkdir(parents=True)
139 |     (setup_dir / "odoo" / "addons" / "addon").symlink_to("../../../../addon")
140 |     subprocess.check_call(["git", "add", "setup"], cwd=git_clone)
141 |     subprocess.check_call(
142 |         ["git", "commit", "-m", "[BOT] add addon setup"], cwd=git_clone
143 |     )
144 |     assert git_modified_addons(git_clone, "origin/master") == ({"addon"}, False)
145 |     assert git_modified_addon_dirs(git_clone, "origin/master") == (
146 |         [str(git_clone / "addon")],
147 |         False,
148 |         {"addon"},
149 |     )
150 |     # add a second addon, and change the first one
151 |     addon2_dir = git_clone / "addon2"
152 |     addon2_dir.mkdir()
153 |     manifest2_path = addon2_dir / "__manifest__.py"
154 |     manifest2_path.write_text("{'name': 'the 2nd addon'}")
155 |     (addon_dir / "__init__.py").write_text("")
156 |     (git_clone / "README").write_text("")
157 |     subprocess.check_call(["git", "add", "."], cwd=git_clone)
158 |     subprocess.check_call(["git", "commit", "-m", "[BOT] add addon2"], cwd=git_clone)
159 |     assert git_modified_addons(git_clone, "origin/master") == (
160 |         {"addon", "addon2"},
161 |         True,  # because of README at repo root
162 |     )
163 |     # remove the first and test it does not appear in result
164 |     subprocess.check_call(["git", "tag", "beforerm"], cwd=git_clone)
165 |     subprocess.check_call(["git", "rm", "-r", "addon"], cwd=git_clone)
166 |     subprocess.check_call(["git", "commit", "-m", "[BOT] rm addon"], cwd=git_clone)
167 |     assert git_modified_addons(git_clone, "beforerm") == (set(), True)
168 | 
169 | 
170 | def test_git_modified_addons_merge_base(git_clone):
171 |     # create addon2 on master
172 |     addon2_dir = git_clone / "addon2"
173 |     addon2_dir.mkdir()
174 |     (addon2_dir / "__manifest__.py").write_text("{'name': 'addon2'}")
175 |     subprocess.check_call(["git", "add", "addon2"], cwd=git_clone)
176 |     subprocess.check_call(["git", "commit", "-m", "[BOT] add addon2"], cwd=git_clone)
177 |     assert git_modified_addons(git_clone, "origin/master") == ({"addon2"}, False)
178 |     # create addon1 on a new branch
179 |     subprocess.check_call(["git", "checkout", "-b" "addon1"], cwd=git_clone)
180 |     addon1_dir = git_clone / "addon1"
181 |     addon1_dir.mkdir()
182 |     (addon1_dir / "__manifest__.py").write_text("{'name': 'addon1'}")
183 |     subprocess.check_call(["git", "add", "addon1"], cwd=git_clone)
184 |     subprocess.check_call(["git", "commit", "-m", "[BOT] add addon1"], cwd=git_clone)
185 |     assert git_modified_addons(git_clone, "master") == ({"addon1"}, False)
186 |     # modify addon2 on master
187 |     subprocess.check_call(["git", "checkout", "master"], cwd=git_clone)
188 |     (addon2_dir / "__manifest__.py").write_text("{'name': 'modified addon2'}")
189 |     subprocess.check_call(["git", "add", "addon2"], cwd=git_clone)
190 |     subprocess.check_call(["git", "commit", "-m", "[BOT] upd addon2"], cwd=git_clone)
191 |     # check comparison of addon1 to master only gives addon1
192 |     subprocess.check_call(["git", "checkout", "addon1"], cwd=git_clone)
193 |     assert git_modified_addons(git_clone, "master") == ({"addon1"}, False)
194 |     # add same commit in master and addon1
195 |     subprocess.check_call(["git", "checkout", "master"], cwd=git_clone)
196 |     addon3_dir = git_clone / "addon3"
197 |     addon3_dir.mkdir()
198 |     (addon3_dir / "__manifest__.py").write_text("{'name': 'addon3'}")
199 |     subprocess.check_call(["git", "add", "addon3"], cwd=git_clone)
200 |     subprocess.check_call(["git", "commit", "-m", "[BOT] add addon3"], cwd=git_clone)
201 |     assert git_modified_addons(git_clone, "HEAD^") == ({"addon3"}, False)
202 |     commit = git_get_head_sha(cwd=git_clone)
203 |     subprocess.check_call(["git", "checkout", "addon1"], cwd=git_clone)
204 |     subprocess.check_call(["git", "cherry-pick", commit], cwd=git_clone)
205 |     assert git_modified_addons(git_clone, "master") == ({"addon1"}, False)
206 | 
207 | 
208 | def test_get_odoo_series_from_branch():
209 |     assert get_odoo_series_from_branch("12.0") == (12, 0)
210 |     with pytest.raises(OdooSeriesNotDetected):
211 |         get_odoo_series_from_branch("12.0.0")
212 | 
213 | 
214 | def test_get_odoo_series_from_version():
215 |     assert get_odoo_series_from_version("12.0.1.0.0") == (12, 0)
216 |     assert get_odoo_series_from_version("6.1.1.0.0") == (6, 1)
217 |     with pytest.raises(OdooSeriesNotDetected):
218 |         get_odoo_series_from_version("1.0.0")
219 |     with pytest.raises(OdooSeriesNotDetected):
220 |         get_odoo_series_from_version("1.0")
221 |     with pytest.raises(OdooSeriesNotDetected):
222 |         get_odoo_series_from_version("12.0.1")
223 | 
224 | 
225 | def test_is_maintainer(tmp_path):
226 |     addon1 = tmp_path / "addon1"
227 |     addon1.mkdir()
228 |     (addon1 / "__manifest__.py").write_text(
229 |         "{'name': 'addon1', 'maintainers': ['u1', 'u2']}"
230 |     )
231 |     addon2 = tmp_path / "addon2"
232 |     addon2.mkdir()
233 |     (addon2 / "__manifest__.py").write_text("{'name': 'addon2', 'maintainers': ['u2']}")
234 |     addon3 = tmp_path / "addon3"
235 |     addon3.mkdir()
236 |     (addon3 / "__manifest__.py").write_text("{'name': 'addon3'}")
237 |     assert is_maintainer("u1", [addon1])
238 |     assert not is_maintainer("u1", [addon2])
239 |     assert not is_maintainer("u1", [addon1, addon2])
240 |     assert is_maintainer("u2", [addon1, addon2])
241 |     assert not is_maintainer("u2", [addon1, addon2, addon3])
242 |     assert not is_maintainer("u1", [tmp_path / "not_an_addon"])
243 | 
244 | 
245 | def test_is_maintainer_other_branches():
246 |     assert is_maintainer_other_branches(
247 |         "OCA", "mis-builder", "sbidoul", {"mis_builder"}, ["12.0"]
248 |     )
249 |     assert not is_maintainer_other_branches(
250 |         "OCA", "mis-builder", "fpdoo", {"mis_builder"}, ["12.0"]
251 |     )
252 | 


--------------------------------------------------------------------------------
/tests/test_mention_maintainer.py:
--------------------------------------------------------------------------------
  1 | # Copyright 2019 Simone Rubino - Agile Business Group
  2 | # Distributed under the MIT License (http://opensource.org/licenses/MIT).
  3 | import shutil
  4 | 
  5 | import pytest
  6 | 
  7 | from oca_github_bot.tasks.mention_maintainer import mention_maintainer
  8 | 
  9 | from .common import make_addon, set_config
 10 | 
 11 | 
 12 | @pytest.mark.vcr()
 13 | def test_maintainer_mentioned(git_clone, mocker):
 14 |     github_mock = mocker.patch("oca_github_bot.tasks.mention_maintainer.github")
 15 |     github_mock.temporary_clone.return_value.__enter__.return_value = str(git_clone)
 16 | 
 17 |     addon_name = "addon1"
 18 |     addon_dir = make_addon(git_clone, addon_name, maintainers=["themaintainer"])
 19 | 
 20 |     modified_addons_mock = mocker.patch(
 21 |         "oca_github_bot.tasks.mention_maintainer.git_modified_addon_dirs"
 22 |     )
 23 |     modified_addons_mock.return_value = [addon_dir], False, {addon_name}
 24 |     mocker.patch("oca_github_bot.tasks.mention_maintainer.check_call")
 25 |     mention_maintainer("org", "repo", "pr")
 26 | 
 27 |     github_mock.gh_call.assert_called_once()
 28 |     assert "@themaintainer" in github_mock.gh_call.mock_calls[0][1][1]
 29 | 
 30 | 
 31 | @pytest.mark.vcr()
 32 | def test_added_maintainer_not_mentioned(git_clone, mocker):
 33 |     """Only maintainers existing before the PR will be mentioned."""
 34 |     github_mock = mocker.patch("oca_github_bot.tasks.mention_maintainer.github")
 35 |     github_mock.temporary_clone.return_value.__enter__.return_value = str(git_clone)
 36 | 
 37 |     addon_name = "addon1"
 38 |     pre_pr_addon = make_addon(git_clone, addon_name, maintainers=["themaintainer"])
 39 |     pre_pr_addon_mock = mocker.patch(
 40 |         "oca_github_bot.tasks.mention_maintainer.addon_dirs_in"
 41 |     )
 42 | 
 43 |     def pr_edited_addon(_args, **_kwargs):
 44 |         shutil.rmtree(pre_pr_addon)
 45 |         edited_addon = make_addon(
 46 |             git_clone, addon_name, maintainers=["themaintainer", "added_maintainer"]
 47 |         )
 48 |         return [str(edited_addon)]
 49 | 
 50 |     pre_pr_addon_mock.side_effect = pr_edited_addon
 51 |     pre_pr_addon_mock.return_value = [pre_pr_addon], False
 52 | 
 53 |     modified_addons_mock = mocker.patch(
 54 |         "oca_github_bot.tasks.mention_maintainer.git_modified_addon_dirs"
 55 |     )
 56 |     modified_addons_mock.return_value = [pre_pr_addon], False, {addon_name}
 57 | 
 58 |     mocker.patch("oca_github_bot.tasks.mention_maintainer.check_call")
 59 | 
 60 |     mention_maintainer("org", "repo", "pr")
 61 | 
 62 |     github_mock.gh_call.assert_called_once()
 63 |     assert "@themaintainer" in github_mock.gh_call.mock_calls[0][1][1]
 64 |     assert "@added_maintainer" in github_mock.gh_call.mock_calls[0][1][1]
 65 | 
 66 | 
 67 | @pytest.mark.vcr()
 68 | def test_multi_maintainer_one_mention(git_clone, mocker):
 69 |     github_mock = mocker.patch("oca_github_bot.tasks.mention_maintainer.github")
 70 |     github_mock.temporary_clone.return_value.__enter__.return_value = str(git_clone)
 71 | 
 72 |     addon_dirs = list()
 73 |     addon_names = ["addon1", "addon2"]
 74 |     themaintainer = "themaintainer"
 75 |     for addon_name in addon_names:
 76 |         addon_dir = make_addon(git_clone, addon_name, maintainers=[themaintainer])
 77 |         addon_dirs.append(addon_dir)
 78 | 
 79 |     modified_addons_mock = mocker.patch(
 80 |         "oca_github_bot.tasks.mention_maintainer.git_modified_addon_dirs"
 81 |     )
 82 |     modified_addons_mock.return_value = addon_dirs, False, set(addon_names)
 83 |     mocker.patch("oca_github_bot.tasks.mention_maintainer.check_call")
 84 |     mention_maintainer("org", "repo", "pr")
 85 | 
 86 |     github_mock.gh_call.assert_called_once()
 87 |     comment = github_mock.gh_call.mock_calls[0][1][1]
 88 |     assert comment.count(themaintainer) == 1
 89 | 
 90 | 
 91 | @pytest.mark.vcr()
 92 | def test_pr_by_maintainer_no_mention(git_clone, mocker):
 93 |     themaintainer = "themaintainer"
 94 |     github_mock = mocker.patch("oca_github_bot.tasks.mention_maintainer.github")
 95 |     github_mock.temporary_clone.return_value.__enter__.return_value = str(git_clone)
 96 |     pr_mock = github_mock.login.return_value.__enter__.return_value.pull_request
 97 |     pr_mock.return_value.user.login = themaintainer
 98 | 
 99 |     addon_dirs = list()
100 |     addon_names = ["addon1", "addon2"]
101 |     for addon_name in addon_names:
102 |         addon_dir = make_addon(git_clone, addon_name, maintainers=[themaintainer])
103 |         addon_dirs.append(addon_dir)
104 | 
105 |     modified_addons_mock = mocker.patch(
106 |         "oca_github_bot.tasks.mention_maintainer.git_modified_addon_dirs"
107 |     )
108 |     modified_addons_mock.return_value = addon_dirs, False, set(addon_names)
109 |     mocker.patch("oca_github_bot.tasks.mention_maintainer.check_call")
110 |     mention_maintainer("org", "repo", "pr")
111 | 
112 |     github_mock.gh_call.assert_not_called()
113 | 
114 | 
115 | @pytest.mark.vcr()
116 | def test_no_maintainer_adopt_module(git_clone, mocker):
117 |     github_mock = mocker.patch("oca_github_bot.tasks.mention_maintainer.github")
118 |     github_mock.temporary_clone.return_value.__enter__.return_value = str(git_clone)
119 | 
120 |     addon_name = "addon1"
121 |     addon_dir = make_addon(git_clone, addon_name)
122 | 
123 |     modified_addons_mock = mocker.patch(
124 |         "oca_github_bot.tasks.mention_maintainer.git_modified_addon_dirs"
125 |     )
126 |     modified_addons_mock.return_value = [addon_dir], False, {addon_name}
127 |     mocker.patch("oca_github_bot.tasks.mention_maintainer.check_call")
128 | 
129 |     with set_config(ADOPT_AN_ADDON_MENTION="Hi {pr_opener}, would you like to adopt?"):
130 |         mention_maintainer("org", "repo", "pr")
131 | 
132 |     github_mock.gh_call.assert_called_once()
133 | 


--------------------------------------------------------------------------------
/tests/test_merge_bot.py:
--------------------------------------------------------------------------------
 1 | # Copyright (c) ACSONE SA/NV 2018
 2 | # Distributed under the MIT License (http://opensource.org/licenses/MIT).
 3 | 
 4 | import subprocess
 5 | 
 6 | import pytest
 7 | 
 8 | from oca_github_bot.manifest import user_can_push
 9 | 
10 | from .common import commit_addon, make_addon
11 | 
12 | 
13 | @pytest.mark.vcr()
14 | def test_user_can_merge_team_member(git_clone, gh):
15 |     assert user_can_push(gh, "OCA", "mis-builder", "sbidoul", git_clone, "master")
16 | 
17 | 
18 | @pytest.mark.vcr()
19 | def test_user_can_merge_maintainer(git_clone, gh):
20 |     make_addon(git_clone, "addon1", maintainers=["themaintainer"])
21 |     commit_addon(git_clone, "addon1")
22 |     subprocess.check_call(["git", "checkout", "-b", "thebranch"], cwd=git_clone)
23 |     (git_clone / "addon1" / "data").write_text("")
24 |     commit_addon(git_clone, "addon1")
25 |     assert user_can_push(gh, "OCA", "mis-builder", "themaintainer", git_clone, "master")
26 | 
27 | 
28 | @pytest.mark.vcr()
29 | def test_user_can_merge_not_maintainer(git_clone, gh):
30 |     make_addon(git_clone, "addon1")
31 |     commit_addon(git_clone, "addon1")
32 |     subprocess.check_call(["git", "checkout", "-b", "thebranch"], cwd=git_clone)
33 |     (git_clone / "addon1" / "data").write_text("")
34 |     commit_addon(git_clone, "addon1")
35 |     assert not user_can_push(
36 |         gh, "OCA", "mis-builder", "themaintainer", git_clone, "master"
37 |     )
38 | 
39 | 
40 | @pytest.mark.vcr()
41 | def test_user_can_merge_not_maintainer_hacker(git_clone, gh):
42 |     make_addon(git_clone, "addon1")
43 |     commit_addon(git_clone, "addon1")
44 |     subprocess.check_call(["git", "checkout", "-b", "thebranch"], cwd=git_clone)
45 |     # themaintainer attempts to add himself as maintainer
46 |     (git_clone / "addon1" / "__manifest__.py").write_text(
47 |         "{'name': 'addon1', 'maintainers': ['themaintainer']}"
48 |     )
49 |     commit_addon(git_clone, "addon1")
50 |     assert not user_can_push(
51 |         gh, "OCA", "mis-builder", "themaintainer", git_clone, "master"
52 |     )
53 | 


--------------------------------------------------------------------------------
/tests/test_migration_issue_bot.py:
--------------------------------------------------------------------------------
 1 | # Copyright 2021 Tecnativa - Víctor Martínez
 2 | # Distributed under the MIT License (http://opensource.org/licenses/MIT).
 3 | 
 4 | import pytest
 5 | 
 6 | from oca_github_bot.tasks.migration_issue_bot import (
 7 |     _check_line_issue,
 8 |     _create_or_find_branch_milestone,
 9 |     _find_issue,
10 |     _set_lines_issue,
11 | )
12 | 
13 | 
14 | def _get_repository(gh, org, repo):
15 |     return gh.repository(org, repo)
16 | 
17 | 
18 | @pytest.mark.vcr()
19 | def test_create_or_find_branch_milestone(gh):
20 |     repo = _get_repository(gh, "OCA", "contract")
21 |     milestone = _create_or_find_branch_milestone(repo, "8.0")
22 |     assert milestone.title == "8.0"
23 | 
24 | 
25 | @pytest.mark.vcr()
26 | def test_find_issue(gh):
27 |     repo = _get_repository(gh, "OCA", "contract")
28 |     milestone = _create_or_find_branch_milestone(repo, "14.0")
29 |     issue = _find_issue(repo, milestone, "14.0")
30 |     assert issue.title == "Migration to version 14.0"
31 | 
32 | 
33 | @pytest.mark.vcr()
34 | def test_set_lines_issue(gh):
35 |     module = "mis_builder"
36 |     gh_pr_user_login = "sbidoul"
37 |     gh_pr_number = 11
38 | 
39 |     body_transformation = [
40 |         (
41 |             "Issue with list but not the module\n"
42 |             "- [ ] a_module_1 - By @legalsylvain - #1\n"
43 |             "- [ ] z_module_1 - By @pedrobaeza - #2",
44 |             f"Issue with list but not the module\n"
45 |             f"- [ ] a_module_1 - By @legalsylvain - #1\n"
46 |             f"- [ ] {module} - By @{gh_pr_user_login} - #{gh_pr_number}\n"
47 |             f"- [ ] z_module_1 - By @pedrobaeza - #2",
48 |         ),
49 |         (
50 |             f"Issue with list containing the module\n"
51 |             f"- [x] {module} - By @legalsylvain - #1\n"
52 |             f"- [ ] z_module_1 - By @pedrobaeza - #2",
53 |             f"Issue with list containing the module\n"
54 |             f"- [x] {module} - By @{gh_pr_user_login} - #{gh_pr_number}\n"
55 |             f"- [ ] z_module_1 - By @pedrobaeza - #2",
56 |         ),
57 |         (
58 |             f"Issue with list containing the module with no PR\n"
59 |             f"- [x] {module}\n"
60 |             f"- [ ] z_module_1 - By @pedrobaeza - #2",
61 |             f"Issue with list containing the module with no PR\n"
62 |             f"- [x] {module} - By @{gh_pr_user_login} - #{gh_pr_number}\n"
63 |             f"- [ ] z_module_1 - By @pedrobaeza - #2",
64 |         ),
65 |         (
66 |             "Issue with no list",
67 |             f"Issue with no list\n"
68 |             f"- [ ] {module} - By @{gh_pr_user_login} - #{gh_pr_number}",
69 |         ),
70 |     ]
71 |     for old_body, new_body_expected in body_transformation:
72 |         new_body, _ = _set_lines_issue(gh_pr_user_login, gh_pr_number, old_body, module)
73 |         assert new_body == new_body_expected
74 | 
75 | 
76 | @pytest.mark.vcr()
77 | def test_check_line_issue(gh):
78 |     module = "mis_builder"
79 |     gh_pr_user_login = "sbidoul"
80 |     gh_pr_number = 11
81 | 
82 |     old_body = (
83 |         f"Issue with list containing the module\n"
84 |         f"- [ ] a_module_1 - By @legalsylvain - #1\n"
85 |         f"- [ ] {module} - By @{gh_pr_user_login} - #{gh_pr_number}\n"
86 |         f"- [ ] z_module_1 - By @pedrobaeza - #2"
87 |     )
88 |     new_body_expected = (
89 |         f"Issue with list containing the module\n"
90 |         f"- [ ] a_module_1 - By @legalsylvain - #1\n"
91 |         f"- [x] {module} - By @{gh_pr_user_login} - #{gh_pr_number}\n"
92 |         f"- [ ] z_module_1 - By @pedrobaeza - #2"
93 |     )
94 |     new_body = _check_line_issue(gh_pr_number, old_body)
95 |     assert new_body == new_body_expected
96 | 


--------------------------------------------------------------------------------
/tests/test_on_command.py:
--------------------------------------------------------------------------------
 1 | # Copyright (c) initOS GmbH 2019
 2 | # Distributed under the MIT License (http://opensource.org/licenses/MIT).
 3 | 
 4 | import pytest
 5 | 
 6 | from oca_github_bot.webhooks import on_command
 7 | 
 8 | from .common import EventMock
 9 | 
10 | 
11 | def create_test_data(pr, user, body):
12 |     return {
13 |         "repository": {"full_name": "OCA/some-repo"},
14 |         "issue": {"number": pr if pr else None, "pull_request": bool(pr)},
15 |         "comment": {"body": body, "user": {"login": user}},
16 |     }
17 | 
18 | 
19 | @pytest.mark.asyncio
20 | async def test_on_command_no_pr(mocker):
21 |     mocker.patch("oca_github_bot.webhooks.on_command.parse_commands")
22 | 
23 |     data = create_test_data(False, "test-user", "/ocabot merge")
24 |     event = EventMock(data)
25 |     await on_command.on_command(event, None)
26 |     on_command.parse_commands.assert_not_called()
27 | 
28 | 
29 | @pytest.mark.asyncio
30 | async def test_on_command_valid_pr(mocker):
31 |     cmd_mock = mocker.Mock()
32 |     mocker.patch("oca_github_bot.webhooks.on_command.parse_commands").return_value = [
33 |         cmd_mock
34 |     ]
35 | 
36 |     body = "test_message"
37 | 
38 |     # Completed
39 |     data = create_test_data(42, "test-user", body)
40 |     event = EventMock(data)
41 |     await on_command.on_command(event, None)
42 |     on_command.parse_commands.assert_called_once_with(body)
43 |     cmd_mock.delay.assert_called_once_with("OCA", "some-repo", 42, "test-user")
44 | 


--------------------------------------------------------------------------------
/tests/test_on_pr_close_delete_branch.py:
--------------------------------------------------------------------------------
 1 | # Copyright (c) ACSONE SA/NV 2018
 2 | # Distributed under the MIT License (http://opensource.org/licenses/MIT).
 3 | 
 4 | import pytest
 5 | 
 6 | from oca_github_bot.webhooks import on_pr_close_delete_branch
 7 | 
 8 | from .common import EventMock
 9 | 
10 | 
11 | @pytest.mark.asyncio
12 | async def test_on_pr_close_delete_branch(mocker):
13 |     mocker.patch(
14 |         "oca_github_bot.webhooks.on_pr_close_delete_branch.delete_branch.delay"
15 |     )
16 |     event = EventMock(
17 |         data={
18 |             "repository": {"full_name": "OCA/some-repo"},
19 |             "pull_request": {
20 |                 "head": {"repo": {"fork": False}, "ref": "pr-branch"},
21 |                 "merged": True,
22 |             },
23 |         }
24 |     )
25 |     await on_pr_close_delete_branch.on_pr_close_delete_branch(event, None)
26 |     on_pr_close_delete_branch.delete_branch.delay.assert_called_once_with(
27 |         "OCA", "some-repo", "pr-branch"
28 |     )
29 | 


--------------------------------------------------------------------------------
/tests/test_on_pr_green_label_needs_review.py:
--------------------------------------------------------------------------------
 1 | # Distributed under the MIT License (http://opensource.org/licenses/MIT).
 2 | 
 3 | import pytest
 4 | 
 5 | from oca_github_bot.webhooks import on_pr_green_label_needs_review
 6 | 
 7 | from .common import EventMock
 8 | 
 9 | 
10 | @pytest.mark.asyncio
11 | async def test_on_pr_green_label_needs_review(mocker):
12 |     mocker.patch(
13 |         "oca_github_bot.webhooks.on_pr_green_label_needs_review.tag_needs_review.delay"
14 |     )
15 |     event = EventMock(
16 |         data={
17 |             "repository": {"full_name": "OCA/some-repo"},
18 |             "check_suite": {"pull_requests": [{"number": 1}], "conclusion": "success"},
19 |         }
20 |     )
21 |     await on_pr_green_label_needs_review.on_pr_green_label_needs_review(event, None)
22 |     on_pr_green_label_needs_review.tag_needs_review.delay.assert_called_once_with(
23 |         "OCA", 1, "some-repo", "success"
24 |     )
25 | 


--------------------------------------------------------------------------------
/tests/test_on_push_to_main_branch.py:
--------------------------------------------------------------------------------
 1 | # Copyright (c) ACSONE SA/NV 2018
 2 | # Distributed under the MIT License (http://opensource.org/licenses/MIT).
 3 | 
 4 | import pytest
 5 | 
 6 | from oca_github_bot.webhooks import on_push_to_main_branch
 7 | 
 8 | from .common import EventMock
 9 | 
10 | 
11 | @pytest.mark.asyncio
12 | async def test_on_push_to_main_branch_1(mocker):
13 |     mocker.patch("oca_github_bot.webhooks.on_push_to_main_branch.main_branch_bot.delay")
14 |     event = EventMock(
15 |         {"repository": {"full_name": "OCA/some-repo"}, "ref": "refs/heads/11.0"}
16 |     )
17 |     await on_push_to_main_branch.on_push_to_main_branch(event, None)
18 |     on_push_to_main_branch.main_branch_bot.delay.assert_called_once_with(
19 |         "OCA", "some-repo", "11.0", build_wheels=False
20 |     )
21 | 


--------------------------------------------------------------------------------
/tests/test_on_status_merge_bot.py:
--------------------------------------------------------------------------------
 1 | # Copyright (c) initOS GmbH 2019
 2 | # Distributed under the MIT License (http://opensource.org/licenses/MIT).
 3 | 
 4 | import pytest
 5 | 
 6 | from oca_github_bot.version_branch import make_merge_bot_branch
 7 | from oca_github_bot.webhooks import on_status_merge_bot
 8 | 
 9 | from .common import EventMock
10 | 
11 | 
12 | def create_test_data(branch, status, conclusion, sha=None):
13 |     return {
14 |         "repository": {"full_name": "OCA/some-repo"},
15 |         "check_suite": {
16 |             "head_branch": branch,
17 |             "head_sha": sha if sha else "0" * 40,
18 |             "status": status,
19 |             "conclusion": conclusion,
20 |             "app": {"name": "Travis CI"},
21 |         },
22 |     }
23 | 
24 | 
25 | def mock_merge_bot_functions(mocker):
26 |     mocker.patch("oca_github_bot.webhooks.on_status_merge_bot.merge_bot_status.delay")
27 | 
28 | 
29 | @pytest.mark.asyncio
30 | async def test_on_check_suite_invalid_branch(mocker):
31 |     mock_merge_bot_functions(mocker)
32 | 
33 |     # Completed
34 |     data = create_test_data("changes", "completed", "success")
35 |     event = EventMock(data)
36 |     await on_status_merge_bot.on_check_suite_merge_bot(event, None)
37 |     on_status_merge_bot.merge_bot_status.delay.assert_not_called()
38 | 
39 |     # Running
40 |     data = create_test_data("changes", "in_progress", None)
41 |     event = EventMock(data)
42 |     await on_status_merge_bot.on_check_suite_merge_bot(event, None)
43 |     on_status_merge_bot.merge_bot_status.delay.assert_not_called()
44 | 
45 | 
46 | @pytest.mark.asyncio
47 | async def test_on_check_suite_valid_branch(mocker):
48 |     mock_merge_bot_functions(mocker)
49 | 
50 |     branch = make_merge_bot_branch(42, "12.0", "toto", "patch")
51 |     sha = "1" * 40
52 |     data = create_test_data(branch, "completed", "failure", sha)
53 |     event = EventMock(data)
54 |     await on_status_merge_bot.on_check_suite_merge_bot(event, None)
55 |     on_status_merge_bot.merge_bot_status.delay.assert_called_once_with(
56 |         "OCA", "some-repo", branch, sha
57 |     )
58 | 
59 | 
60 | @pytest.mark.asyncio
61 | async def test_on_check_suite_valid_branch_in_progress(mocker):
62 |     mock_merge_bot_functions(mocker)
63 | 
64 |     branch = make_merge_bot_branch(42, "12.0", "toto", "patch")
65 |     sha = "1" * 40
66 |     data = create_test_data(branch, "in_progress", "success", sha)
67 |     event = EventMock(data)
68 |     await on_status_merge_bot.on_check_suite_merge_bot(event, None)
69 |     on_status_merge_bot.merge_bot_status.delay.assert_not_called()
70 | 


--------------------------------------------------------------------------------
/tests/test_pypi.py:
--------------------------------------------------------------------------------
 1 | # Copyright (c) ACSONE SA/NV 2021
 2 | # Distributed under the MIT License (http://opensource.org/licenses/MIT).
 3 | import tempfile
 4 | from pathlib import Path
 5 | 
 6 | import pytest
 7 | 
 8 | from oca_github_bot.pypi import TwineDistPublisher, exists_on_index
 9 | 
10 | 
11 | @pytest.mark.parametrize(
12 |     "filename, expected",
13 |     [
14 |         ("pip-21.0.1-py3-none-any.whl", True),
15 |         ("pip-20.4-py3-none-any.whl", False),
16 |         ("not_a_pkg-1.0-py3-none-any.whl", False),
17 |     ],
18 | )
19 | @pytest.mark.vcr()
20 | def test_exists_on_index(filename, expected):
21 |     assert exists_on_index("https://pypi.org/simple/", filename) is expected
22 | 
23 | 
24 | @pytest.mark.vcr()
25 | def test_twine_publisher_file_exists():
26 |     """Basic test for the twine publisher.
27 | 
28 |     This test succeeds despite the bogus upload URL, because the file exists,
29 |     so no upload is attempted.
30 |     """
31 |     publisher = TwineDistPublisher(
32 |         "https://pypi.org/simple/", "https://pypi.org/legacy", "username", "password"
33 |     )
34 |     with tempfile.TemporaryDirectory() as tmpdir:
35 |         filepath = Path(tmpdir) / "odoo9_addon_mis_builder-9.0.3.5.0-py2-none-any.whl"
36 |         filepath.touch()
37 |         publisher.publish(tmpdir, dry_run=False)
38 | 


--------------------------------------------------------------------------------
/tests/test_switchable.py:
--------------------------------------------------------------------------------
 1 | # Copyright (c) ACSONE SA/NV 2018
 2 | # Distributed under the MIT License (http://opensource.org/licenses/MIT).
 3 | 
 4 | from oca_github_bot import config
 5 | 
 6 | 
 7 | @config.switchable()
 8 | def ping_method(msg):
 9 |     return msg
10 | 
11 | 
12 | @config.switchable("ping_method_switch")
13 | def ping_method_2(msg):
14 |     return msg
15 | 
16 | 
17 | def test_switchable_method():
18 |     assert config.BOT_TASKS == ["all"]
19 |     assert ping_method("test") == "test"
20 |     assert ping_method_2("test") == "test"
21 |     config.BOT_TASKS_DISABLED = ["ping_method"]
22 |     assert ping_method("test") is None
23 |     assert ping_method_2("test") == "test"
24 |     config.BOT_TASKS_DISABLED = []
25 |     config.BOT_TASKS = []
26 |     assert ping_method("test") is None
27 |     assert ping_method_2("test") is None
28 |     config.BOT_TASKS = ["ping_method"]
29 |     assert ping_method("test") == "test"
30 |     assert ping_method_2("test") is None
31 |     config.BOT_TASKS = ["ping_method", "ping_method_switch"]
32 |     assert ping_method("test") == "test"
33 |     assert ping_method_2("test") == "test"
34 | 


--------------------------------------------------------------------------------
/tests/test_utils.py:
--------------------------------------------------------------------------------
  1 | # Copyright (c) ACSONE SA/NV 2021
  2 | # Distributed under the MIT License (http://opensource.org/licenses/MIT).
  3 | 
  4 | from pathlib import Path
  5 | 
  6 | import pytest
  7 | 
  8 | from oca_github_bot.utils import cmd_to_str, hide_secrets, retry_on_exception
  9 | 
 10 | from .common import set_config
 11 | 
 12 | 
 13 | def test_hide_secrets_nothing_to_hide():
 14 |     with set_config(GITHUB_TOKEN="token"):
 15 |         assert "nothing to hide" == hide_secrets("nothing to hide")
 16 | 
 17 | 
 18 | def test_hide_secrets_github_token():
 19 |     with set_config(GITHUB_TOKEN="token"):
 20 |         assert "git push https://***@github.com" == hide_secrets(
 21 |             "git push https://token@github.com"
 22 |         )
 23 | 
 24 | 
 25 | def test_retry_on_exception_raise_match():
 26 |     counter = 0
 27 | 
 28 |     def func_that_raises():
 29 |         nonlocal counter
 30 |         counter += 1
 31 |         raise Exception("something")
 32 | 
 33 |     max_retries = 2
 34 |     sleep_time = 0.1
 35 | 
 36 |     try:
 37 |         retry_on_exception(
 38 |             func_that_raises,
 39 |             "something",
 40 |             max_retries=max_retries,
 41 |             sleep_time=sleep_time,
 42 |         )
 43 |     except Exception as e:
 44 |         assert "something" in str(e)
 45 |         assert counter == max_retries + 1
 46 | 
 47 | 
 48 | def test_retry_on_exception_raise_no_match():
 49 |     counter = 0
 50 | 
 51 |     def func_that_raises():
 52 |         nonlocal counter
 53 |         counter += 1
 54 |         raise Exception("somestuff")
 55 | 
 56 |     max_retries = 2
 57 |     sleep_time = 0.1
 58 | 
 59 |     try:
 60 |         retry_on_exception(
 61 |             func_that_raises,
 62 |             "something",
 63 |             max_retries=max_retries,
 64 |             sleep_time=sleep_time,
 65 |         )
 66 |     except Exception as e:
 67 |         assert "somestuff" in str(e)
 68 |         assert counter == 1
 69 | 
 70 | 
 71 | def test_retry_on_exception_no_raise():
 72 |     counter = 0
 73 | 
 74 |     def func_that_raises():
 75 |         nonlocal counter
 76 |         counter += 1
 77 |         return True
 78 | 
 79 |     max_retries = 2
 80 |     sleep_time = 0.1
 81 | 
 82 |     retry_on_exception(
 83 |         func_that_raises,
 84 |         "something",
 85 |         max_retries=max_retries,
 86 |         sleep_time=sleep_time,
 87 |     )
 88 |     assert counter == 1
 89 | 
 90 | 
 91 | @pytest.mark.parametrize(
 92 |     "cmd, expected",
 93 |     [
 94 |         (["a", "b"], "a b"),
 95 |         (["ls", Path("./user name")], "ls 'user name'"),
 96 |         (["a", "b c"], "a 'b c'"),
 97 |     ],
 98 | )
 99 | def test_cmd_to_str(cmd, expected):
100 |     assert cmd_to_str(cmd) == expected
101 | 


--------------------------------------------------------------------------------
/tests/test_version_branch.py:
--------------------------------------------------------------------------------
 1 | # Copyright (c) ACSONE SA/NV 2018
 2 | # Distributed under the MIT License (http://opensource.org/licenses/MIT).
 3 | 
 4 | import pytest
 5 | 
 6 | from oca_github_bot.version_branch import (
 7 |     is_main_branch_bot_branch,
 8 |     is_merge_bot_branch,
 9 |     is_protected_branch,
10 |     is_supported_main_branch,
11 |     make_merge_bot_branch,
12 |     parse_merge_bot_branch,
13 |     search_merge_bot_branch,
14 | )
15 | 
16 | from .common import set_config
17 | 
18 | 
19 | def test_is_main_branch_bot_branch():
20 |     assert not is_main_branch_bot_branch("6.1")
21 |     assert not is_main_branch_bot_branch("7.0")
22 |     assert is_main_branch_bot_branch("8.0")
23 |     assert is_main_branch_bot_branch("12.0")
24 |     assert not is_main_branch_bot_branch("10.0-something")
25 |     with set_config(MAIN_BRANCH_BOT_MIN_VERSION="10.0"):
26 |         assert is_main_branch_bot_branch("10.0")
27 |         assert is_main_branch_bot_branch("12.0")
28 |         assert not is_main_branch_bot_branch("7.0")
29 | 
30 | 
31 | def test_is_protected_branch():
32 |     assert is_protected_branch("master")
33 |     assert is_protected_branch("6.0")
34 |     assert is_protected_branch("12.0")
35 |     assert not is_protected_branch("10.0-something")
36 | 
37 | 
38 | def test_is_merge_bot_branch():
39 |     assert is_merge_bot_branch("12.0-ocabot-merge-pr-100-by-toto-bump-patch")
40 |     assert not is_merge_bot_branch("12.0-cabot-merge-pr-a100-by-titi-bump-no")
41 |     assert is_merge_bot_branch("master-ocabot-merge-pr-100-by-toto-bump-no")
42 | 
43 | 
44 | def test_make_merge_bot_branch():
45 |     assert (
46 |         make_merge_bot_branch("100", "12.0", "toto", "patch")
47 |         == "12.0-ocabot-merge-pr-100-by-toto-bump-patch"
48 |     )
49 | 
50 | 
51 | def test_parse_merge_bot_branch():
52 |     assert parse_merge_bot_branch("12.0-ocabot-merge-pr-100-by-toto-bump-patch") == (
53 |         "100",
54 |         "12.0",
55 |         "toto",
56 |         "patch",
57 |     )
58 |     assert parse_merge_bot_branch("12.0-ocabot-merge-pr-100-by-toto-bump-no") == (
59 |         "100",
60 |         "12.0",
61 |         "toto",
62 |         "nobump",
63 |     )
64 | 
65 | 
66 | def test_merge_bot_branch_name():
67 |     # ocabot-merge must not change, as other tools may depend on it.
68 |     # The rest of the branch name must be considered opaque and fit for the bot
69 |     # needs only.
70 |     assert "ocabot-merge" in make_merge_bot_branch("100", "12.0", "toto", "patch")
71 | 
72 | 
73 | def test_search_merge_bot_branch():
74 |     text = "blah blah 12.0-ocabot-merge-pr-100-by-toto-bump-no more stuff"
75 |     assert search_merge_bot_branch(text) == "12.0-ocabot-merge-pr-100-by-toto-bump-no"
76 |     text = "blah blah more stuff"
77 |     assert search_merge_bot_branch(text) is None
78 | 
79 | 
80 | @pytest.mark.parametrize(
81 |     ("branch_name", "min_version", "expected"),
82 |     [
83 |         ("8.0", None, True),
84 |         ("8.0", "8.0", True),
85 |         ("8.0", "9.0", False),
86 |     ],
87 | )
88 | def test_is_supported_branch(branch_name, min_version, expected):
89 |     assert is_supported_main_branch(branch_name, min_version) is expected
90 | 


--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
 1 | [tox]
 2 | envlist =
 3 |   py312
 4 |   check_readme
 5 |   pre_commit
 6 | 
 7 | [testenv]
 8 | skip_missing_interpreters = True
 9 | use_develop = true
10 | commands =
11 |   pytest --cov=oca_github_bot --cov-branch --cov-report=html --cov-report=xml --vcr-record=none {posargs}
12 | deps =
13 |   -r requirements.txt
14 |   -r requirements-test.txt
15 | 
16 | [testenv:check_readme]
17 | description = check that the long description is valid (need for PyPi)
18 | deps =
19 |   twine
20 |   pip
21 | skip_install = true
22 | commands =
23 |   pip wheel -w {envtmpdir}/build --no-deps .
24 |   twine check {envtmpdir}/build/*
25 | 
26 | [testenv:pre_commit]
27 | deps =
28 |   pre-commit
29 | skip_install = true
30 | commands =
31 |   pre-commit run --all-files
32 | 


--------------------------------------------------------------------------------