diff --git a/environment.sample b/environment.sample index ff75553a..3c9e32f4 100644 --- a/environment.sample +++ b/environment.sample @@ -84,3 +84,6 @@ OCABOT_TWINE_REPOSITORIES="[('https://pypi.org/simple','https://upload.pypi.org/ # List of branches the bot will check to verify if user is the maintainer # of module(s) MAINTAINER_CHECK_ODOO_RELEASES=8.0,9.0,10.0,11.0,12.0,13.0,14.0,15.0 + +# Reminder of migration guidelines. +#MIGRATION_GUIDELINES_REMINDER= diff --git a/src/oca_github_bot/config.py b/src/oca_github_bot/config.py index 4fbc5926..2823bec5 100644 --- a/src/oca_github_bot/config.py +++ b/src/oca_github_bot/config.py @@ -142,3 +142,20 @@ def func_wrapper(*args, **kwargs): "WHEEL_BUILD_TOOLS", "build,pip,setuptools<58,wheel,setuptools-odoo,whool", ).split(",") + +MIGRATION_GUIDELINES_REMINDER = os.environ.get( + "MIGRATION_GUIDELINES_REMINDER", + """Thanks for the contribution! +This appears to be a migration, \ +so here you have a gentle reminder about the migration guidelines. + +Please preserve commit history following technical method \ +explained in https://github.com/OCA/maintainer-tools/wiki/#migration. +If the jump is between several versions, \ +you have to modify the source branch in the main command \ +to accommodate it to this circumstance. + +You can also take a look on the project \ +https://github.com/OCA/odoo-module-migrator/ to make easier migration. +""", +) diff --git a/src/oca_github_bot/tasks/__init__.py b/src/oca_github_bot/tasks/__init__.py index b9d6be7c..971f113c 100644 --- a/src/oca_github_bot/tasks/__init__.py +++ b/src/oca_github_bot/tasks/__init__.py @@ -6,6 +6,7 @@ main_branch_bot, mention_maintainer, migration_issue_bot, + migration_pr_check, tag_approved, tag_needs_review, tag_ready_to_merge, diff --git a/src/oca_github_bot/tasks/migration_issue_bot.py b/src/oca_github_bot/tasks/migration_issue_bot.py index 510c435e..1d50786d 100644 --- a/src/oca_github_bot/tasks/migration_issue_bot.py +++ b/src/oca_github_bot/tasks/migration_issue_bot.py @@ -12,11 +12,18 @@ from ..utils import hide_secrets -def _create_or_find_branch_milestone(gh_repo, branch): +def _find_branch_milestone(gh_repo, branch): for milestone in gh_repo.milestones(): if milestone.title == branch: return milestone - return gh_repo.create_milestone(branch) + return None + + +def _create_or_find_branch_milestone(gh_repo, branch): + branch_milestone = _find_branch_milestone(gh_repo, branch) + if not branch_milestone: + branch_milestone = gh_repo.create_milestone(branch) + return branch_milestone def _find_issue(gh_repo, milestone, target_branch): diff --git a/src/oca_github_bot/tasks/migration_pr_check.py b/src/oca_github_bot/tasks/migration_pr_check.py new file mode 100644 index 00000000..748f62a6 --- /dev/null +++ b/src/oca_github_bot/tasks/migration_pr_check.py @@ -0,0 +1,77 @@ +# Copyright 2022 Simone Rubino - TAKOBI +# Distributed under the MIT License (http://opensource.org/licenses/MIT). +import os.path +import re + +from .. import config, github +from ..config import switchable +from ..manifest import addon_dirs_in +from ..process import check_call +from ..queue import getLogger, task +from .migration_issue_bot import _find_branch_milestone, _find_issue + +_logger = getLogger(__name__) + + +def _get_added_modules(org, repo, gh_pr): + target_branch = gh_pr.base.ref + with github.temporary_clone(org, repo, target_branch) as clonedir: + # We need a list now because otherwise modules + # are yielded when the directory is already changed + existing_addons_paths = list(addon_dirs_in(clonedir, installable_only=True)) + + pr_branch = f"tmp-pr-{gh_pr.number}" + check_call( + ["git", "fetch", "origin", f"refs/pull/{gh_pr.number}/head:{pr_branch}"], + cwd=clonedir, + ) + check_call(["git", "checkout", pr_branch], cwd=clonedir) + pr_addons_paths = addon_dirs_in(clonedir, installable_only=True) + + new_addons_paths = { + addon_path + for addon_path in pr_addons_paths + if addon_path not in existing_addons_paths + } + return new_addons_paths + + +def is_migration_pr(org, repo, pr): + """ + Determine if the PR is a migration. + """ + with github.login() as gh: + gh_repo = gh.repository(org, repo) + gh_pr = gh_repo.pull_request(pr) + target_branch = gh_pr.base.ref + milestone = _find_branch_milestone(gh_repo, target_branch) + gh_migration_issue = _find_issue(gh_repo, milestone, target_branch) + + # The PR is mentioned in the migration issue + pr_regex = re.compile(rf"#({gh_pr.number})") + found_pr = pr_regex.findall(gh_migration_issue.body) + if found_pr: + return True + + # The added module is mentioned in the migration issue + new_addons_paths = _get_added_modules(org, repo, gh_pr) + new_addons = map(os.path.basename, new_addons_paths) + for addon in new_addons: + module_regex = re.compile(rf"- \[[ x]] {addon}") + found_module = module_regex.search(gh_migration_issue.body) + if found_module: + return True + return False + + +@task() +@switchable("comment_migration_guidelines") +def comment_migration_guidelines(org, repo, pr, dry_run=False): + migration_reminder = config.MIGRATION_GUIDELINES_REMINDER + if dry_run: + _logger.info(f"DRY-RUN Comment {migration_reminder}") + else: + _logger.info(f"Comment {migration_reminder}") + with github.login() as gh: + gh_pr = gh.pull_request(org, repo, pr) + return github.gh_call(gh_pr.create_comment, migration_reminder) diff --git a/src/oca_github_bot/webhooks/__init__.py b/src/oca_github_bot/webhooks/__init__.py index 55e90e44..b096a4d3 100644 --- a/src/oca_github_bot/webhooks/__init__.py +++ b/src/oca_github_bot/webhooks/__init__.py @@ -7,6 +7,7 @@ on_pr_green_label_needs_review, on_pr_open_label_new_contributor, on_pr_open_mention_maintainer, + on_pr_open_migration_check, on_pr_review, on_push_to_main_branch, on_status_merge_bot, diff --git a/src/oca_github_bot/webhooks/on_pr_open_migration_check.py b/src/oca_github_bot/webhooks/on_pr_open_migration_check.py new file mode 100644 index 00000000..708e8d4e --- /dev/null +++ b/src/oca_github_bot/webhooks/on_pr_open_migration_check.py @@ -0,0 +1,21 @@ +# Copyright 2022 Simone Rubino - TAKOBI +# Distributed under the MIT License (http://opensource.org/licenses/MIT). + +import logging + +from ..router import router +from ..tasks.migration_pr_check import comment_migration_guidelines, is_migration_pr + +_logger = logging.getLogger(__name__) + + +@router.register("pull_request", action="opened") +async def on_pr_open_migration_check(event, *args, **kwargs): + """ + Whenever a PR is opened, if it is a migration PR, + remind the migration guidelines. + """ + org, repo = event.data["repository"]["full_name"].split("/") + pr = event.data["pull_request"]["number"] + if is_migration_pr(org, repo, pr): + comment_migration_guidelines.delay(org, repo, pr) diff --git a/tests/test_migration_pr_check.py b/tests/test_migration_pr_check.py new file mode 100644 index 00000000..1e06e95d --- /dev/null +++ b/tests/test_migration_pr_check.py @@ -0,0 +1,125 @@ +# Copyright 2022 Simone Rubino - TAKOBI +# Distributed under the MIT License (http://opensource.org/licenses/MIT). + +import pytest + +from oca_github_bot.tasks.migration_pr_check import ( + comment_migration_guidelines, + is_migration_pr, +) + +MIGRATION_PR_PATH = "oca_github_bot.tasks.migration_pr_check" + + +def _get_addons_gen_mock(pr_new_modules=None): + """ + Return a callable that returns a list of modules. + The list contains `pr_new_modules` only after first call. + """ + if pr_new_modules is None: + pr_new_modules = list() + + class AddonsGenMock: + def __init__(self): + self.addons_gen_calls = 0 + + def __call__(self, *args, **kwargs): + # First time, only the existing addons are returned + existing_addons = ["existing_addon"] + if self.addons_gen_calls > 0: + # After that, return also `pr_new_modules` + if pr_new_modules: + existing_addons.extend(pr_new_modules) + self.addons_gen_calls += 1 + return existing_addons + + return AddonsGenMock() + + +@pytest.mark.vcr() +def test_no_new_module(mocker): + """ + If a PR does not add a new module, then it is not a migration. + """ + mocker.patch("%s.github" % MIGRATION_PR_PATH) + mocker.patch("%s.check_call" % MIGRATION_PR_PATH) + + migration_issue = mocker.patch("%s._find_issue" % MIGRATION_PR_PATH) + migration_issue.return_value.body = "Migration Issue Body" + + addons_gen = mocker.patch("%s.addon_dirs_in" % MIGRATION_PR_PATH) + addons_gen.side_effect = _get_addons_gen_mock() + + is_migration = is_migration_pr("org", "repo", "pr") + assert not is_migration + + +@pytest.mark.vcr() +def test_new_module_no_migration(mocker): + """ + If a PR adds a new module but the module is not in the migration issue, + then it is not a migration. + """ + mocker.patch("%s.github" % MIGRATION_PR_PATH) + mocker.patch("%s.check_call" % MIGRATION_PR_PATH) + + migration_issue_body = """ +Modules to migrate: +- [ ] a_module + """ + migration_issue = mocker.patch("%s._find_issue" % MIGRATION_PR_PATH) + migration_issue.return_value.body = migration_issue_body + + addons_gen = mocker.patch("%s.addon_dirs_in" % MIGRATION_PR_PATH) + addons_gen.side_effect = _get_addons_gen_mock() + + is_migration = is_migration_pr("org", "repo", "pr") + assert not is_migration + + +@pytest.mark.vcr() +def test_new_module_migration(mocker): + """ + If a PR adds a new module and the module is in the migration issue, + then it is a migration. + """ + mocker.patch("%s.github" % MIGRATION_PR_PATH) + mocker.patch("%s.check_call" % MIGRATION_PR_PATH) + + addon_name = "migrated_module" + migration_issue_body = f""" +Modules to migrate: +- [ ] {addon_name} + """ + migration_issue = mocker.patch("%s._find_issue" % MIGRATION_PR_PATH) + migration_issue.return_value.body = migration_issue_body + + addons_gen = mocker.patch("%s.addon_dirs_in" % MIGRATION_PR_PATH) + addons_gen.side_effect = _get_addons_gen_mock([addon_name]) + + is_migration = is_migration_pr("org", "repo", "pr") + assert is_migration + + +@pytest.mark.vcr() +def test_migration_comment(mocker): + """ + If a PR adds a new module and it is in the migration issue, + then it is a migration. + """ + github_mock = mocker.patch("%s.github" % MIGRATION_PR_PATH) + mocker.patch("%s.check_call" % MIGRATION_PR_PATH) + + addon_name = "migrated_module" + migration_issue_body = f""" +Modules to migrate: +- [ ] {addon_name} + """ + migration_issue = mocker.patch("%s._find_issue" % MIGRATION_PR_PATH) + migration_issue.return_value.body = migration_issue_body + + addons_gen = mocker.patch("%s.addon_dirs_in" % MIGRATION_PR_PATH) + addons_gen.side_effect = _get_addons_gen_mock([addon_name]) + + comment_migration_guidelines("org", "repo", "pr") + github_mock.gh_call.assert_called_once()