From 7734430f23a8c2472583543d4e4919aa37bf632f Mon Sep 17 00:00:00 2001
From: Werner Dijkerman <werner@dj-wasabi.nl>
Date: Sat, 17 Jul 2021 08:49:09 +0200
Subject: [PATCH] Added module for creating protected branches (#2781)

* Added module for creating protected branches

* Applied some changes due to comments and added a test that currently fails

* Changing no_access to nobody due to comment on PR

* Changing the description to clarify it a bit more

* Added working tests for module 'gitlab_protected_branch'

* Fixing lint issues

* Added doc that minimum of v2.3.0 is needed to work correctly

* Fixed the requirements notation

* Check the version of the module

* Hopefully fixed the tests by skipping it when lower version of 2.3.0 is installed

* Fix lint issues

* Applying changes due to comments in PR

* Remove commented code

* Removing the trailing dot ...

Co-authored-by: jenkins-x-bot <jenkins-x@googlegroups.com>
Co-authored-by: Werner Dijkerman <iam@werner-dijkerman.nl>
---
 plugins/modules/gitlab_protected_branch.py    |   1 +
 .../gitlab/gitlab_protected_branch.py         | 201 ++++++++++++++++++
 .../modules/source_control/gitlab/gitlab.py   |  38 +++-
 .../gitlab/test_gitlab_protected_branch.py    |  81 +++++++
 4 files changed, 319 insertions(+), 2 deletions(-)
 create mode 120000 plugins/modules/gitlab_protected_branch.py
 create mode 100644 plugins/modules/source_control/gitlab/gitlab_protected_branch.py
 create mode 100644 tests/unit/plugins/modules/source_control/gitlab/test_gitlab_protected_branch.py

diff --git a/plugins/modules/gitlab_protected_branch.py b/plugins/modules/gitlab_protected_branch.py
new file mode 120000
index 0000000000..7af5b500ce
--- /dev/null
+++ b/plugins/modules/gitlab_protected_branch.py
@@ -0,0 +1 @@
+source_control/gitlab/gitlab_protected_branch.py
\ No newline at end of file
diff --git a/plugins/modules/source_control/gitlab/gitlab_protected_branch.py b/plugins/modules/source_control/gitlab/gitlab_protected_branch.py
new file mode 100644
index 0000000000..f61f2b9fa1
--- /dev/null
+++ b/plugins/modules/source_control/gitlab/gitlab_protected_branch.py
@@ -0,0 +1,201 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+
+# Copyright: (c) 2021, Werner Dijkerman (ikben@werner-dijkerman.nl)
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+from __future__ import absolute_import, division, print_function
+__metaclass__ = type
+
+DOCUMENTATION = '''
+module: gitlab_protected_branch
+short_description: (un)Marking existing branches for protection
+version_added: 3.4.0
+description:
+  - (un)Marking existing branches for protection.
+author:
+  - "Werner Dijkerman (@dj-wasabi)"
+requirements:
+  - python >= 2.7
+  - python-gitlab >= 2.3.0
+extends_documentation_fragment:
+- community.general.auth_basic
+
+options:
+  state:
+    description:
+      - Create or delete proteced branch.
+    default: present
+    type: str
+    choices: ["present", "absent"]
+  api_token:
+    description:
+      - GitLab access token with API permissions.
+    required: true
+    type: str
+  project:
+    description:
+      - The path and name of the project.
+    required: true
+    type: str
+  name:
+    description:
+      - The name of the branch that needs to be protected.
+      - Can make use a wildcard charachter for like C(production/*) or just have C(main) or C(develop) as value.
+    required: true
+    type: str
+  merge_access_levels:
+    description:
+      - Access levels allowed to merge.
+    default: maintainer
+    type: str
+    choices: ["maintainer", "developer", "nobody"]
+  push_access_level:
+    description:
+      - Access levels allowed to push.
+    default: maintainer
+    type: str
+    choices: ["maintainer", "developer", "nobody"]
+'''
+
+
+EXAMPLES = '''
+- name: Create protected branch on main
+  community.general.gitlab_protected_branch:
+    api_url: https://gitlab.com
+    api_token: secret_access_token
+    project: "dj-wasabi/collection.general"
+    name: main
+    merge_access_levels: maintainer
+    push_access_level: nobody
+
+'''
+
+RETURN = '''
+'''
+
+import traceback
+
+from ansible.module_utils.basic import AnsibleModule, missing_required_lib
+from ansible.module_utils.api import basic_auth_argument_spec
+from distutils.version import LooseVersion
+
+GITLAB_IMP_ERR = None
+try:
+    import gitlab
+    HAS_GITLAB_PACKAGE = True
+except Exception:
+    GITLAB_IMP_ERR = traceback.format_exc()
+    HAS_GITLAB_PACKAGE = False
+
+from ansible_collections.community.general.plugins.module_utils.gitlab import gitlabAuthentication
+
+
+class GitlabProtectedBranch(object):
+
+    def __init__(self, module, project, gitlab_instance):
+        self.repo = gitlab_instance
+        self._module = module
+        self.project = self.get_project(project)
+        self.ACCESS_LEVEL = {
+            'nobody': gitlab.NO_ACCESS,
+            'developer': gitlab.DEVELOPER_ACCESS,
+            'maintainer': gitlab.MAINTAINER_ACCESS
+        }
+
+    def get_project(self, project_name):
+        return self.repo.projects.get(project_name)
+
+    def protected_branch_exist(self, name):
+        try:
+            return self.project.protectedbranches.get(name)
+        except Exception as e:
+            return False
+
+    def create_protected_branch(self, name, merge_access_levels, push_access_level):
+        if self._module.check_mode:
+            return True
+        merge = self.ACCESS_LEVEL[merge_access_levels]
+        push = self.ACCESS_LEVEL[push_access_level]
+        self.project.protectedbranches.create({
+            'name': name,
+            'merge_access_level': merge,
+            'push_access_level': push
+        })
+
+    def compare_protected_branch(self, name, merge_access_levels, push_access_level):
+        configured_merge = self.ACCESS_LEVEL[merge_access_levels]
+        configured_push = self.ACCESS_LEVEL[push_access_level]
+        current = self.protected_branch_exist(name=name)
+        current_merge = current.merge_access_levels[0]['access_level']
+        current_push = current.push_access_levels[0]['access_level']
+        if current:
+            if current.name == name and current_merge == configured_merge and current_push == configured_push:
+                return True
+        return False
+
+    def delete_protected_branch(self, name):
+        if self._module.check_mode:
+            return True
+        return self.project.protectedbranches.delete(name)
+
+
+def main():
+    argument_spec = basic_auth_argument_spec()
+    argument_spec.update(
+        api_token=dict(type='str', required=True, no_log=True),
+        project=dict(type='str', required=True),
+        name=dict(type='str', required=True),
+        merge_access_levels=dict(type='str', default="maintainer", choices=["maintainer", "developer", "nobody"]),
+        push_access_level=dict(type='str', default="maintainer", choices=["maintainer", "developer", "nobody"]),
+        state=dict(type='str', default="present", choices=["absent", "present"]),
+    )
+
+    module = AnsibleModule(
+        argument_spec=argument_spec,
+        mutually_exclusive=[
+            ['api_username', 'api_token'],
+            ['api_password', 'api_token'],
+        ],
+        required_together=[
+            ['api_username', 'api_password'],
+        ],
+        required_one_of=[
+            ['api_username', 'api_token']
+        ],
+        supports_check_mode=True
+    )
+
+    project = module.params['project']
+    name = module.params['name']
+    merge_access_levels = module.params['merge_access_levels']
+    push_access_level = module.params['push_access_level']
+    state = module.params['state']
+
+    if not HAS_GITLAB_PACKAGE:
+        module.fail_json(msg=missing_required_lib("python-gitlab"), exception=GITLAB_IMP_ERR)
+
+    gitlab_version = gitlab.__version__
+    if LooseVersion(gitlab_version) < LooseVersion('2.3.0'):
+        module.fail_json(msg="community.general.gitlab_proteched_branch requires python-gitlab Python module >= 2.3.0 (installed version: [%s])."
+                             " Please upgrade python-gitlab to version 2.3.0 or above." % gitlab_version)
+
+    gitlab_instance = gitlabAuthentication(module)
+    this_gitlab = GitlabProtectedBranch(module=module, project=project, gitlab_instance=gitlab_instance)
+
+    p_branch = this_gitlab.protected_branch_exist(name=name)
+    if not p_branch and state == "present":
+        this_gitlab.create_protected_branch(name=name, merge_access_levels=merge_access_levels, push_access_level=push_access_level)
+        module.exit_json(changed=True, msg="Created the proteched branch.")
+    elif p_branch and state == "present":
+        if not this_gitlab.compare_protected_branch(name, merge_access_levels, push_access_level):
+            this_gitlab.delete_protected_branch(name=name)
+            this_gitlab.create_protected_branch(name=name, merge_access_levels=merge_access_levels, push_access_level=push_access_level)
+            module.exit_json(changed=True, msg="Recreated the proteched branch.")
+    elif p_branch and state == "absent":
+        this_gitlab.delete_protected_branch(name=name)
+        module.exit_json(changed=True, msg="Deleted the proteched branch.")
+    module.exit_json(changed=False, msg="No changes are needed.")
+
+
+if __name__ == '__main__':
+    main()
diff --git a/tests/unit/plugins/modules/source_control/gitlab/gitlab.py b/tests/unit/plugins/modules/source_control/gitlab/gitlab.py
index eb8099d37b..5feff78b43 100644
--- a/tests/unit/plugins/modules/source_control/gitlab/gitlab.py
+++ b/tests/unit/plugins/modules/source_control/gitlab/gitlab.py
@@ -13,7 +13,7 @@ from httmock import urlmatch  # noqa
 
 from ansible_collections.community.general.tests.unit.compat import unittest
 
-from gitlab import Gitlab
+import gitlab
 
 
 class FakeAnsibleModule(object):
@@ -33,7 +33,7 @@ class GitlabModuleTestCase(unittest.TestCase):
 
         self.mock_module = FakeAnsibleModule()
 
-        self.gitlab_instance = Gitlab("http://localhost", private_token="private_token", api_version=4)
+        self.gitlab_instance = gitlab.Gitlab("http://localhost", private_token="private_token", api_version=4)
 
 
 # Python 2.7+ is needed for python-gitlab
@@ -45,6 +45,14 @@ def python_version_match_requirement():
     return sys.version_info >= GITLAB_MINIMUM_PYTHON_VERSION
 
 
+def python_gitlab_module_version():
+    return gitlab.__version__
+
+
+def python_gitlab_version_match_requirement():
+    return "2.3.0"
+
+
 # Skip unittest test case if python version don't match requirement
 def unitest_python_version_check_requirement(unittest_testcase):
     if not python_version_match_requirement():
@@ -467,6 +475,32 @@ def resp_delete_project(url, request):
     return response(204, content, headers, None, 5, request)
 
 
+@urlmatch(scheme="http", netloc="localhost", path="/api/v4/projects/1/protected_branches/master", method="get")
+def resp_get_protected_branch(url, request):
+    headers = {'content-type': 'application/json'}
+    content = ('{"id": 1, "name": "master", "push_access_levels": [{"access_level": 40, "access_level_description": "Maintainers"}],'
+               '"merge_access_levels": [{"access_level": 40, "access_level_description": "Maintainers"}],'
+               '"allow_force_push":false, "code_owner_approval_required": false}')
+    content = content.encode("utf-8")
+    return response(200, content, headers, None, 5, request)
+
+
+@urlmatch(scheme="http", netloc="localhost", path="/api/v4/projects/1/protected_branches/master", method="get")
+def resp_get_protected_branch_not_exist(url, request):
+    headers = {'content-type': 'application/json'}
+    content = ('')
+    content = content.encode("utf-8")
+    return response(404, content, headers, None, 5, request)
+
+
+@urlmatch(scheme="http", netloc="localhost", path="/api/v4/projects/1/protected_branches/master", method="delete")
+def resp_delete_protected_branch(url, request):
+    headers = {'content-type': 'application/json'}
+    content = ('')
+    content = content.encode("utf-8")
+    return response(204, content, headers, None, 5, request)
+
+
 '''
 HOOK API
 '''
diff --git a/tests/unit/plugins/modules/source_control/gitlab/test_gitlab_protected_branch.py b/tests/unit/plugins/modules/source_control/gitlab/test_gitlab_protected_branch.py
new file mode 100644
index 0000000000..026efb19d8
--- /dev/null
+++ b/tests/unit/plugins/modules/source_control/gitlab/test_gitlab_protected_branch.py
@@ -0,0 +1,81 @@
+# -*- coding: utf-8 -*-
+
+# Copyright: (c) 2019, Guillaume Martinez (lunik@tiwabbit.fr)
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+import pytest
+from distutils.version import LooseVersion
+
+from ansible_collections.community.general.plugins.modules.source_control.gitlab.gitlab_protected_branch import GitlabProtectedBranch
+
+
+def _dummy(x):
+    """Dummy function.  Only used as a placeholder for toplevel definitions when the test is going
+    to be skipped anyway"""
+    return x
+
+
+pytestmark = []
+try:
+    from .gitlab import (GitlabModuleTestCase,
+                         python_version_match_requirement, python_gitlab_module_version,
+                         python_gitlab_version_match_requirement,
+                         resp_get_protected_branch, resp_get_project_by_name,
+                         resp_get_protected_branch_not_exist,
+                         resp_delete_protected_branch, resp_get_user)
+
+    # GitLab module requirements
+    if python_version_match_requirement():
+        from gitlab.v4.objects import Project
+    gitlab_req_version = python_gitlab_version_match_requirement()
+    gitlab_module_version = python_gitlab_module_version()
+    if LooseVersion(gitlab_module_version) < LooseVersion(gitlab_req_version):
+        pytestmark.append(pytest.mark.skip("Could not load gitlab module required for testing (Wrong  version)"))
+except ImportError:
+    pytestmark.append(pytest.mark.skip("Could not load gitlab module required for testing"))
+
+# Unit tests requirements
+try:
+    from httmock import with_httmock  # noqa
+except ImportError:
+    pytestmark.append(pytest.mark.skip("Could not load httmock module required for testing"))
+    with_httmock = _dummy
+
+
+class TestGitlabProtectedBranch(GitlabModuleTestCase):
+    @with_httmock(resp_get_project_by_name)
+    @with_httmock(resp_get_user)
+    def setUp(self):
+        super(TestGitlabProtectedBranch, self).setUp()
+
+        self.gitlab_instance.user = self.gitlab_instance.users.get(1)
+        self.moduleUtil = GitlabProtectedBranch(module=self.mock_module, project="foo-bar/diaspora-client", gitlab_instance=self.gitlab_instance)
+
+    @with_httmock(resp_get_protected_branch)
+    def test_protected_branch_exist(self):
+        rvalue = self.moduleUtil.protected_branch_exist(name="master")
+        self.assertEqual(rvalue.name, "master")
+
+    @with_httmock(resp_get_protected_branch_not_exist)
+    def test_protected_branch_exist_not_exist(self):
+        rvalue = self.moduleUtil.protected_branch_exist(name="master")
+        self.assertEqual(rvalue, False)
+
+    @with_httmock(resp_get_protected_branch)
+    def test_compare_protected_branch(self):
+        rvalue = self.moduleUtil.compare_protected_branch(name="master", merge_access_levels="maintainer", push_access_level="maintainer")
+        self.assertEqual(rvalue, True)
+
+    @with_httmock(resp_get_protected_branch)
+    def test_compare_protected_branch_different_settings(self):
+        rvalue = self.moduleUtil.compare_protected_branch(name="master", merge_access_levels="developer", push_access_level="maintainer")
+        self.assertEqual(rvalue, False)
+
+    @with_httmock(resp_get_protected_branch)
+    @with_httmock(resp_delete_protected_branch)
+    def test_delete_protected_branch(self):
+        rvalue = self.moduleUtil.delete_protected_branch(name="master")
+        self.assertEqual(rvalue, None)