Repository: redkyn/assigner

Issue:
ERROR    list index out of range
Hello so I think I have pinpointed the source of the issue but I am not sure how to fix it.
The issue is when I run the command "assigner new hw1" it say "ERROR    list index out of range"
the index that is out of range is the namespace because it is empty which is because the json is empty. All of this is happening in the gitlab.py
`    def _cls_gl_get(cls, config, path, params={}):
        """Make a Gitlab GET request"""
        headers = {"Private-Token": config["token"]}
        url = urljoin(config["host"], "/api/v4" + path)
        r = requests.get(url, params=params, headers=headers)
        r.raise_for_status()
        return r.json()
`
the r.json() is an empty list. I don't think it is supposed to be either.
the URL is https://git-classes.mst.edu/api/v4/namespaces
the params is {'search': '2021-FS-CS3800'}
the header is {'Private-Token': token}
the token is an actual token so I won't be sharing it on here unless it is required.

Discussion:
LinuxMercedes: Looks like python 3.4 is no longer supported by Github Actions (ref. #164)

Patch:
diff --git a/assigner/__init__.py b/assigner/__init__.py
index 8155975..a1076e9 100755
--- a/assigner/__init__.py
+++ b/assigner/__init__.py
@@ -17,9 +17,9 @@ import sys
 
 from colorlog import ColoredFormatter
 from git.cmd import GitCommandNotFound
-from requests.exceptions import HTTPError
 
 from assigner.backends.decorators import requires_config_and_backend
+from assigner.exceptions import AssignerException
 from assigner.roster_util import get_filtered_roster
 from assigner import progress
 
@@ -85,16 +85,12 @@ def manage_repos(conf, backend, args, action):
         full_name = backend.student_repo.build_name(semester, student_section,
                                                     hw_name, username)
 
-        try:
-            repo = backend.student_repo(backend_conf, namespace, full_name)
-            if not dry_run:
-                if action(repo, student):
-                    count += 1
-            else:
+        repo = backend.student_repo(backend_conf, namespace, full_name)
+        if not dry_run:
+            if action(repo, student):
                 count += 1
-        except HTTPError:
-            logging.warning("Error processing %s", username)
-            raise
+        else:
+            count += 1
 
     print("Changed {} repositories.".format(count))
 
@@ -202,8 +198,11 @@ def main(args=sys.argv[1:]):
             logger.error("%s is missing", e)
         elif isinstance(e, GitCommandNotFound):
             logger.error("git is not installed!")
+        elif isinstance(e, AssignerException):
+            logger.error(str(e))
         else:
             logger.error(str(e))
+            logger.error("This is a bug. Please file an issue here: https://github.com/redkyn/assigner/issues/new")
         raise SystemExit(1) from e
 
 
diff --git a/assigner/backends/base.py b/assigner/backends/base.py
index ddcc527..07c3762 100644
--- a/assigner/backends/base.py
+++ b/assigner/backends/base.py
@@ -1,9 +1,10 @@
 import git
 import re
 from typing import Optional, Type, TypeVar, List, Any, Dict
+from assigner.exceptions import AssignerException
 
 
-class RepoError(Exception):
+class RepoError(AssignerException):
     pass
 
 
@@ -81,6 +82,10 @@ class RepoBase:
     def delete(self) -> None:
         raise NotImplementedError
 
+    @classmethod
+    def create_group(cls, group, config) -> None:
+        raise NotImplementedError
+
     @classmethod
     def get_user_id(cls, username: str, config) -> str:
         raise NotImplementedError
diff --git a/assigner/backends/exceptions.py b/assigner/backends/exceptions.py
index 299e6e6..6ee213e 100644
--- a/assigner/backends/exceptions.py
+++ b/assigner/backends/exceptions.py
@@ -1,10 +1,26 @@
 from assigner.exceptions import AssignerException
+from assigner.backends.base import RepoError
 
 class UserInAssignerGroup(AssignerException):
     """ A student is a member of the group/namespace assigner is creating assignments in. This may make some operatons, such as assigning homework, no-ops as they already have access. """
 
+class UserAlreadyAssigned(AssignerException):
+    """ A student is already a member of their homework repository. """
+
 class UserNotAssigned(AssignerException):
     """ A student is not a member of their homework repository. """
 
 class RetryableGitError(AssignerException):
     """ Git has encountered an error that may be spurious. """
+
+class AssignerGroupNotFound(AssignerException):
+    """ The group where assignments will be created does not exist. """
+
+class RepositoryAlreadyExists(AssignerException):
+    """ The repository has already been created. """
+
+class BranchNotFound(RepoError):
+    """ The branch cannot be found in the repository. """
+
+class CIArtifactNotFound(AssignerException):
+    """ CI Artifact not found. """
diff --git a/assigner/backends/gitlab.py b/assigner/backends/gitlab.py
index 0a9d15e..4a7d3cc 100644
--- a/assigner/backends/gitlab.py
+++ b/assigner/backends/gitlab.py
@@ -22,11 +22,19 @@ from assigner.backends.base import (
 
 from assigner.backends.gitlab_exceptions import (
     raiseUserInAssignerGroup,
+    raiseUserAlreadyAssigned,
     raiseUserNotAssigned,
+    raiseRepositoryAlreadyExists,
+    raiseCIArtifactNotFound,
 )
 
 from assigner.backends.git_exceptions import raiseRetryableGitError
-from assigner.backends.exceptions import RetryableGitError
+from assigner.backends.exceptions import (
+    AssignerGroupNotFound,
+    RetryableGitError,
+    BranchNotFound,
+)
+
 
 # Transparently use a common TLS session for each request
 requests = requests.Session()
@@ -237,14 +245,17 @@ class GitlabRepo(RepoBase):
 
             try:  # for exp. backoff
                 try:
+                    self._repo = git.Repo.clone_from(self.ssh_url, dir_name)
                     if branch:
-                        self._repo = git.Repo.clone_from(self.ssh_url, dir_name)
                         for b in branch:
-                            self._repo.create_head(b, "origin/{}".format(b))
+                            try:
+                                self._repo.create_head(b, "origin/{}".format(b))
+                            # pylint: disable=no-member
+                            except git.exc.BadName as e:
+                                raise BranchNotFound(b, e) from e
 
                         logging.debug(self._repo.heads)
-                    else:
-                        self._repo = git.Repo.clone_from(self.ssh_url, dir_name)
+
                     logging.debug("Cloned %s.", self.name)
                 # pylint: disable=no-member
                 except git.exc.GitCommandError as e:
@@ -346,6 +357,7 @@ class GitlabRepo(RepoBase):
             return self._gl_post("/projects/{}/members".format(self.id), payload)
         except HTTPError as e:
             raiseUserInAssignerGroup(e)
+            raiseUserAlreadyAssigned(e)
             raise e
 
     def edit_member(self, user_id, level):
@@ -403,10 +415,14 @@ class GitlabRepo(RepoBase):
 
     def get_ci_artifact(self, job_id, artifact_path):
         params = {"id": self.id, "job_id": job_id, "artifact_path": artifact_path}
-        return self._gl_get_raw(
-            "/projects/{}/jobs/{}/artifacts/{}".format(self.id, job_id, artifact_path),
-            params,
-        )
+        try:
+            return self._gl_get_raw(
+                "/projects/{}/jobs/{}/artifacts/{}".format(self.id, job_id, artifact_path),
+                params,
+            )
+        except HTTPError as e:
+            raiseCIArtifactNotFound(e)
+            raise e
 
     def list_pushes(self):
         return self._gl_get("/projects/{}/events?action=pushed".format(self.id))
@@ -497,6 +513,10 @@ class GitlabTemplateRepo(GitlabRepo, TemplateRepoBase):
     @classmethod
     def new(cls, name, namespace, config):
         namespaces = cls._cls_gl_get(config, "/namespaces", {"search": namespace})
+
+        if len(namespaces) == 0:
+            raise AssignerGroupNotFound("No groups matching {} were found on Gitlab".format(namespace))
+
         if len(namespaces) > 1:
             logging.warning(
                 "%s namespaces match %s; defaulting to namespace %s.",
@@ -524,7 +544,11 @@ class GitlabTemplateRepo(GitlabRepo, TemplateRepoBase):
             "visibility_level": Visibility.private.value,
         }
 
-        result = cls._cls_gl_post(config, "/projects", payload)
+        try:
+            result = cls._cls_gl_post(config, "/projects", payload)
+        except HTTPError as e:
+            raiseRepositoryAlreadyExists(e)
+            raise e
 
         return cls.from_url(result["http_url_to_repo"], config["token"])
 
@@ -551,7 +575,11 @@ class GitlabStudentRepo(GitlabRepo, StudentRepoBase):
             "visibility_level": Visibility.private.value,
         }
 
-        result = cls._cls_gl_post(base_repo.config, "/projects", payload)
+        try:
+            result = cls._cls_gl_post(base_repo.config, "/projects", payload)
+        except HTTPError as e:
+            raiseRepositoryAlreadyExists(e)
+            raise e
 
         return cls.from_url(result["http_url_to_repo"], base_repo.config["token"])
 
diff --git a/assigner/backends/gitlab_exceptions.py b/assigner/backends/gitlab_exceptions.py
index 9b665af..d8b76f9 100644
--- a/assigner/backends/gitlab_exceptions.py
+++ b/assigner/backends/gitlab_exceptions.py
@@ -1,8 +1,11 @@
 from requests.exceptions import HTTPError
 
 from assigner.backends.exceptions import (
+    RepositoryAlreadyExists,
     UserInAssignerGroup,
+    UserAlreadyAssigned,
     UserNotAssigned,
+    CIArtifactNotFound,
 )
 
 def raiseUserInAssignerGroup(err: HTTPError):
@@ -29,6 +32,19 @@ def raiseUserInAssignerGroup(err: HTTPError):
 
     raise UserInAssignerGroup(err)
 
+def raiseUserAlreadyAssigned(err: HTTPError):
+    """
+    Request url:
+        POST /api/v4/projects/{}/members
+
+    Expected response: HTTP 409
+    """
+
+    if err.response.status_code != 409:
+        return
+
+    raise UserAlreadyAssigned(err)
+
 def raiseUserNotAssigned(err: HTTPError):
     """
     Request url:
@@ -40,3 +56,26 @@ def raiseUserNotAssigned(err: HTTPError):
         return
 
     raise UserNotAssigned(err)
+
+def raiseRepositoryAlreadyExists(err: HTTPError):
+    """
+    Request url:
+        POST /api/v4/projects
+
+    Expected response: HTTP 400
+    """
+    if err.response.status_code != 400:
+        return
+
+    raise RepositoryAlreadyExists(err)
+
+def raiseCIArtifactNotFound(err: HTTPError):
+    """
+    Request url:
+        GET /projects/{}/jobs/{}/artifacts/{}
+    """
+
+    if err.response.status_code != 404:
+        return
+
+    raise CIArtifactNotFound(err)
diff --git a/assigner/backends/mock.py b/assigner/backends/mock.py
index 2da8538..ec00a6a 100644
--- a/assigner/backends/mock.py
+++ b/assigner/backends/mock.py
@@ -169,6 +169,10 @@ class MockRepo(RepoBase):
     def delete(self):
         logging.debug("Deleted %s.", self.name)
 
+    @classmethod
+    def create_group(cls, group, config) -> None:
+        logging.debug("Created group %s", group)
+
     @classmethod
     def get_user_id(cls, username, config):
         id = sum(map(ord, username))
diff --git a/assigner/commands/assign.py b/assigner/commands/assign.py
index 603d271..53020c9 100644
--- a/assigner/commands/assign.py
+++ b/assigner/commands/assign.py
@@ -4,17 +4,18 @@ import time
 
 from assigner.backends.base import RepoError
 from assigner.backends.decorators import requires_config_and_backend
+from assigner.backends.exceptions import (
+    RepositoryAlreadyExists,
+    BranchNotFound,
+)
 from assigner.commands.open import open_assignment
 from assigner import progress
 from assigner.roster_util import get_filtered_roster
 
-from requests.exceptions import HTTPError
-
 help = "Assign a template repo to students"
 
 logger = logging.getLogger(__name__)
 
-
 @requires_config_and_backend
 def assign(conf, backend, args):
     """Creates homework repositories for an assignment for each student
@@ -46,6 +47,10 @@ def assign(conf, backend, args):
         if not dry_run:
             try:
                 template.clone_to(tmpdirname, branch)
+            except BranchNotFound as e:
+                logging.error("Cound not find branch %s in base repository", e.args[0])
+                logging.debug(e)
+                return
             except RepoError as e:
                 logging.error(
                     "Could not clone template repo (have you pushed at least one commit to it?)"
@@ -89,11 +94,12 @@ def assign(conf, backend, args):
                             )
                             logger.debug("Success!")
                             break
-                        except HTTPError as e:
-                            if retries >= 5 or e.response.status_code != 400:
+                        except RepositoryAlreadyExists as e:
+                            if retries >= 5:
                                 logger.debug("Critical Failure!")
                                 raise
                             logger.debug("Failed, retrying...")
+                            logger.debug(e)
 
                         # Delay and try again
                         time.sleep(wait * 2**retries)
diff --git a/assigner/commands/commit.py b/assigner/commands/commit.py
index 9f398e7..8f84716 100644
--- a/assigner/commands/commit.py
+++ b/assigner/commands/commit.py
@@ -2,7 +2,6 @@ import logging
 import os
 import subprocess
 
-from requests.exceptions import HTTPError
 from git.exc import NoSuchPathError
 
 from assigner.roster_util import get_filtered_roster
@@ -101,11 +100,6 @@ def _push(conf, backend, args):
             logging.warning("Local repo for %s does not exist; skipping...", username)
         except RepoError as e:
             logging.warning(e)
-        except HTTPError as e:
-            if e.response.status_code == 404:
-                logging.warning("Repository %s does not exist.", full_name)
-            else:
-                raise
 
 def setup_parser(parser):
     parser.add_argument("name",
diff --git a/assigner/commands/get.py b/assigner/commands/get.py
index 271185c..f91a2d0 100644
--- a/assigner/commands/get.py
+++ b/assigner/commands/get.py
@@ -1,7 +1,6 @@
 import logging
 import os
 
-from requests.exceptions import HTTPError
 from git import NoSuchPathError, GitCommandError, InvalidGitRepositoryError
 
 from assigner.backends import RepoError
@@ -120,11 +119,6 @@ def _get(conf, backend, args):
             logging.warning(e)
         except RepoError as e:
             logging.warning(e)
-        except HTTPError as e:
-            if e.response.status_code == 404:
-                logging.warning("Repository %s does not exist.", full_name)
-            else:
-                raise
 
     out_str = output.get_string()
     if out_str != "":
diff --git a/assigner/commands/new.py b/assigner/commands/new.py
index ec5b6dd..83c6d8f 100644
--- a/assigner/commands/new.py
+++ b/assigner/commands/new.py
@@ -1,8 +1,10 @@
 import logging
 
-from requests.exceptions import HTTPError
-
 from assigner.backends.decorators import requires_config_and_backend
+from assigner.backends.exceptions import (
+    AssignerGroupNotFound,
+    RepositoryAlreadyExists,
+)
 
 help = "Create a new template repo"
 
@@ -20,19 +22,28 @@ def new(conf, backend, args):
     namespace = conf.namespace
     backend_conf = conf.backend
 
-    if dry_run:
-        url = backend.repo.build_url(backend_conf, namespace, hw_name)
-        print(
-            "Created repo for {}:\n\t{}\n\t{}".format(hw_name, url, "(ssh url not available)"))
-    else:
-        try:
+    try:
+        if dry_run:
+            url = backend.repo.build_url(backend_conf, namespace, hw_name)
+            ssh_url = "(ssh url not available)"
+        else:
             repo = backend.template_repo.new(hw_name, namespace, backend_conf)
-            print("Created repo for {}:\n\t{}\n\t{}".format(hw_name, repo.url, repo.ssh_url))
-        except HTTPError as e:
-            if e.response.status_code == 400:
-                logger.warning("Repository %s already exists!", hw_name)
-            else:
-                raise
+            url = repo.url
+            ssh_url = repo.ssh_url
+
+        print("Created repo for {}:\n\t{}\n\t{}".format(hw_name, url, ssh_url))
+
+    except AssignerGroupNotFound as e:
+        logger.error(e)
+
+        do_create_group = input("\nDo you want to create this group? [y/N]: ")
+        if do_create_group.lower() == "y":
+            backend.repo.create_group(conf["namespace"], conf["backend"])
+            print("{} created!".format(conf["namespace"]))
+
+    except RepositoryAlreadyExists as e:
+        logger.warning("Repository %s already exists!", hw_name)
+        logger.debug(e)
 
 
 def setup_parser(parser):
diff --git a/assigner/commands/open.py b/assigner/commands/open.py
index a3e633d..d386033 100644
--- a/assigner/commands/open.py
+++ b/assigner/commands/open.py
@@ -1,10 +1,11 @@
 import logging
 
-from requests.exceptions import HTTPError
-
 from assigner.backends.base import RepoError
 from assigner.backends.decorators import requires_config_and_backend
-from assigner.backends.exceptions import UserInAssignerGroup
+from assigner.backends.exceptions import (
+    UserInAssignerGroup,
+    UserAlreadyAssigned,
+)
 from assigner.roster_util import get_filtered_roster
 from assigner import progress
 
@@ -17,11 +18,9 @@ def open_assignment(repo, student, access):
     try:
         logging.debug("Opening %s...", repo.name)
         repo.add_member(student["id"], access)
-    except HTTPError as e:
-        if e.response.status_code == 409:
-            logging.warning("%s is already a member of %s.", student["username"], repo.name)
-        else:
-            raise
+    except UserAlreadyAssigned as e:
+        logging.warning("%s is already a member of %s.", student["username"], repo.name)
+        logging.debug(e)
 
 
 @requires_config_and_backend
diff --git a/assigner/commands/push.py b/assigner/commands/push.py
index 644cb67..6025aed 100644
--- a/assigner/commands/push.py
+++ b/assigner/commands/push.py
@@ -1,7 +1,6 @@
 import logging
 import os
 
-from requests.exceptions import HTTPError
 from git.exc import NoSuchPathError
 
 from assigner.roster_util import get_filtered_roster
@@ -56,11 +55,6 @@ def _push(conf, backend, args):
             logging.warning("Local repo for %s does not exist; skipping...", username)
         except RepoError as e:
             logging.warning(e)
-        except HTTPError as e:
-            if e.response.status_code == 404:
-                logging.warning("Repository %s does not exist.", full_name)
-            else:
-                raise
 
 
 def setup_parser(parser):
diff --git a/assigner/commands/score.py b/assigner/commands/score.py
index 4ff67cf..6459115 100644
--- a/assigner/commands/score.py
+++ b/assigner/commands/score.py
@@ -4,14 +4,14 @@ from typing import Any, Dict, List, Optional, Tuple, Set
 import re
 import os
 
-from requests.exceptions import HTTPError
-
 from redkyn.canvas import CanvasAPI
 from redkyn.canvas.exceptions import CourseNotFound, StudentNotFound
 
 from assigner import make_help_parser
 from assigner.backends.base import RepoError, RepoBase, BackendBase
 from assigner.backends.decorators import requires_config_and_backend
+from assigner.backends.exceptions import CIArtifactNotFound
+from assigner.exceptions import AssignerException
 from assigner.roster_util import get_filtered_roster
 from assigner import progress
 from assigner.config import Config
@@ -20,6 +20,8 @@ help = "Retrieves scores from CI artifacts and optionally uploads to Canvas"
 
 logger = logging.getLogger(__name__)
 
+class CIJobNotFound(AssignerException):
+    """ No CI jobs found for repository. """
 
 class OptionalCanvas:
     """
@@ -110,6 +112,9 @@ def get_most_recent_score(repo: RepoBase, result_path: str) -> float:
     """
     try:
         ci_jobs = repo.list_ci_jobs()
+        if len(ci_jobs) == 0:
+            raise CIJobNotFound
+
         most_recent_job_id = ci_jobs[0]["id"]
         score_file = repo.get_ci_artifact(most_recent_job_id, result_path)
         last_token = score_file.split()[-1]
@@ -117,12 +122,9 @@ def get_most_recent_score(repo: RepoBase, result_path: str) -> float:
         if not 0.0 <= score <= 100.0:
             logger.warning("Unusual score retrieved: %f.", score)
         return score
-    except HTTPError as e:
-        if e.response.status_code == 404:
-            logger.warning(
-                "CI artifact does not exist in repo %s.", repo.name_with_namespace,
-            )
-        raise
+    except CIArtifactNotFound as e:
+        logger.warning("CI artifact does not exist in repo %s.", repo.name_with_namespace)
+        logger.debug(e)
 
 
 def student_search(
@@ -199,6 +201,9 @@ def print_statistics(scores: List[float]) -> None:
     Displays aggregate information (summary statistics)
     for a one-dimensional data set
     """
+    if len(scores) == 0:
+        return
+
     print("---Assignment Statistics---")
     print("Mean: ", sum(scores) / len(scores))
     print("Number of zeroes:", len([score for score in scores if score < 0.1]))
@@ -296,6 +301,9 @@ def handle_scoring(
                 logger.debug(e)
                 logger.warning("Unable to update submission for Canvas assignment")
 
+    except CIJobNotFound:
+        logger.error("No CI jobs found for repo %s", repo.name_with_namespace)
+        score = None
     except RepoError as e:
         logger.debug(e)
         logger.warning("Unable to find repo for %s with URL %s", username, full_name)
diff --git a/pylintrc b/pylintrc
index 66bd035..5f3761b 100644
--- a/pylintrc
+++ b/pylintrc
@@ -185,7 +185,7 @@ score=yes
 [REFACTORING]
 
 # Maximum number of nested blocks for function / method body
-max-nested-blocks=5
+max-nested-blocks=6
 
 
 [VARIABLES]


Test patch:
diff --git a/assigner/tests/assigner_test.py b/assigner/tests/assigner_test.py
index 537b50b..d8ad5c2 100644
--- a/assigner/tests/assigner_test.py
+++ b/assigner/tests/assigner_test.py
@@ -2,6 +2,7 @@ import itertools
 from unittest.mock import patch
 
 from assigner import main, make_parser, subcommands
+from assigner.exceptions import AssignerException
 from assigner.tests.utils import AssignerTestCase
 
 from git.cmd import GitCommandNotFound
@@ -52,6 +53,9 @@ class MakeParserTestCase(AssignerTestCase):
         self.assertTrue(self.mock_parser.print_usage.called)
 
 
+class ExampleAssignerError(AssignerException):
+    pass
+
 class ExampleError(Exception):
     pass
 
@@ -112,13 +116,28 @@ class MainTestCase(AssignerTestCase):
         main should log exceptions when raised.
         """
         self.mock_args.tracebacks = False
-        self.mock_args.run.side_effect = ExampleError
+        self.mock_args.run.side_effect = ExampleAssignerError("assigner exception")
+        try:
+            main([])
+        except SystemExit:
+            pass
+
+        mock_logger.error.assert_called_once_with(str(ExampleAssignerError("assigner exception")))
+
+    @patch("assigner.logger", autospec=True)
+    def test_main_reports_unexpected_exceptions(self, mock_logger):
+        """
+        main should log exceptions when raised.
+        """
+        self.mock_args.tracebacks = False
+        self.mock_args.run.side_effect = ExampleError("unexpected error")
         try:
             main([])
         except SystemExit:
             pass
 
-        mock_logger.error.assert_called_once_with(str(ExampleError()))
+        mock_logger.error.assert_any_call(str(ExampleError("unexpected error")))
+        mock_logger.error.assert_any_call("This is a bug. Please file an issue here: https://github.com/redkyn/assigner/issues/new")
 
     @patch("assigner.logger", autospec=True)
     def test_main_logs_keyerror_with_catch(self, mock_logger):