Repository: repobee/repobee

Issue:
Implement verify-settings for GitLab
Requires some re-writing in the cli module, as well as implementing the required function in the GitLabAPI class.

Discussion:

Patch:
diff --git a/README.md b/README.md
index 2ac5c47..7704eac 100644
--- a/README.md
+++ b/README.md
@@ -142,11 +142,11 @@ plugin system**.
 There is still a lot in store for RepoBee. Below is a roadmap for major
 features that are in the works.
 
-| Feature                 | Status                                                                                      | ETA         |
-| -------                 | ------                                                                                      | ---         |
-| Full support for GitLab | Work in progress ([See docs](https://repobee.readthedocs.io/en/latest/gitlab.html#roadmap)) | Late 2019   |
-| Video tutorials         | Planning                                                                                    | August 2019 |
-| GUI                     | Planning                                                                                    | Late 2019   |
+| Feature                 | Status                                                                                      | ETA            |
+| -------                 | ------                                                                                      | ---            |
+| Full support for GitLab | Work in progress ([See docs](https://repobee.readthedocs.io/en/latest/gitlab.html#roadmap)) | September 2019 |
+| Video tutorials         | Work in progress                                                                            | September 2019 |
+| GUI                     | Planning                                                                                    | Late 2019      |
 
 ## License
 This software is licensed under the MIT License. See the [LICENSE](LICENSE)
diff --git a/docs/gitlab.rst b/docs/gitlab.rst
index c5aa109..71c016b 100644
--- a/docs/gitlab.rst
+++ b/docs/gitlab.rst
@@ -38,7 +38,7 @@ list-issues          Done           v1.6.0
 assign-reviews       Not started    Late 2019
 end-reviews          Not started    Late 2019
 check-reviews        Not started    Late 2019
-verify-settings      Not started    August 2019
+verify-settings      Not started    v2.3.0
 ===================  =============  ============
 
 GitLab terminology
diff --git a/src/_repobee/cli.py b/src/_repobee/cli.py
index e155c59..fbb2143 100644
--- a/src/_repobee/cli.py
+++ b/src/_repobee/cli.py
@@ -172,6 +172,7 @@ def parse_args(
 
     _validate_tls_url(args.base_url)
 
+    user = args.user if "user" in args else None
     if subparser == VERIFY_PARSER:
         # quick parse for verify connection
         return (
@@ -179,7 +180,7 @@ def parse_args(
                 subparser=VERIFY_PARSER,
                 org_name=args.org_name,
                 base_url=args.base_url,
-                user=args.user,
+                user=user,
                 traceback=args.traceback,
                 master_org_name=args.master_org_name
                 if "master_org_name" in args
@@ -192,12 +193,7 @@ def parse_args(
         # only if clone is chosen should plugins be able to hook in
         plug.manager.hook.parse_args(args=args)
 
-    api = _connect_to_api(
-        args.base_url,
-        args.token,
-        args.org_name,
-        args.user if "user" in args else None,
-    )
+    api = _connect_to_api(args.base_url, args.token, args.org_name, user)
 
     master_org_name = args.org_name
     if "master_org_name" in args and args.master_org_name is not None:
diff --git a/src/_repobee/ext/gitlab.py b/src/_repobee/ext/gitlab.py
index 4742895..8b51600 100644
--- a/src/_repobee/ext/gitlab.py
+++ b/src/_repobee/ext/gitlab.py
@@ -19,6 +19,7 @@ from typing import List, Iterable, Optional, Generator, Tuple
 
 import daiquiri
 import gitlab
+import requests.exceptions
 
 import repobee_plug as plug
 
@@ -57,6 +58,14 @@ def _convert_404_to_not_found_error(msg):
         )
 
 
+@contextlib.contextmanager
+def _convert_error(expected, conversion, msg):
+    try:
+        yield
+    except expected as exc:
+        raise conversion(msg) from exc
+
+
 @contextlib.contextmanager
 def _try_api_request(ignore_statuses: Optional[Iterable[int]] = None):
     """Context manager for trying API requests.
@@ -91,12 +100,9 @@ class GitLabAPI(plug.API):
 
     def __init__(self, base_url, token, org_name):
         # ssl turns off only for
-        ssl_verify = not os.getenv("REPOBEE_NO_VERIFY_SSL") == "true"
-        if not ssl_verify:
-            LOGGER.warning("SSL verification turned off, only for testing")
         self._user = "oauth2"
         self._gitlab = gitlab.Gitlab(
-            base_url, private_token=token, ssl_verify=ssl_verify
+            base_url, private_token=token, ssl_verify=self._ssl_verify()
         )
         self._group_name = org_name
         self._token = token
@@ -105,6 +111,13 @@ class GitLabAPI(plug.API):
         with _try_api_request():
             self._group = self._get_organization(self._group_name)
 
+    @staticmethod
+    def _ssl_verify():
+        ssl_verify = not os.getenv("REPOBEE_NO_VERIFY_SSL") == "true"
+        if not ssl_verify:
+            LOGGER.warning("SSL verification turned off, only for testing")
+        return ssl_verify
+
     def _get_teams_in(self, team_names: Iterable[str]):
         """Get all teams that match any team name in the team_names iterable.
 
@@ -447,6 +460,88 @@ class GitLabAPI(plug.API):
             )
             yield from name_issues_pairs
 
+    @staticmethod
+    def verify_settings(
+        user: str,
+        org_name: str,
+        base_url: str,
+        token: str,
+        master_org_name: Optional[str] = None,
+    ):
+        """See :py:meth:`repobee_plug.apimeta.APISpec.verify_settings`."""
+        LOGGER.info("GitLabAPI is verifying settings ...")
+        if not token:
+            raise exception.BadCredentials(
+                msg="Token is empty. Check that REPOBEE_TOKEN environment "
+                "variable is properly set, or supply the `--token` option."
+            )
+
+        gl = gitlab.Gitlab(
+            base_url, private_token=token, ssl_verify=GitLabAPI._ssl_verify()
+        )
+
+        LOGGER.info("Authenticating connection to {}...".format(base_url))
+        with _convert_error(
+            gitlab.exceptions.GitlabAuthenticationError,
+            exception.BadCredentials,
+            "Could not authenticate token",
+        ), _convert_error(
+            requests.exceptions.ConnectionError,
+            exception.APIError,
+            "Could not connect to {}, please check the URL".format(base_url),
+        ):
+            gl.auth()
+        LOGGER.info(
+            "SUCCESS: Authenticated as {} at {}".format(
+                gl.user.username, base_url
+            )
+        )
+
+        GitLabAPI._verify_group(org_name, gl)
+        if master_org_name:
+            GitLabAPI._verify_group(master_org_name, gl)
+
+        LOGGER.info("GREAT SUCCESS: All settings check out!")
+
+    @staticmethod
+    def _verify_group(group_name: str, gl: gitlab.Gitlab) -> None:
+        """Check that the group exists and that the user is an owner."""
+        user = gl.user.username
+
+        LOGGER.info("Trying to fetch group {}".format(group_name))
+        slug_matched = [
+            group
+            for group in gl.groups.list(search=group_name)
+            if group.path == group_name
+        ]
+        if not slug_matched:
+            raise exception.NotFoundError(
+                "Could not find group with slug {}. Verify that you have "
+                "access to the group, and that you've provided the slug "
+                "(the name in the address bar).".format(group_name)
+            )
+        group = slug_matched[0]
+        LOGGER.info("SUCCESS: Found group {}".format(group.name))
+
+        LOGGER.info(
+            "Verifying that user {} is an owner of group {}".format(
+                user, group_name
+            )
+        )
+        matching_members = [
+            member
+            for member in group.members.list()
+            if member.username == user
+            and member.access_level == gitlab.OWNER_ACCESS
+        ]
+        if not matching_members:
+            raise exception.BadCredentials(
+                "User {} is not an owner of {}".format(user, group_name)
+            )
+        LOGGER.info(
+            "SUCCESS: User {} is an owner of group {}".format(user, group_name)
+        )
+
 
 class GitLabAPIHook(plug.Plugin):
     def api_init_requires(self):


Test patch:
diff --git a/tests/unit_tests/plugin_tests/test_gitlab.py b/tests/unit_tests/plugin_tests/test_gitlab.py
index 4ebfed9..3fb1c4b 100644
--- a/tests/unit_tests/plugin_tests/test_gitlab.py
+++ b/tests/unit_tests/plugin_tests/test_gitlab.py
@@ -1,5 +1,6 @@
 from collections import namedtuple
 
+import requests.exceptions
 import pytest
 import gitlab
 import repobee_plug as plug
@@ -25,11 +26,11 @@ class Group:
             create=self._create_member, list=self._list_members
         )
         self._member_list = []
-        self._users_read_only = users_read_only
+        self._users_read_only = users_read_only or {}
         # the group owner is always added to the group with max access level
         owner_id = [
             uid
-            for uid, user in users_read_only.items()
+            for uid, user in self._users_read_only.items()
             if user.username == constants.USER
         ][0]
         owner_data = {"user_id": owner_id, "access_level": gitlab.OWNER_ACCESS}
@@ -72,6 +73,9 @@ class Project:
         self.attributes = dict(http_url_to_repo=http_url)
 
 
+User = namedtuple("User", ("id", "username"))
+
+
 class GitLabMock:
     """Class representing a GitLab instance, with the subset of functionality
     required for RepoBee to work. It may seem counterintuitive to create this
@@ -85,14 +89,18 @@ class GitLabMock:
 
     _Groups = namedtuple("_Groups", ("create", "list", "get"))
     _Users = namedtuple("_Users", ("list"))
-    _User = namedtuple("_User", ("id", "username"))
     _Projects = namedtuple("_Projects", "create get".split())
 
     def __init__(self, url, private_token, ssl_verify):
         self._users = {
-            id: self._User(id=id, username=str(grp))
+            id: User(id=id, username=str(grp))
             for id, grp in enumerate(constants.STUDENTS + (constants.USER,))
         }
+        self._owner, *_ = [
+            usr
+            for usr in self._users.values()
+            if usr.username == constants.USER
+        ]
         self._base_url = url
         self._private_token = private_token
         self._groups = {}
@@ -103,6 +111,18 @@ class GitLabMock:
         # this is only for testing purposes, does not exist in the real class
         self._target_group_id = list(self._groups.keys())[0]
 
+    def auth(self):
+        if self._base_url != BASE_URL:
+            raise requests.exceptions.ConnectionError("could not connect")
+        if self._private_token != TOKEN:
+            raise gitlab.exceptions.GitlabAuthenticationError(
+                "could not authenticate token"
+            )
+
+    @property
+    def user(self):
+        return self._owner
+
     @property
     def tests_only_target_group_id(self):
         return self._target_group_id
@@ -243,6 +263,7 @@ class GitLabMock:
 BASE_URL = "https://some-host.com"
 TOKEN = "3049fplktdufpdl23"
 TARGET_GROUP = "repobee-testing"
+MASTER_GROUP = "repobee-master"
 
 
 @pytest.fixture(autouse=True)
@@ -252,6 +273,11 @@ def api_mock(mocker):
     )
 
 
+@pytest.fixture
+def api(api_mock):
+    yield _repobee.ext.gitlab.GitLabAPI(BASE_URL, TOKEN, TARGET_GROUP)
+
+
 @pytest.fixture
 def team_names():
     return [str(s) for s in constants.STUDENTS]
@@ -483,10 +509,6 @@ class TestGetRepoUrls:
 
 
 class TestCreateRepos:
-    @pytest.fixture
-    def api(self, api_mock):
-        yield _repobee.ext.gitlab.GitLabAPI(BASE_URL, TOKEN, TARGET_GROUP)
-
     @pytest.fixture
     def repos(self, api, master_repo_names):
         """Setup repo tuples along with groups for the repos to be created
@@ -535,3 +557,86 @@ class TestCreateRepos:
         actual_urls = api.create_repos(repos)
 
         assert sorted(expected_urls) == sorted(actual_urls)
+
+
+class TestVerifySettings:
+    def test_raises_if_token_is_empty(self):
+        with pytest.raises(exception.BadCredentials):
+            _repobee.ext.gitlab.GitLabAPI.verify_settings(
+                user=None, org_name=TARGET_GROUP, base_url=BASE_URL, token=""
+            )
+
+    def test_raises_on_failed_connection(self):
+        with pytest.raises(exception.APIError) as exc_info:
+            _repobee.ext.gitlab.GitLabAPI.verify_settings(
+                user=None,
+                org_name=TARGET_GROUP,
+                base_url="https://garbage-url",
+                token=TOKEN,
+            )
+
+        assert "please check the URL" in str(exc_info.value)
+
+    def test_raises_on_bad_token(self):
+        with pytest.raises(exception.BadCredentials) as exc_info:
+            _repobee.ext.gitlab.GitLabAPI.verify_settings(
+                user=None,
+                org_name=TARGET_GROUP,
+                base_url=BASE_URL,
+                token="wrong-token",
+            )
+
+        assert "Could not authenticate token" in str(exc_info.value)
+
+    def test_raises_if_group_cant_be_found(self):
+        non_existing_group = "some-garbage-group"
+        with pytest.raises(exception.NotFoundError) as exc_info:
+            _repobee.ext.gitlab.GitLabAPI.verify_settings(
+                user=None,
+                org_name=non_existing_group,
+                base_url=BASE_URL,
+                token=TOKEN,
+            )
+
+        assert "Could not find group with slug {}".format(
+            non_existing_group
+        ) in str(exc_info.value)
+
+    def test_raises_if_master_group_cant_be_found(self):
+        non_existing_group = "some-garbage-group"
+        with pytest.raises(exception.NotFoundError) as exc_info:
+            _repobee.ext.gitlab.GitLabAPI.verify_settings(
+                user=None,
+                org_name=TARGET_GROUP,
+                base_url=BASE_URL,
+                token=TOKEN,
+                master_org_name=non_existing_group,
+            )
+
+        assert "Could not find group with slug {}".format(
+            non_existing_group
+        ) in str(exc_info.value)
+
+    def test_happy_path(self, mocker):
+        """Test that the great success message is printed if all is as it
+        should.
+        """
+        gl = GitLabMock(BASE_URL, TOKEN, False)
+        gl.groups.create(dict(name=MASTER_GROUP, path=MASTER_GROUP))
+        mocker.patch(
+            "_repobee.ext.gitlab.gitlab.Gitlab",
+            side_effect=lambda base_url, private_token, ssl_verify: gl,
+        )
+        log_mock = mocker.patch("_repobee.ext.gitlab.LOGGER")
+
+        _repobee.ext.gitlab.GitLabAPI.verify_settings(
+            user=None,
+            org_name=TARGET_GROUP,
+            base_url=BASE_URL,
+            token=TOKEN,
+            master_org_name=MASTER_GROUP,
+        )
+
+        log_mock.info.assert_called_with(
+            "GREAT SUCCESS: All settings check out!"
+        )