Repository: repobee/repobee (python)

Issue:
Enable collaboration repos
While I don't personally have much use of this, I'd imagine it could be interesting for other courses. It basically just boils down to augmenting the student list (maybe listing multiple students on one line in the list) and also the repo name generation. The `GitHubAPI.ensure_teams_and_members` is already prepared to handle multiple students per team, so there's really only a need to change top level functionality in the `commands` and `cli` modules.

Things to consider:

* Repo name generation could simply be `<student_1>[-<student_i>...]-<master_repo_name>`
* Student names should be sorted lexicographically so order of arguments doesn't matter
* Potentially limit this functionality to the student's list to avoid overcomplicating the CLI

All in all, this should be pretty easy.

Pull request:
Group assignments feature
Add group assignments. Supports everything but peer review features.

Bumps version to 1.3.0

Fix #157 


Patch:
diff --git a/docs/conf.py b/docs/conf.py
index a1a18c9..1ae05f7 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -70,9 +70,9 @@ author = "Simon Larsén"
 # built documents.
 #
 # The short X.Y version.
-version = "1.2"
+version = "1.3"
 # The full version, including alpha/beta/rc tags.
-release = "1.2.1"
+release = "1.3.0"
 
 # The language for content autogenerated by Sphinx. Refer to documentation
 # for a list of supported languages.
diff --git a/docs/getting_started.rst b/docs/getting_started.rst
index 1a239fd..2a3aab7 100644
--- a/docs/getting_started.rst
+++ b/docs/getting_started.rst
@@ -170,6 +170,20 @@ a file called ``students.txt`` and type each username on a separate line.
     ham
     eggs
 
+.. note::
+
+   **Since v1.3.0:** It is now possible to specify groups of students to get
+   access to the same repos by putting multiple usernames on the same line,
+   separated by spaces. For example, the following file will put `spam` and
+   `ham` in the same group.
+
+   .. code-block:: bash
+
+      spam ham
+      eggs
+
+   See :ref:`groups` for details.
+
 An absolute file path to this file can be added to the config file with the
 ``students_file`` option (see :ref:`config`). Now, I want to create one student
 repo for each student per master repo. The repo names will be on the form
diff --git a/docs/group_assignments.rst b/docs/group_assignments.rst
new file mode 100644
index 0000000..6527e2d
--- /dev/null
+++ b/docs/group_assignments.rst
@@ -0,0 +1,65 @@
+.. _groups:
+
+Group assignments
+*****************
+
+.. note::
+   
+   **New in v1.3.0!** This feature is in beta phase, please report any bugs you
+   encounter on the GitHub issue tracker!
+
+.. important::
+
+   The peer review commands (see :ref:`peer review`) do not currently support
+   group assignments.
+
+RepoBee supports group assignments such that multiple students are assigned to
+the same student repositories. To put students in a group, they need to be
+entered on the same line in the students file, separated by spaces. This is the
+only way to group students, the ``--s`` option on the command line does not
+support groups. As an example, if ``ham`` and ``spam`` should be in one group,
+and ``eggs`` solo, the following students file would work:
+
+.. code-block:: bash
+
+   ham spam
+   eggs
+
+There is no difference in using RepoBee with student groups in the student
+file. For example, running the setup command from :ref:`setup` would then have
+the following result:
+
+.. code-block:: bash
+
+    $ repobee setup -mn master-repo-1 master-repo-2 -sf students.txt
+    [INFO] cloning into master repos ...
+    [INFO] cloning into file:///home/slarse/tmp/master-repo-1
+    [INFO] cloning into file:///home/slarse/tmp/master-repo-2
+    [INFO] created team eggs
+    [INFO] created team ham-spam
+    [INFO] adding members eggs to team eggs
+    [WARNING] user eggs does not exist
+    [INFO] adding members ham, spam to team ham-spam
+    [INFO] creating student repos ...
+    [INFO] created repobee-demo/eggs-master-repo-1
+    [INFO] created repobee-demo/ham-spam-master-repo-1
+    [INFO] created repobee-demo/eggs-master-repo-2
+    [INFO] created repobee-demo/ham-spam-master-repo-2
+    [INFO] pushing files to student repos ...
+    [INFO] pushing, attempt 1/3
+    [INFO] Pushed files to https://some-enterprise-host/repobee-demo/ham-spam-master-repo-2 master
+    [INFO] Pushed files to https://some-enterprise-host/repobee-demo/ham-spam-master-repo-1 master
+    [INFO] Pushed files to https://some-enterprise-host/repobee-demo/eggs-master-repo-2 master
+    [INFO] Pushed files to https://some-enterprise-host/repobee-demo/eggs-master-repo-1 master
+
+Note the naming convention for group repos:
+``<student-1>-<student-2>-[...]-<master-repo-name>``. The associated teams
+follow the same convention, but without the trailing ``-<master-repo-name>``.
+And that is all you need to know to start doing group assignments!
+
+.. warning::
+
+   The naming scheme has a weakness: it can create fairly long names, and
+   GitHub has a hard limit for repo names at 100 characters. RepoBee will
+   therefore crash (on purpose) if a Team or repo name exceeds 100 characters.
+   There is no workaround for this problem at the moment.
diff --git a/docs/userguide.rst b/docs/userguide.rst
index e45d489..0e6ae2c 100644
--- a/docs/userguide.rst
+++ b/docs/userguide.rst
@@ -14,3 +14,4 @@ RepoBee User Guide
    peer
    plugins
    migrate
+   group_assignments
diff --git a/repobee/__version.py b/repobee/__version.py
index a955fda..67bc602 100644
--- a/repobee/__version.py
+++ b/repobee/__version.py
@@ -1,1 +1,1 @@
-__version__ = "1.2.1"
+__version__ = "1.3.0"
diff --git a/repobee/cli.py b/repobee/cli.py
index 74bc120..6fd5d48 100644
--- a/repobee/cli.py
+++ b/repobee/cli.py
@@ -13,6 +13,7 @@ import pathlib
 import os
 import sys
 from contextlib import contextmanager
+from collections import namedtuple
 from typing import List, Iterable, Optional, Tuple
 
 import logging
@@ -156,8 +157,19 @@ def parse_args(
         master_urls = _repo_names_to_urls(master_names, master_org_name, api)
     assert master_urls and master_names
 
+    groups = _extract_groups(args)
+    subparser = getattr(args, SUB)
+    if subparser in [
+        ASSIGN_REVIEWS_PARSER,
+        CHECK_REVIEW_PROGRESS_PARSER,
+        ASSIGN_REVIEWS_PARSER,
+    ] and any([len(g.members) > 1 for g in groups]):
+        raise exception.ParseError(
+            "review commands do not currently support groups of students"
+        )
+
     parsed_args = tuples.Args(
-        subparser=getattr(args, SUB),
+        subparser=subparser,
         org_name=args.org_name,
         master_org_name=args.master_org_name
         if "master_org_name" in args
@@ -166,7 +178,7 @@ def parse_args(
         user=args.user if "user" in args else None,
         master_repo_urls=master_urls,
         master_repo_names=master_names,
-        students=_extract_students(args),
+        students=_extract_groups(args),
         issue=util.read_issue(args.issue)
         if "issue" in args and args.issue
         else None,
@@ -786,8 +798,8 @@ def _sys_exit_on_expected_error():
         sys.exit(1)
 
 
-def _extract_students(args: argparse.Namespace) -> List[str]:
-    """Extract students from args namespace.`
+def _extract_groups(args: argparse.Namespace) -> List[str]:
+    """Extract groups from args namespace.`
 
     Args:
         args: A namespace object.
@@ -797,7 +809,7 @@ def _extract_students(args: argparse.Namespace) -> List[str]:
         `students_file` is in the namespace.
     """
     if "students" in args and args.students:
-        students = args.students
+        students = [tuples.Group([s]) for s in args.students]
     elif "students_file" in args and args.students_file:
         students_file = pathlib.Path(args.students_file)
         try:  # raises FileNotFoundError in 3.5 if no such file exists
@@ -811,11 +823,11 @@ def _extract_students(args: argparse.Namespace) -> List[str]:
         if not students_file.stat().st_size:
             raise exception.FileError("'{!s}' is empty".format(students_file))
         students = [
-            student.strip()
-            for student in students_file.read_text(
+            tuples.Group(members=[s for s in group.strip().split()])
+            for group in students_file.read_text(
                 encoding=sys.getdefaultencoding()
             ).split(os.linesep)
-            if student  # skip blank lines
+            if group  # skip blank lines
         ]
     else:
         students = None
diff --git a/repobee/command.py b/repobee/command.py
index 9b6106c..b37ac31 100644
--- a/repobee/command.py
+++ b/repobee/command.py
@@ -41,7 +41,7 @@ MASTER_TEAM = "master_repos"
 
 def setup_student_repos(
     master_repo_urls: Iterable[str],
-    students: Iterable[str],
+    students: Iterable[tuples.Group],
     user: str,
     api: GitHubAPI,
 ) -> None:
@@ -87,7 +87,7 @@ def setup_student_repos(
 
 
 def add_students_to_teams(
-    students: Iterable[str], api: GitHubAPI
+    students: Iterable[tuples.Group], api: GitHubAPI
 ) -> List[Team]:
     """Create one team for each student (with the same name as the student),
     and add the student to the team. If a team already exists, it is not
@@ -102,8 +102,8 @@ def add_students_to_teams(
     """
     util.validate_types(api=(api, GitHubAPI))
     util.validate_non_empty(students=students)
-    # (team_name, member list) mappings, each student gets its own team
-    member_lists = {student: [student] for student in students}
+    # TODO simply pass the group to ensure_teams_and_members instead
+    member_lists = {str(group): group.members for group in students}
     return api.ensure_teams_and_members(member_lists)
 
 
@@ -157,7 +157,7 @@ def _clone_all(urls: Iterable[str], token: str, cwd: str):
 
 def update_student_repos(
     master_repo_urls: Iterable[str],
-    students: Iterable[str],
+    students: Iterable[tuples.Group],
     user: str,
     api: GitHubAPI,
     issue: Optional[tuples.Issue] = None,
@@ -221,7 +221,7 @@ def _open_issue_by_urls(
 
 def list_issues(
     master_repo_names: Iterable[str],
-    students: Iterable[str],
+    students: Iterable[tuples.Group],
     api: GitHubAPI,
     state: str = "open",
     title_regex: str = "",
@@ -336,7 +336,7 @@ def _limit_line_length(s: str, max_line_length: int = 100) -> str:
 def open_issue(
     issue: tuples.Issue,
     master_repo_names: Iterable[str],
-    students: Iterable[str],
+    students: Iterable[tuples.Group],
     api: GitHubAPI,
 ) -> None:
     """Open an issue in student repos.
@@ -360,7 +360,7 @@ def open_issue(
 def close_issue(
     title_regex: str,
     master_repo_names: Iterable[str],
-    students: Iterable[str],
+    students: Iterable[tuples.Group],
     api: GitHubAPI,
 ) -> None:
     """Close issues whose titles match the title_regex in student repos.
@@ -384,7 +384,9 @@ def close_issue(
 
 
 def clone_repos(
-    master_repo_names: Iterable[str], students: Iterable[str], api: GitHubAPI
+    master_repo_names: Iterable[str],
+    students: Iterable[tuples.Group],
+    api: GitHubAPI,
 ) -> None:
     """Clone all student repos related to the provided master repos and students.
 
@@ -478,7 +480,7 @@ def migrate_repos(
 
 def assign_peer_reviews(
     master_repo_names: Iterable[str],
-    students: Iterable[str],
+    students: Iterable[tuples.Group],
     num_reviews: int,
     issue: Optional[tuples.Issue],
     api: GitHubAPI,
@@ -507,10 +509,15 @@ def assign_peer_reviews(
         master_repo_names=master_repo_names, students=students
     )
 
+    # currently only supports single students
+    # TODO support groups
+    assert all(map(lambda g: len(g.members) == 1, students))
+    single_students = [g.members[0] for g in students]
+
     for master_name in master_repo_names:
         allocations = plug.manager.hook.generate_review_allocations(
             master_repo_name=master_name,
-            students=students,
+            students=single_students,
             num_reviews=num_reviews,
             review_team_name_function=util.generate_review_team_name,
         )
@@ -520,14 +527,16 @@ def assign_peer_reviews(
                 util.generate_review_team_name(student, master_name): [
                     util.generate_repo_name(student, master_name)
                 ]
-                for student in students
+                for student in single_students
             },
             issue=issue,
         )
 
 
 def purge_review_teams(
-    master_repo_names: Iterable[str], students: Iterable[str], api: GitHubAPI
+    master_repo_names: Iterable[str],
+    students: Iterable[tuples.Group],
+    api: GitHubAPI,
 ) -> None:
     """Delete all review teams associated with the given master repo names and
     students.
@@ -550,21 +559,27 @@ def purge_review_teams(
 
 def check_peer_review_progress(
     master_repo_names: Iterable[str],
-    students: Iterable[str],
+    students: Iterable[tuples.Group],
     title_regex: str,
     num_reviews: int,
     api: GitHubAPI,
 ) -> None:
+    # TODO support groups
+    assert all(map(lambda g: len(g.members) == 1, students))
+    single_students = [g.members[0] for g in students]
+
     review_team_names = [
         util.generate_review_team_name(student, master_name)
-        for student in students
+        for student in single_students
         for master_name in master_repo_names
     ]
-    reviews = api.get_review_progress(review_team_names, students, title_regex)
+    reviews = api.get_review_progress(
+        review_team_names, single_students, title_regex
+    )
 
     LOGGER.info(
         formatters.format_peer_review_progress_output(
-            reviews, students, num_reviews
+            reviews, single_students, num_reviews
         )
     )
 
diff --git a/repobee/tuples.py b/repobee/tuples.py
index c06dbcd..ebaaff7 100644
--- a/repobee/tuples.py
+++ b/repobee/tuples.py
@@ -12,6 +12,28 @@ the goal is to collect all container types in this module.
 """
 from collections import namedtuple
 
+import daiquiri
+
+LOGGER = daiquiri.getLogger(__file__)
+
+
+def _check_name_length(name):
+    """Check that a Team/Repository name does not exceed the maximum GitHub
+    allows (100 characters)
+    """
+    max_len = 100
+    if len(name) > max_len:
+        LOGGER.error("Team/Repository name {} is too long".format(name))
+        raise ValueError(
+            "generated Team/Repository name is too long, was {} chars, "
+            "max is {} chars".format(len(name), max_len)
+        )
+    elif len(name) > max_len * 0.8:
+        LOGGER.warning(
+            "Team/Repository name {} is {} chars long, close to the max of "
+            "{} chars.".format(name, len(name), max_len)
+        )
+
 
 class Issue(
     namedtuple("Issue", ("title", "body", "number", "created_at", "author"))
@@ -20,6 +42,20 @@ class Issue(
         return super().__new__(cls, title, body, number, created_at, author)
 
 
+class Group(namedtuple("Group", ("members"))):
+    # GitHub allows only 100 characters for repository names
+    MAX_STR_LEN = 100
+
+    def __str__(self):
+        return "-".join(sorted(self.members))
+
+    def __new__(cls, members):
+        instance = super().__new__(cls, members)
+        team_name = Group.__str__(instance)
+        _check_name_length(team_name)
+        return instance
+
+
 Args = namedtuple(
     "Args",
     (
@@ -50,6 +86,7 @@ class Repo(
     namedtuple("Repo", ("name", "description", "private", "team_id", "url"))
 ):
     def __new__(cls, name, description, private, team_id=None, url=None):
+        _check_name_length(name)
         return super().__new__(cls, name, description, private, team_id, url)
 
 


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 9e4097b..ed4bdc9 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -71,7 +71,7 @@ def students_file(empty_students_file):
     """A fixture with a temporary file containt the students in
     constants.STUDENTS.
     """
-    empty_students_file.write(os.linesep.join(constants.STUDENTS))
+    empty_students_file.write(os.linesep.join([str(s) for s in constants.STUDENTS]))
     yield empty_students_file
 
 
diff --git a/tests/constants.py b/tests/constants.py
index e1d9a71..cf7fc4c 100644
--- a/tests/constants.py
+++ b/tests/constants.py
@@ -2,6 +2,7 @@
 import string
 import collections
 from datetime import datetime
+from itertools import permutations
 
 from repobee import tuples
 
@@ -10,7 +11,9 @@ ORG_NAME = "test-org"
 MASTER_ORG_NAME = "test-master-org"
 HOST_URL = "https://some_enterprise_host"
 GITHUB_BASE_URL = "{}/api/v3".format(HOST_URL)
-STUDENTS = tuple(string.ascii_lowercase[:4])
+
+# 5! = 120 different students
+STUDENTS = tuple(tuples.Group(members=[''.join(perm)]) for perm in permutations(string.ascii_lowercase[:5]))
 ISSUE_PATH = "some/issue/path"
 ISSUE = tuples.Issue(title="Best title", body="This is the body of the issue.")
 PLUGINS = ["javac", "pylint"]
diff --git a/tests/test_cli.py b/tests/test_cli.py
index bfaf0ba..42ed097 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -17,6 +17,7 @@ USER = constants.USER
 ORG_NAME = constants.ORG_NAME
 GITHUB_BASE_URL = constants.GITHUB_BASE_URL
 STUDENTS = constants.STUDENTS
+STUDENTS_STRING = " ".join([str(s) for s in STUDENTS])
 ISSUE_PATH = constants.ISSUE_PATH
 ISSUE = constants.ISSUE
 generate_repo_url = functions.generate_repo_url
@@ -485,7 +486,13 @@ class TestStudentParsing:
         """Test that the different subparsers parse arguments corectly when
         students are listed directly on the command line.
         """
-        sys_args = [parser, *BASE_ARGS, "-s", *STUDENTS, *extra_args]
+        sys_args = [
+            parser,
+            *BASE_ARGS,
+            "-s",
+            *STUDENTS_STRING.split(),
+            *extra_args,
+        ]
 
         parsed_args, _ = cli.parse_args(sys_args)
 
@@ -524,7 +531,7 @@ class TestStudentParsing:
 
     @pytest.mark.parametrize(*STUDENT_PARSING_PARAMS, ids=STUDENT_PARSING_IDS)
     def test_parsers_raise_if_both_file_and_listing(
-        read_issue_mock, students_file, parser, extra_args
+        self, read_issue_mock, students_file, parser, extra_args
     ):
         """Test that the student subparsers raise if students are both listed
         on the CLI, and a file is specified.
@@ -535,13 +542,78 @@ class TestStudentParsing:
             "-sf",
             str(students_file),
             "-s",
-            *STUDENTS,
+            *STUDENTS_STRING.split(),
             *extra_args,
         ]
 
         with pytest.raises(SystemExit):
             cli.parse_args(sys_args)
 
+    @pytest.mark.parametrize(*STUDENT_PARSING_PARAMS, ids=STUDENT_PARSING_IDS)
+    def test_student_groups_parsed_correcly(
+        self, empty_students_file, read_issue_mock, parser, extra_args
+    ):
+        """Test that putting multiple students on the same line in the students
+        file results in them being in the same group.
+        """
+        # arrange
+        groupings = (
+            ["study"],
+            ["buddy", "shuddy"],
+            ["grape"],
+            ["cat", "dog", "mouse"],
+        )
+        expected_groups = sorted(
+            tuples.Group(members=group) for group in groupings
+        )
+        empty_students_file.write(
+            os.linesep.join([" ".join(group) for group in groupings])
+        )
+        sys_args = [
+            parser,
+            *BASE_ARGS,
+            "-sf",
+            str(empty_students_file),
+            *extra_args,
+        ]
+
+        # act
+        parsed_args, _ = cli.parse_args(sys_args)
+
+        # assert
+        assert sorted(parsed_args.students) == expected_groups
+
+    @pytest.mark.parametrize(*STUDENT_PARSING_PARAMS, ids=STUDENT_PARSING_IDS)
+    def test_raises_if_generated_team_name_too_long(
+        self, empty_students_file, read_issue_mock, parser, extra_args
+    ):
+        """Test that the parser raises a ValueError if the team name generated
+        from a group of students is longer than the maximum allowed by GitHub.
+        """
+        # arrange
+        groupings = (
+            ["buddy", "shuddy"],
+            ["a" * tuples.Group.MAX_STR_LEN, "b"],
+            ["cat", "dog", "mouse"],
+        )
+        empty_students_file.write(
+            os.linesep.join([" ".join(group) for group in groupings])
+        )
+        sys_args = [
+            parser,
+            *BASE_ARGS,
+            "-sf",
+            str(empty_students_file),
+            *extra_args,
+        ]
+
+        # act
+        with pytest.raises(ValueError) as exc_info:
+            cli.parse_args(sys_args)
+
+        # assert
+        assert "generated Team/Repository name is too long" in str(exc_info)
+
 
 def assert_base_push_args(parsed_args, api):
     """Assert that the parsed arguments are consistend with the
@@ -639,7 +711,12 @@ class TestSetupAndUpdateParsers:
 
     def test_happy_path(self, api_class_mock, parser):
         """Tests standard operation of the parsers."""
-        sys_args = [parser, *COMPLETE_PUSH_ARGS, "-s", *STUDENTS]
+        sys_args = [
+            parser,
+            *COMPLETE_PUSH_ARGS,
+            "-s",
+            *STUDENTS_STRING.split(),
+        ]
 
         parsed_args, _ = cli.parse_args(sys_args)
 
@@ -665,7 +742,12 @@ class TestSetupAndUpdateParsers:
             generate_repo_url(name, ORG_NAME) for name in repo_names
         ]
 
-        sys_args = [parser, *COMPLETE_PUSH_ARGS, "-s", *STUDENTS]
+        sys_args = [
+            parser,
+            *COMPLETE_PUSH_ARGS,
+            "-s",
+            *STUDENTS_STRING.split(),
+        ]
 
         parsed_args, _ = cli.parse_args(sys_args)
 
@@ -776,8 +858,6 @@ class TestCloneParser:
             args=mock.ANY
         )
 
-    STUDENTS_STRING = " ".join(STUDENTS)
-
     # def test_no_plugins_option_drops_plugins()
 
     @pytest.mark.parametrize(
@@ -785,21 +865,42 @@ class TestCloneParser:
         [
             (
                 cli.SETUP_PARSER,
-                ["-u", USER, "-s", STUDENTS_STRING, "-mn", *REPO_NAMES],
+                [
+                    "-u",
+                    USER,
+                    "-s",
+                    *STUDENTS_STRING.split(),
+                    "-mn",
+                    *REPO_NAMES,
+                ],
             ),
             (
                 cli.UPDATE_PARSER,
-                ["-u", USER, "-s", STUDENTS_STRING, "-mn", *REPO_NAMES],
+                [
+                    "-u",
+                    USER,
+                    "-s",
+                    *STUDENTS_STRING.split(),
+                    "-mn",
+                    *REPO_NAMES,
+                ],
             ),
             (
                 cli.OPEN_ISSUE_PARSER,
-                ["-s", STUDENTS_STRING, "-mn", *REPO_NAMES, "-i", ISSUE_PATH],
+                [
+                    "-s",
+                    *STUDENTS_STRING.split(),
+                    "-mn",
+                    *REPO_NAMES,
+                    "-i",
+                    ISSUE_PATH,
+                ],
             ),
             (
                 cli.CLOSE_ISSUE_PARSER,
                 [
                     "-s",
-                    STUDENTS_STRING,
+                    *STUDENTS_STRING.split(),
                     "-mn",
                     *REPO_NAMES,
                     "-r",
@@ -853,7 +954,7 @@ class TestCommandDeprecation:
                     "-i",
                     ISSUE_PATH,
                     "-s",
-                    *STUDENTS,
+                    *STUDENTS_STRING.split(),
                     "-n",
                     "3",
                 ],
@@ -861,7 +962,7 @@ class TestCommandDeprecation:
             (
                 cli.PURGE_REVIEW_TEAMS_PARSER_OLD,
                 cli.PURGE_REVIEW_TEAMS_PARSER,
-                [*BASE_ARGS, "-mn", "week-10", "-s", *STUDENTS],
+                [*BASE_ARGS, "-mn", "week-10", "-s", *STUDENTS_STRING.split()],
             ),
             (
                 cli.CHECK_REVIEW_PROGRESS_PARSER_OLD,
@@ -875,7 +976,7 @@ class TestCommandDeprecation:
                     "-n",
                     "3",
                     "-s",
-                    *STUDENTS,
+                    *STUDENTS_STRING.split(),
                 ],
             ),
         ],
diff --git a/tests/test_command.py b/tests/test_command.py
index 7ebe23f..4aed2b7 100644
--- a/tests/test_command.py
+++ b/tests/test_command.py
@@ -170,8 +170,7 @@ def api_mock(request, mocker):
         map(url_from_repo_info, repo_infos)
     )
     mock.get_issues = MagicMock(
-        spec="repobee.github_api.GitHubAPI.get_issues",
-        side_effect=_get_issues,
+        spec="repobee.github_api.GitHubAPI.get_issues", side_effect=_get_issues
     )
     type(mock).token = PropertyMock(return_value=TOKEN)
     return mock
@@ -361,7 +360,7 @@ class TestSetupStudentRepos:
             call(url, TOKEN, cwd=str(tmpdir)) for url in master_urls
         ]
         expected_ensure_teams_arg = {
-            student: [student] for student in students
+            str(student): [str(student)] for student in students
         }
 
         command.setup_student_repos(master_urls, students, USER, api_mock)
@@ -911,10 +910,13 @@ class TestCheckPeerReviewProgress:
             for student in students
             for master_name in master_names
         ]
+        # TODO change this when groups are supported
+        single_students = [g.members[0] for g in students]
+        
         command.check_peer_review_progress(
             master_names, students, title_regex, 2, api_mock
         )
 
         api_mock.get_review_progress.assert_called_once_with(
-            review_team_names, students, title_regex
+            review_team_names, single_students, title_regex
         )