Repository: OCA/oca-github-bot

Issue:
Merge on demand
A new bot task, that would be triggered by a command in a pull request comment.

**Command syntax**

`/ocabot merge (major|minor|patch)?`

**Actions performed**

- on merge command detected in PR comment
  - !!! check the author of the comment has the right to merge
    - is part of a team with write permission on the repo
    - or, is declared as maintainer in the manifest of all addons modified by the PR
  - rebase on main branch with autosquash
  - generate addon readme and setup.py
  - bump manifest version if (major|minor|patch), looking at all addons changed by the PR
  - push to a new branch named ``ocabot-merge-pr<pr>-to-<branch>`` (fail if that branch already exists)

- on red CI status on ``ocabot-merge-pr<pr>-to-<branch>`` branch
  - (ignore coverage/coveralls) 
  - add failed comment on PR
  - delete branch, exit

- on green CI status on ``ocabot-merge-pr<pr>-to-<branch>`` branch
  - push to main branch (--no-ff), if fail: rebase on main branch and force push ``ocabot-merge-pr<pr>-to-<branch>``, if rebase fails, delete branch and exit
  - delete branch ``ocabot-merge-pr<pr>-to-<branch>``
  - add a comment on the PR: *merged at sha*
  - close the PR
  - create wheel and publish to pypi, for the commit of our previous merge (to make sure a non .dev version goes to pypi if we bumped version)

**Attention point**
- makepot must somehow run before bumping the version: idea travis does makepot on `ocabot-merge-pr#`, and a local `msgmerge -U` is done before building the wheel so the translations in the wheel are aligned with the .pot.
- ~maybe we should allow makepot to run and push to PR branches~ => risk of merge conflict

**Tasks**

- [x] create main PR for this issue to which other subtasks can be contributed: #39
- [x] main method @sbidoul 
- [x] tie main method to command @sbidoul 
- [x] [function to bump manifest version](https://github.com/OCA/oca-github-bot/blob/cfe057f17a56a3a2a8827e11dda546546b780712/src/oca_github_bot/manifest.py#L65)
- [x] function to check if a github login has write access to the repo @fkantelberg 
- [x] webhook @fkantelberg 
- [x] function to detect addons modified by a PR, [start here](https://github.com/OCA/oca-github-bot/blob/cfe057f17a56a3a2a8827e11dda546546b780712/src/oca_github_bot/manifest.py#L93)
- [x] function to parse a comment and detect the merge command, returning {pr, target branch, version bump requested ('major'|'minor'|'patch'|None)}
- [x] functions to make the merge bot branch name from {pr, target branch} and parse the branch name 
to return {pr, target branch}
- [x] function to check if a github status webhook call is considered valid for merging (ignore coverage?)

Discussion:

Patch:
diff --git a/README.rst b/README.rst
index e48a544..ba11cbc 100644
--- a/README.rst
+++ b/README.rst
@@ -52,6 +52,19 @@ On Pull Request review
 When there are two approvals, set the ``approved`` label.
 When the PR is at least 5 days old, set the ``ready to merge`` label.
 
+Commands
+--------
+
+One can ask the bot to perform some tasks by entering special commands
+as merge request comments.
+
+`/ocabot merge` optionally followed by one of `major`, `minor`, `patch`,
+can be used to ask the bot to the following:
+* rebase the PR on the target branch
+* run the main branch operations (see above) on it
+* optionally bump the version number of the addons modified by the PR
+* merge when tests on the rebased branch are green
+
 TODO (help wanted)
 ------------------
 
@@ -117,6 +130,7 @@ Contributors
 * Stéphane Bidoul <stephane.bidoul@acsone.eu>
 * Holger Brunn <hbrunn@therp.nl>
 * Michel Raich <miquel.raich@eficent.com>
+* Florian Kantelberg <florian.kantelberg@initos.com>
 
 Maintainers
 ===========
diff --git a/environment.sample b/environment.sample
index 1d48f71..cbe1014 100644
--- a/environment.sample
+++ b/environment.sample
@@ -15,8 +15,6 @@ GITHUB_SECRET=
 GITHUB_LOGIN=
 # GitHub oauth token
 GITHUB_TOKEN=
-# Github organisation name (default OCA)
-GITHUB_ORG=
 # Git name and email used for creating commits (default: user git config,
 # needs to be set to to launch the docker composition)
 GIT_NAME=
diff --git a/src/oca_github_bot/commands.py b/src/oca_github_bot/commands.py
new file mode 100644
index 0000000..e377d69
--- /dev/null
+++ b/src/oca_github_bot/commands.py
@@ -0,0 +1,63 @@
+# Copyright (c) ACSONE SA/NV 2019
+# Distributed under the MIT License (http://opensource.org/licenses/MIT).
+
+import re
+
+from .tasks import merge_bot
+
+BOT_COMMAND_RE = re.compile(
+    r"/ocabot +(?P<command>\w+)( +(?P<options>.*?))? *$", re.MULTILINE
+)
+
+
+class InvalidCommandError(Exception):
+    def __init__(self, name):
+        super().__init__(f"Invalid command: {name}")
+
+
+class InvalidOptionsError(Exception):
+    def __init__(self, name, options):
+        super().__init__(f"Invalid options for command {name}: {options}")
+
+
+class BotCommand:
+    def __init__(self, name, options):
+        self.name = name
+        self.parse_options(options)
+
+    @classmethod
+    def create(cls, name, options):
+        if name == "merge":
+            return BotCommandMerge(name, options)
+        else:
+            raise InvalidCommandError(name)
+
+    def parse_options(self, options):
+        pass
+
+    def delay(self, org, repo, pr, username, dry_run=False):
+        """ Run the command on a given pull request on behalf of a GitHub user """
+        raise NotImplementedError()
+
+
+class BotCommandMerge(BotCommand):
+    bumpversion = None  # optional str: major|minor|patch
+
+    def parse_options(self, options):
+        if not options:
+            return
+        if options in ("major", "minor", "patch"):
+            self.bumpversion = options
+        else:
+            raise InvalidOptionsError(self.name, options)
+
+    def delay(self, org, repo, pr, username, dry_run=False):
+        merge_bot.merge_bot_start.delay(
+            org, repo, pr, username, bumpversion=self.bumpversion, dry_run=False
+        )
+
+
+def parse_commands(text):
+    """ Parse a text and return an iterator of BotCommand objects. """
+    for mo in BOT_COMMAND_RE.finditer(text):
+        yield BotCommand.create(mo.group("command"), mo.group("options"))
diff --git a/src/oca_github_bot/config.py b/src/oca_github_bot/config.py
index a1399e4..d12060d 100644
--- a/src/oca_github_bot/config.py
+++ b/src/oca_github_bot/config.py
@@ -9,7 +9,6 @@ HTTP_PORT = int(os.environ.get("HTTP_PORT") or "8080")
 GITHUB_SECRET = os.environ.get("GITHUB_SECRET")
 GITHUB_LOGIN = os.environ.get("GITHUB_LOGIN")
 GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN")
-GITHUB_ORG = os.environ.get("GITHUB_ORG", "OCA")
 GIT_NAME = os.environ.get("GIT_NAME")
 GIT_EMAIL = os.environ.get("GIT_EMAIL")
 
diff --git a/src/oca_github_bot/cron.py b/src/oca_github_bot/cron.py
index 0db5660..58734ef 100644
--- a/src/oca_github_bot/cron.py
+++ b/src/oca_github_bot/cron.py
@@ -3,7 +3,6 @@
 
 from celery.schedules import crontab
 
-from .config import GITHUB_ORG
 from .queue import app
 
 app.conf.beat_schedule = {
@@ -13,12 +12,12 @@ app.conf.beat_schedule = {
     },
     "main_branch_bot_all_repos": {
         "task": "oca_github_bot.tasks.main_branch_bot.main_branch_bot_all_repos",
-        "args": (GITHUB_ORG,),
+        "args": ("OCA",),
         "schedule": crontab(hour="2", minute="30"),
     },
     "tag_ready_to_merge": {
         "task": "oca_github_bot.tasks.tag_ready_to_merge.tag_ready_to_merge",
-        "args": (GITHUB_ORG,),
+        "args": ("OCA",),
         "schedule": crontab(minute="0"),
     },
 }
diff --git a/src/oca_github_bot/github.py b/src/oca_github_bot/github.py
index 0fec0d9..8dc0aa0 100644
--- a/src/oca_github_bot/github.py
+++ b/src/oca_github_bot/github.py
@@ -137,3 +137,17 @@ def git_push_if_needed(remote, branch, cwd=None):
         else:
             raise
     return True
+
+
+def git_user_can_push(gh_repo, username):
+    for collaborator in gh_call(gh_repo.collaborators):
+        if username == collaborator.login and collaborator.permissions.get("push"):
+            return True
+    return False
+
+
+def git_get_head_sha():
+    """ Get the sha of the git HEAD in current directory """
+    return subprocess.check_output(
+        ["git", "rev-parse", "HEAD"], universal_newlines=True
+    ).strip()
diff --git a/src/oca_github_bot/manifest.py b/src/oca_github_bot/manifest.py
index f783c65..799e12a 100644
--- a/src/oca_github_bot/manifest.py
+++ b/src/oca_github_bot/manifest.py
@@ -37,6 +37,19 @@ def is_addon_dir(addon_dir):
     return bool(get_manifest_path(addon_dir))
 
 
+def get_addon_name(addon_dir):
+    return os.path.basename(os.path.abspath(addon_dir))
+
+
+def get_manifest_file_name(addon_dir):
+    """ Return the name of the manifest file, without path """
+    for manifest_name in MANIFEST_NAMES:
+        manifest_path = os.path.join(addon_dir, manifest_name)
+        if os.path.exists(manifest_path):
+            return manifest_name
+    return None
+
+
 def get_manifest_path(addon_dir):
     for manifest_name in MANIFEST_NAMES:
         manifest_path = os.path.join(addon_dir, manifest_name)
@@ -84,10 +97,23 @@ def bump_version(version, mode):
     return f"{series}.{major}.{minor}.{patch}"
 
 
-def bump_manifest_version(addon_dir, mode):
+def bump_manifest_version(addon_dir, mode, git_commit=False):
     version = get_manifest(addon_dir)["version"]
     version = bump_version(version, mode)
     set_manifest_version(addon_dir, version)
+    if git_commit:
+        addon_name = get_addon_name(addon_dir)
+        subprocess.check_call(
+            [
+                "git",
+                "commit",
+                "-m",
+                f"{addon_name} {version}",
+                "--",
+                get_manifest_file_name(addon_dir),
+            ],
+            cwd=addon_dir,
+        )
 
 
 def git_modified_addons(addons_dir, ref):
@@ -103,7 +129,11 @@ def git_modified_addons(addons_dir, ref):
         parts = diff.split("/")
         if parts[0] == "setup" and len(parts) > 1:
             addon_name = parts[1]
-            if os.path.isdir(os.path.join(addons_dir, "setup", addon_name)):
+            if is_addon_dir(
+                os.path.join(addons_dir, "setup", "addon", "odoo_addons", addon_name)
+            ) or is_addon_dir(
+                os.path.join(addons_dir, "setup", "addon", "odoo", "addons", addon_name)
+            ):
                 modified.add(addon_name)
         else:
             addon_name = parts[0]
diff --git a/src/oca_github_bot/tasks/main_branch_bot.py b/src/oca_github_bot/tasks/main_branch_bot.py
index 81cdab7..3dacd98 100644
--- a/src/oca_github_bot/tasks/main_branch_bot.py
+++ b/src/oca_github_bot/tasks/main_branch_bot.py
@@ -23,8 +23,6 @@ def _gen_addons_readme(org, repo, branch, dry_run):
     _logger.info("oca-gen-addon-readme in %s/%s@%s", org, repo, branch)
     gen_addon_readme_cmd = [
         "oca-gen-addon-readme",
-        "--org-name",
-        org,
         "--repo-name",
         repo,
         "--branch",
@@ -52,7 +50,7 @@ def _setuptools_odoo_make_default(org, repo, branch, dry_run):
         "--addons-dir",
         ".",
         "--metapackage",
-        org.lower() + "-" + repo,
+        "oca-" + repo,
         "--clean",
     ]
     if not dry_run:
diff --git a/src/oca_github_bot/tasks/merge_bot.py b/src/oca_github_bot/tasks/merge_bot.py
new file mode 100644
index 0000000..692e336
--- /dev/null
+++ b/src/oca_github_bot/tasks/merge_bot.py
@@ -0,0 +1,112 @@
+# Copyright (c) ACSONE SA/NV 2019
+# Distributed under the MIT License (http://opensource.org/licenses/MIT).
+
+import subprocess
+
+from .. import github
+from ..manifest import bump_manifest_version, git_modified_addons
+from ..queue import getLogger, task
+from ..version_branch import make_merge_bot_branch, parse_merge_bot_branch
+from .main_branch_bot import main_branch_bot_actions
+
+_logger = getLogger(__name__)
+
+
+def _git_call(cmd):
+    subprocess.check_output(cmd, universal_newlines=True, stderr=subprocess.STDOUT)
+
+
+def _merge_bot_merge_pr(org, repo, merge_bot_branch):
+    pr, target_branch, username = parse_merge_bot_branch(merge_bot_branch)
+    _git_call(["git", "checkout", target_branch])
+    # TODO what if is there is a merge conflict anyway here?
+    # TODO what if something else was merged since the bot branch was created
+    msg = f"Merge PR #{pr} into {target_branch}\n\nSigned-off-by {username}"
+    _git_call(["git", "merge", "--no-ff", "--m", msg, merge_bot_branch])
+    _git_call(["git", "push", "origin", target_branch])
+    _git_call(["git", "push", "origin", f":{merge_bot_branch}"])
+    with github.login() as gh:
+        gh_pr = gh.pull_request(org, repo, pr)
+        merge_sha = github.git_get_head_sha()
+        github.gh_call(gh_pr.create_comment, f"Merged at {merge_sha}.")
+        github.gh_call(gh_pr.close)
+
+
+@task()
+def merge_bot_start(org, repo, pr, username, bumpversion=None, dry_run=False):
+    with github.login() as gh:
+        if not github.git_user_can_push(gh.repository(org, repo), username):
+            gh_pr = gh.pull_request(org, repo, pr)
+            github.gh_call(
+                gh_pr.create_comment,
+                f"Sorry @{username} "
+                f"you do not have push permission, so I can't merge for you.",
+            )
+            return
+        gh_pr = gh.pull_request(org, repo, pr)
+        target_branch = gh_pr.base.ref
+        try:
+            with github.temporary_clone(org, repo, target_branch):
+                # create merge bot branch from PR and rebase it on target branch
+                merge_bot_branch = make_merge_bot_branch(pr, target_branch, username)
+                _git_call(
+                    ["git", "fetch", "origin", f"pull/{pr}/head:{merge_bot_branch}"]
+                )
+                _git_call(["git", "checkout", merge_bot_branch])
+                pr_sha = github.git_get_head_sha()
+                _git_call(["git", "rebase", "--autosquash", target_branch])
+                # run main branch bot actions
+                main_branch_bot_actions(org, repo, target_branch, dry_run)
+                if bumpversion:
+                    for addon in git_modified_addons(".", target_branch):
+                        bump_manifest_version(addon, bumpversion, git_commit=True)
+                if pr_sha != github.git_get_head_sha():
+                    # push and let tests run again
+                    _git_call(["git", "push", "--force", "origin", merge_bot_branch])
+                else:
+                    # nothing changed, no need to retest, merge now
+                    # TODO check status instead of obeying order blindly?
+                    _merge_bot_merge_pr(org, repo, merge_bot_branch)
+        except subprocess.CalledProcessError as e:
+            cmd = " ".join(e.cmd)
+            github.gh_call(
+                gh_pr.create_comment,
+                f"Command `{cmd}` failed with output:\n```\n{e.output}\n```",
+            )
+            raise
+        else:
+            github.gh_call(
+                gh_pr.create_comment,
+                f"Rebased to `{merge_bot_branch}`, awaiting test results.",
+            )
+            raise
+
+
+@task()
+def merge_bot_status_failure(org, repo, merge_bot_branch, sha):
+    with github.temporary_clone(org, repo, merge_bot_branch):
+        head_sha = github.git_get_head_sha()
+        if head_sha != sha:
+            # the branch has evolved, this means that this status
+            # does not correspond to the last commit of the bot, ignore it
+            return
+        pr, target_branch, _ = parse_merge_bot_branch(merge_bot_branch)
+        with github.login() as gh:
+            gh_pr = gh.pull_request(org, repo, pr)
+            github.gh_call(
+                gh_pr.create_comment,
+                f"Merge command aborted due to a red status `{merge_bot_branch}`.",
+            )
+            # TODO add link to failure status in message?
+            # TODO delete merge_bot_branch?
+
+
+@task()
+def merge_bot_status_success(org, repo, merge_bot_branch, sha):
+    with github.temporary_clone(org, repo, merge_bot_branch):
+        head_sha = github.git_get_head_sha()
+        if head_sha != sha:
+            # the branch has evolved, this means that this status
+            # does not correspond to the last commit of the bot, ignore it
+            return
+        _merge_bot_merge_pr(org, repo, merge_bot_branch)
diff --git a/src/oca_github_bot/version_branch.py b/src/oca_github_bot/version_branch.py
index 32007e0..9695292 100644
--- a/src/oca_github_bot/version_branch.py
+++ b/src/oca_github_bot/version_branch.py
@@ -1,9 +1,12 @@
-# Copyright (c) ACSONE SA/NV 2018
+# Copyright (c) ACSONE SA/NV 2018-2019
 # Distributed under the MIT License (http://opensource.org/licenses/MIT).
 
 import re
 
 ODOO_VERSION_RE = re.compile(r"^(?P<major>\d+)\.(?P<minor>\d+)$")
+MERGE_BOT_BRANCH_RE = re.compile(
+    r"^ocabot-merge-pr-(?P<pr>\d+)-to-(?P<target_branch>\d+\.\d+)-by-(?P<username>.+)$"
+)
 
 
 def is_main_branch_bot_branch(branch_name):
@@ -17,3 +20,16 @@ def is_protected_branch(branch_name):
     if branch_name == "master":
         return True
     return bool(ODOO_VERSION_RE.match(branch_name))
+
+
+def is_merge_bot_branch(branch):
+    return bool(MERGE_BOT_BRANCH_RE.match(branch))
+
+
+def parse_merge_bot_branch(branch):
+    mo = MERGE_BOT_BRANCH_RE.match(branch)
+    return mo.group("pr"), mo.group("target_branch"), mo.group("username")
+
+
+def make_merge_bot_branch(pr, target_branch, username):
+    return f"ocabot-merge-pr-{pr}-to-{target_branch}-by-{username}"
diff --git a/src/oca_github_bot/webhooks/__init__.py b/src/oca_github_bot/webhooks/__init__.py
index 8be89c7..ba58153 100644
--- a/src/oca_github_bot/webhooks/__init__.py
+++ b/src/oca_github_bot/webhooks/__init__.py
@@ -1,6 +1,8 @@
 # Copyright (c) ACSONE SA/NV 2018
 # Distributed under the MIT License (http://opensource.org/licenses/MIT).
 
+from . import on_check_suite
+from . import on_command
 from . import on_pr_close_delete_branch
 from . import on_pr_open_label_new_contributor
 from . import on_pr_review
diff --git a/src/oca_github_bot/webhooks/on_check_suite.py b/src/oca_github_bot/webhooks/on_check_suite.py
new file mode 100644
index 0000000..3ad33c7
--- /dev/null
+++ b/src/oca_github_bot/webhooks/on_check_suite.py
@@ -0,0 +1,21 @@
+# Copyright (c) initOS GmbH 2019
+# Distributed under the MIT License (http://opensource.org/licenses/MIT).
+
+from ..router import router
+from ..tasks.merge_bot import merge_bot_status_failure, merge_bot_status_success
+from ..version_branch import is_merge_bot_branch
+
+
+@router.register("check_suite", action="completed")
+async def on_check_suite(event, gh, *args, **kwargs):
+    org, repo = event.data["repository"]["full_name"].split("/")
+    branch = event.data["check_suite"]["head_branch"]
+    sha = event.data["check_suite"]["head_sha"]
+    status = event.data["check_suite"]["status"]
+    conclusion = event.data["check_suite"]["conclusion"]
+
+    if is_merge_bot_branch(branch) and status == "completed":
+        if conclusion == "success":
+            merge_bot_status_success.delay(org, repo, branch, sha)
+        else:
+            merge_bot_status_failure.delay(org, repo, branch, sha)
diff --git a/src/oca_github_bot/webhooks/on_command.py b/src/oca_github_bot/webhooks/on_command.py
new file mode 100644
index 0000000..e11e0eb
--- /dev/null
+++ b/src/oca_github_bot/webhooks/on_command.py
@@ -0,0 +1,20 @@
+# Copyright (c) initOS GmbH 2019
+# Distributed under the MIT License (http://opensource.org/licenses/MIT).
+
+from ..commands import parse_commands
+from ..router import router
+
+
+@router.register("issue_comment", action="created")
+async def on_command(event, gh, *args, **kwargs):
+    """On pull request review, tag if approved or ready to merge."""
+    if not event.data["issue"].get("pull_request"):
+        # ignore issue comments
+        return
+    org, repo = event.data["repository"]["full_name"].split("/")
+    pr = event.data["issue"]["number"]
+    username = event.data["comment"]["user"]["login"]
+    text = event.data["comment"]["body"]
+
+    for command in parse_commands(text):
+        command.delay(org, repo, pr, username)


Test patch:
diff --git a/tests/test_commands.py b/tests/test_commands.py
new file mode 100644
index 0000000..9886036
--- /dev/null
+++ b/tests/test_commands.py
@@ -0,0 +1,50 @@
+# Copyright (c) ACSONE SA/NV 2018
+# Distributed under the MIT License (http://opensource.org/licenses/MIT).
+
+import pytest
+from oca_github_bot.commands import (
+    InvalidCommandError,
+    InvalidOptionsError,
+    parse_commands,
+)
+
+
+def test_parse_command_not_a_command():
+    with pytest.raises(InvalidCommandError):
+        list(parse_commands("/ocabot not_a_command"))
+
+
+def test_parse_command_multi():
+    cmds = list(
+        parse_commands(
+            """
+                ...
+                /ocabot merge major
+                /ocabot   merge   patch
+                /ocabot merge patch
+                ...
+            """
+        )
+    )
+    assert len(cmds) == 3
+
+
+def test_parse_command_merge():
+    cmds = list(parse_commands("/ocabot merge major"))
+    assert len(cmds) == 1
+    assert cmds[0].name == "merge"
+    assert cmds[0].bumpversion == "major"
+    cmds = list(parse_commands("/ocabot merge minor"))
+    assert len(cmds) == 1
+    assert cmds[0].name == "merge"
+    assert cmds[0].bumpversion == "minor"
+    cmds = list(parse_commands("/ocabot merge patch"))
+    assert len(cmds) == 1
+    assert cmds[0].name == "merge"
+    assert cmds[0].bumpversion == "patch"
+    cmds = list(parse_commands("/ocabot merge"))
+    assert len(cmds) == 1
+    assert cmds[0].name == "merge"
+    assert cmds[0].bumpversion is None
+    with pytest.raises(InvalidOptionsError):
+        list(parse_commands("/ocabot merge brol"))
diff --git a/tests/test_git_get_head_sha.py b/tests/test_git_get_head_sha.py
new file mode 100644
index 0000000..bbd58d4
--- /dev/null
+++ b/tests/test_git_get_head_sha.py
@@ -0,0 +1,11 @@
+# Copyright (c) ACSONE SA/NV 2018
+# Distributed under the MIT License (http://opensource.org/licenses/MIT).
+
+import re
+
+from oca_github_bot.github import git_get_head_sha
+
+
+def test_git_get_head_sha(git_clone):
+    sha = git_get_head_sha()
+    assert re.match("^[a-f0-9]{40}$", sha)
diff --git a/tests/test_manifest.py b/tests/test_manifest.py
index 27807cf..5544c41 100644
--- a/tests/test_manifest.py
+++ b/tests/test_manifest.py
@@ -103,6 +103,11 @@ def tests_git_modified_addons(git_clone):
     (setup_dir / "setup.py").write_text("")
     subprocess.check_call(["git", "add", "setup"], cwd=git_clone)
     subprocess.check_call(["git", "commit", "-m", "add addon setup"], cwd=git_clone)
+    assert not git_modified_addons(git_clone, "origin/master")
+    (setup_dir / "odoo" / "addons").mkdir(parents=True)
+    (setup_dir / "odoo" / "addons" / "addon").symlink_to("../../../../addon")
+    subprocess.check_call(["git", "add", "setup"], cwd=git_clone)
+    subprocess.check_call(["git", "commit", "-m", "add addon setup"], cwd=git_clone)
     assert git_modified_addons(git_clone, "origin/master") == {"addon"}
     # add a second addon, and change the first one
     addon2_dir = git_clone / "addon2"
diff --git a/tests/test_on_check_suite.py b/tests/test_on_check_suite.py
new file mode 100644
index 0000000..52f2dac
--- /dev/null
+++ b/tests/test_on_check_suite.py
@@ -0,0 +1,92 @@
+# Copyright (c) initOS GmbH 2019
+# Distributed under the MIT License (http://opensource.org/licenses/MIT).
+
+import pytest
+from oca_github_bot.version_branch import make_merge_bot_branch
+from oca_github_bot.webhooks import on_check_suite
+
+from .common import EventMock
+
+
+def create_test_data(branch, status, conclusion, sha=None):
+    return {
+        "repository": {"full_name": "OCA/some-repo"},
+        "check_suite": {
+            "head_branch": branch,
+            "head_sha": sha if sha else "0" * 40,
+            "status": status,
+            "conclusion": conclusion,
+        },
+    }
+
+
+def mock_merge_bot_functions(mocker):
+    mocker.patch(
+        "oca_github_bot.webhooks.on_check_suite.merge_bot_status_success.delay"
+    )
+    mocker.patch(
+        "oca_github_bot.webhooks.on_check_suite.merge_bot_status_failure.delay"
+    )
+
+
+@pytest.mark.asyncio
+async def test_on_check_suite_invalid_branch(mocker):
+    mock_merge_bot_functions(mocker)
+
+    # Completed
+    data = create_test_data("changes", "completed", "success")
+    event = EventMock(data)
+    await on_check_suite.on_check_suite(event, None)
+    on_check_suite.merge_bot_status_success.delay.assert_not_called()
+    on_check_suite.merge_bot_status_failure.delay.assert_not_called()
+
+    # Running
+    data = create_test_data("changes", "in_progress", None)
+    event = EventMock(data)
+    await on_check_suite.on_check_suite(event, None)
+    on_check_suite.merge_bot_status_success.delay.assert_not_called()
+    on_check_suite.merge_bot_status_failure.delay.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_on_check_suite_valid_branch_success(mocker):
+    mock_merge_bot_functions(mocker)
+
+    branch = make_merge_bot_branch(42, "12.0")
+    sha = "1" * 40
+    # Completed
+    data = create_test_data(branch, "completed", "success", sha)
+    event = EventMock(data)
+    await on_check_suite.on_check_suite(event, None)
+    on_check_suite.merge_bot_status_success.delay.assert_called_once_with(
+        "OCA", "some-repo", branch, sha
+    )
+    on_check_suite.merge_bot_status_failure.delay.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_on_check_suite_valid_branch_failure(mocker):
+    mock_merge_bot_functions(mocker)
+
+    branch = make_merge_bot_branch(42, "12.0")
+    sha = "1" * 40
+    data = create_test_data(branch, "completed", "failure", sha)
+    event = EventMock(data)
+    await on_check_suite.on_check_suite(event, None)
+    on_check_suite.merge_bot_status_success.delay.assert_not_called()
+    on_check_suite.merge_bot_status_failure.delay.assert_called_once_with(
+        "OCA", "some-repo", branch, sha
+    )
+
+
+@pytest.mark.asyncio
+async def test_on_check_suite_valid_branch_in_progress(mocker):
+    mock_merge_bot_functions(mocker)
+
+    branch = make_merge_bot_branch(42, "12.0")
+    sha = "1" * 40
+    data = create_test_data(branch, "in_progress", "success", sha)
+    event = EventMock(data)
+    await on_check_suite.on_check_suite(event, None)
+    on_check_suite.merge_bot_status_success.delay.assert_not_called()
+    on_check_suite.merge_bot_status_failure.delay.assert_not_called()
diff --git a/tests/test_on_command.py b/tests/test_on_command.py
new file mode 100644
index 0000000..2a0e0d1
--- /dev/null
+++ b/tests/test_on_command.py
@@ -0,0 +1,42 @@
+# Copyright (c) initOS GmbH 2019
+# Distributed under the MIT License (http://opensource.org/licenses/MIT).
+
+import pytest
+from oca_github_bot.webhooks import on_command
+
+from .common import EventMock
+
+
+def create_test_data(pr, user, body):
+    return {
+        "repository": {"full_name": "OCA/some-repo"},
+        "issue": {"number": pr if pr else None, "pull_request": bool(pr)},
+        "comment": {"body": body, "user": {"login": user}},
+    }
+
+
+@pytest.mark.asyncio
+async def test_on_command_no_pr(mocker):
+    mocker.patch("oca_github_bot.webhooks.on_command.parse_commands")
+
+    data = create_test_data(False, "test-user", "/ocabot merge")
+    event = EventMock(data)
+    await on_command.on_command(event, None)
+    on_command.parse_commands.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_on_command_valid_pr(mocker):
+    cmd_mock = mocker.Mock()
+    mocker.patch("oca_github_bot.webhooks.on_command.parse_commands").return_value = [
+        cmd_mock
+    ]
+
+    body = "test_message"
+
+    # Completed
+    data = create_test_data(42, "test-user", body)
+    event = EventMock(data)
+    await on_command.on_command(event, None)
+    on_command.parse_commands.assert_called_once_with(body)
+    cmd_mock.delay.assert_called_once_with("OCA", "some-repo", 42, "test-user")
diff --git a/tests/test_version_branch.py b/tests/test_version_branch.py
index 08fd19a..6337394 100644
--- a/tests/test_version_branch.py
+++ b/tests/test_version_branch.py
@@ -1,7 +1,13 @@
 # Copyright (c) ACSONE SA/NV 2018
 # Distributed under the MIT License (http://opensource.org/licenses/MIT).
 
-from oca_github_bot.version_branch import is_main_branch_bot_branch, is_protected_branch
+from oca_github_bot.version_branch import (
+    is_main_branch_bot_branch,
+    is_merge_bot_branch,
+    is_protected_branch,
+    make_merge_bot_branch,
+    parse_merge_bot_branch,
+)
 
 
 def test_is_main_branch_bot_branch():
@@ -17,3 +23,24 @@ def test_is_protected_branch():
     assert is_protected_branch("6.0")
     assert is_protected_branch("12.0")
     assert not is_protected_branch("10.0-something")
+
+
+def test_is_merge_bot_branch():
+    assert is_merge_bot_branch("ocabot-merge-pr-100-to-12.0-by-toto")
+    assert not is_merge_bot_branch("cabot-merge-pr-a100-to-12.0-by-titi")
+    assert not is_merge_bot_branch("ocabot-merge-pr-100-to-something-by-toto")
+
+
+def test_make_merge_bot_branch():
+    assert (
+        make_merge_bot_branch("100", "12.0", "toto")
+        == "ocabot-merge-pr-100-to-12.0-by-toto"
+    )
+
+
+def test_parse_merge_bot_branch():
+    assert parse_merge_bot_branch("ocabot-merge-pr-100-to-12.0-by-toto") == (
+        "100",
+        "12.0",
+        "toto",
+    )