Repository: repobee/repobee (python)

Issue:
Wrap API objects that are used by the command module
Off the top of my head, this should just be repository, team and issue objects (that are currently represented as `tuples.Repo` and `tuples.Team` and `tuples.Issue`). The reason to create other wrappers is that it's very possible that the platform APIs need to access implementation specific things, so these wrappers must have a `implementation` property that can be accessed wherever necessary (should just be in the platform API classes)

This will also allow for concisely documenting the behavior of the APISpec methods, see #191 

Pull request:
[fact] Wrap Repo, Issue and Team API objects in apimeta
Fix #208

Patch:
diff --git a/repobee/apimeta.py b/repobee/apimeta.py
index 2e9569d..5ea0424 100644
--- a/repobee/apimeta.py
+++ b/repobee/apimeta.py
@@ -19,9 +19,94 @@ NotImplementedError) for any unimplemented API methods.
 .. moduleauthor:: Simon Larsén
 """
 import inspect
+import collections
+
+import daiquiri
 
 from repobee import exception
 
+LOGGER = daiquiri.getLogger(__file__)
+
+
+class APIObject:
+    """Base wrapper class for platform API objects."""
+
+
+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 Repo(
+    APIObject,
+    collections.namedtuple(
+        "Repo", "name description private team_id url implementation".split()
+    ),
+):
+    """Wrapper class for a Repo API object."""
+
+    def __new__(
+        cls,
+        name,
+        description,
+        private,
+        team_id=None,
+        url=None,
+        implementation=None,
+    ):
+        _check_name_length(name)
+        return super().__new__(
+            cls, name, description, private, team_id, url, implementation
+        )
+
+
+class Team(
+    APIObject,
+    collections.namedtuple("Repo", "name members id implementation".split()),
+):
+    """Wrapper class for a Team API object."""
+
+    def __new__(cls, members, name=None, id=None, implementation=None):
+        if not name:
+            name = "-".join(sorted(members))
+        _check_name_length(name)
+        return super().__new__(cls, name, members, id, implementation)
+
+
+class Issue(
+    APIObject,
+    collections.namedtuple(
+        "Issue", "title body number created_at author implementation".split()
+    ),
+):
+    """Wrapper class for an Issue API object."""
+
+    def __new__(
+        cls,
+        title,
+        body,
+        number=None,
+        created_at=None,
+        author=None,
+        implementation=None,
+    ):
+        return super().__new__(
+            cls, title, body, number, created_at, author, implementation
+        )
+
 
 class APISpec:
     """Wrapper class for API method stubs."""
diff --git a/repobee/command.py b/repobee/command.py
index fbcce15..f71952a 100644
--- a/repobee/command.py
+++ b/repobee/command.py
@@ -27,11 +27,11 @@ import repobee_plug as plug
 from repobee import git
 from repobee import util
 from repobee import tuples
+from repobee import apimeta
 from repobee import exception
 from repobee import config
 from repobee import formatters
 from repobee.github_api import GitHubAPI
-from repobee.tuples import Team
 from repobee.git import Push
 
 LOGGER = daiquiri.getLogger(__file__)
@@ -79,7 +79,7 @@ def setup_student_repos(
 
 def add_students_to_teams(
     students: Iterable[tuples.Group], api: GitHubAPI
-) -> List[Team]:
+) -> List[apimeta.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
     created.  If a student is already in his/her team, nothing happens.
@@ -97,7 +97,9 @@ def add_students_to_teams(
 
 
 def _create_student_repos(
-    master_repo_urls: Iterable[str], teams: Iterable[Team], api: GitHubAPI
+    master_repo_urls: Iterable[str],
+    teams: Iterable[apimeta.Team],
+    api: GitHubAPI,
 ) -> List[str]:
     """Create student repos. Each team (usually representing one student) is
     assigned a single repo per master repo. Repos that already exist are not
@@ -147,7 +149,7 @@ def update_student_repos(
     master_repo_urls: Iterable[str],
     students: Iterable[tuples.Group],
     api: GitHubAPI,
-    issue: Optional[tuples.Issue] = None,
+    issue: Optional[apimeta.Issue] = None,
 ) -> None:
     """Attempt to update all student repos related to one of the master repos.
 
@@ -184,7 +186,7 @@ def update_student_repos(
 
 
 def _open_issue_by_urls(
-    repo_urls: Iterable[str], issue: tuples.Issue, api: GitHubAPI
+    repo_urls: Iterable[str], issue: apimeta.Issue, api: GitHubAPI
 ) -> None:
     """Open issues in the repos designated by the repo_urls.
 
@@ -236,7 +238,7 @@ def list_issues(
 
 
 def _log_repo_issues(
-    issues_per_repo: Tuple[str, Generator[tuples.Issue, None, None]],
+    issues_per_repo: Tuple[str, Generator[apimeta.Issue, None, None]],
     show_body: bool,
     title_alignment: int,
 ) -> None:
@@ -306,7 +308,7 @@ def _limit_line_length(s: str, max_line_length: int = 100) -> str:
 
 
 def open_issue(
-    issue: tuples.Issue,
+    issue: apimeta.Issue,
     master_repo_names: Iterable[str],
     students: Iterable[tuples.Group],
     api: GitHubAPI,
@@ -394,7 +396,7 @@ def migrate_repos(master_repo_urls: Iterable[str], api: GitHubAPI) -> None:
     master_names = [util.repo_name(url) for url in master_repo_urls]
 
     infos = [
-        tuples.Repo(
+        apimeta.Repo(
             name=master_name,
             description="Master repository {}".format(master_name),
             private=True,
@@ -424,7 +426,7 @@ def assign_peer_reviews(
     master_repo_names: Iterable[str],
     students: Iterable[tuples.Group],
     num_reviews: int,
-    issue: Optional[tuples.Issue],
+    issue: Optional[apimeta.Issue],
     api: GitHubAPI,
 ) -> None:
     """Assign peer reviewers among the students to each student repo. Each
@@ -516,8 +518,8 @@ def check_peer_review_progress(
 
 
 def _create_repo_infos(
-    urls: Iterable[str], teams: Iterable[Team]
-) -> List[tuples.Repo]:
+    urls: Iterable[str], teams: Iterable[apimeta.Team]
+) -> List[apimeta.Repo]:
     """Create Repo namedtuples for all combinations of url and team.
 
     Args:
@@ -531,7 +533,7 @@ def _create_repo_infos(
     for url in urls:
         repo_base_name = util.repo_name(url)
         repo_infos += [
-            tuples.Repo(
+            apimeta.Repo(
                 name=util.generate_repo_name(team.name, repo_base_name),
                 description="{} created for {}".format(
                     repo_base_name, team.name
diff --git a/repobee/github_api.py b/repobee/github_api.py
index 5089d7e..d984160 100644
--- a/repobee/github_api.py
+++ b/repobee/github_api.py
@@ -24,6 +24,7 @@ from repobee import util
 from repobee import apimeta
 
 REQUIRED_OAUTH_SCOPES = {"admin:org", "repo"}
+ISSUE_GENERATOR = Generator[apimeta.Issue, None, None]
 
 LOGGER = daiquiri.getLogger(__file__)
 
@@ -32,7 +33,7 @@ _Team = github.Team.Team
 _User = github.NamedUser.NamedUser
 _Repo = github.Repository.Repository
 
-DEFAULT_REVIEW_ISSUE = tuples.Issue(
+DEFAULT_REVIEW_ISSUE = apimeta.Issue(
     title="Peer review",
     body="You have been assigned to peer review this repo.",
 )
@@ -202,7 +203,7 @@ class GitHubAPI(apimeta.API):
         self,
         member_lists: Mapping[str, Iterable[str]],
         permission: str = "push",
-    ) -> List[tuples.Team]:
+    ) -> List[apimeta.Team]:
         """Create teams that do not exist and add members not in their
         specified teams (if they exist as users).
 
@@ -213,19 +214,27 @@ class GitHubAPI(apimeta.API):
             A list of Team namedtuples of the teams corresponding to the keys
             of the member_lists mapping.
         """
-        teams = self._ensure_teams_exist(
+        raw_teams = self._ensure_teams_exist(
             [team_name for team_name in member_lists.keys()],
             permission=permission,
         )
 
-        for team in [team for team in teams if member_lists[team.name]]:
+        for team in [team for team in raw_teams if member_lists[team.name]]:
             self._ensure_members_in_team(team, member_lists[team.name])
 
-        return list(self._get_teams_in(set(member_lists.keys())))
+        return [
+            apimeta.Team(
+                name=t.name,
+                members=member_lists[t.name],
+                id=t.id,
+                implementation=t,
+            )
+            for t in raw_teams
+        ]
 
     def _ensure_teams_exist(
         self, team_names: Iterable[str], permission: str = "push"
-    ) -> List[tuples.Team]:
+    ) -> List[github.Team.Team]:
         """Create any teams that do not yet exist.
 
         Args:
@@ -295,7 +304,7 @@ class GitHubAPI(apimeta.API):
             for user in users:
                 team.add_membership(user)
 
-    def create_repos(self, repos: Iterable[tuples.Repo]):
+    def create_repos(self, repos: Iterable[apimeta.Repo]):
         """Create repositories in the given organization according to the Repos.
         Repos that already exist are skipped.
 
@@ -397,7 +406,7 @@ class GitHubAPI(apimeta.API):
         repo_names: Iterable[str],
         state: str = "open",
         title_regex: str = "",
-    ):
+    ) -> Generator[Tuple[str, ISSUE_GENERATOR], None, None]:
         """Get all issues for the repos in repo_names an return a generator
         that yields (repo_name, issue generator) tuples.
 
@@ -416,7 +425,14 @@ class GitHubAPI(apimeta.API):
                 (
                     repo.name,
                     (
-                        issue
+                        apimeta.Issue(
+                            title=issue.title,
+                            body=issue.body,
+                            number=issue.number,
+                            created_at=issue.created_at,
+                            author=issue.user.login,
+                            implementation=issue,
+                        )
                         for issue in repo.get_issues(state=state)
                         if re.match(title_regex or "", issue.title)
                     ),
@@ -478,7 +494,7 @@ class GitHubAPI(apimeta.API):
     def add_repos_to_review_teams(
         self,
         team_to_repos: Mapping[str, Iterable[str]],
-        issue: Optional[tuples.Issue],
+        issue: Optional[apimeta.Issue],
     ) -> None:
         """Add repos to review teams. For each repo, an issue is opened, and
         every user in the review team is assigned to it. If no issue is
diff --git a/repobee/gitlab_api.py b/repobee/gitlab_api.py
index 6038109..18b55ff 100644
--- a/repobee/gitlab_api.py
+++ b/repobee/gitlab_api.py
@@ -105,7 +105,7 @@ class GitLabAPI(apimeta.API):
         self,
         member_lists: Mapping[str, Iterable[str]],
         permission: str = "push",
-    ) -> List[tuples.Team]:
+    ) -> List[apimeta.Team]:
         """Create teams that do not exist and add members not in their
         specified teams (if they exist as users).
 
@@ -116,15 +116,23 @@ class GitLabAPI(apimeta.API):
             A list of Team namedtuples of the teams corresponding to the keys
             of the member_lists mapping.
         """
-        teams = self._ensure_teams_exist(
+        raw_teams = self._ensure_teams_exist(
             [str(team_name) for team_name in member_lists.keys()],
             permission=permission,
         )
 
-        for team in [team for team in teams if member_lists[team.name]]:
+        for team in [team for team in raw_teams if member_lists[team.name]]:
             self._ensure_members_in_team(team, member_lists[team.name])
 
-        return list(self._get_teams_in(set(member_lists.keys())))
+        return [
+            apimeta.Team(
+                name=t.name,
+                members=member_lists[t.name],
+                id=t.id,
+                implementation=t,
+            )
+            for t in raw_teams
+        ]
 
     def _ensure_members_in_team(self, team, members: Iterable[str]):
         """Add all of the users in ``members`` to a team. Skips any users that
@@ -192,7 +200,7 @@ class GitLabAPI(apimeta.API):
 
     def _ensure_teams_exist(
         self, team_names: Iterable[str], permission: str = "push"
-    ) -> List[tuples.Team]:
+    ) -> List[apimeta.Team]:
         """Create any teams that do not yet exist.
 
         Args:
@@ -226,12 +234,12 @@ class GitLabAPI(apimeta.API):
                 teams.append(new_team)
         return teams
 
-    def create_repos(self, repos: Iterable[tuples.Repo]):
+    def create_repos(self, repos: Iterable[apimeta.Repo]) -> List[str]:
         """Create repositories in the given organization according to the Repos.
         Repos that already exist are skipped.
 
         Args:
-            repo_infos: An iterable of Repo namedtuples.
+            repos: An iterable of Repo API objects.
 
         Returns:
             A list of urls to all repos corresponding to the Repos.
diff --git a/repobee/tuples.py b/repobee/tuples.py
index ebaaff7..3623d69 100644
--- a/repobee/tuples.py
+++ b/repobee/tuples.py
@@ -35,13 +35,6 @@ def _check_name_length(name):
         )
 
 
-class Issue(
-    namedtuple("Issue", ("title", "body", "number", "created_at", "author"))
-):
-    def __new__(cls, title, body, number=None, created_at=None, author=None):
-        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
@@ -79,17 +72,6 @@ Args = namedtuple(
 )
 Args.__new__.__defaults__ = (None,) * len(Args._fields)
 
-Team = namedtuple("Team", ("name", "members", "id"))
-
-
-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)
-
-
 Review = namedtuple("Review", ["repo", "done"])
 
 Deprecation = namedtuple("Deprecation", ["replacement", "remove_by"])
diff --git a/repobee/util.py b/repobee/util.py
index aa36303..3c3c87e 100644
--- a/repobee/util.py
+++ b/repobee/util.py
@@ -10,10 +10,11 @@ import os
 import sys
 import pathlib
 from typing import Iterable, Generator, Union
-from repobee import tuples
 
+from repobee import apimeta
 
-def read_issue(issue_path: str) -> tuples.Issue:
+
+def read_issue(issue_path: str) -> apimeta.Issue:
     """Attempt to read an issue from a textfile. The first line of the file
     is interpreted as the issue's title.
 
@@ -23,7 +24,7 @@ def read_issue(issue_path: str) -> tuples.Issue:
     if not os.path.isfile(issue_path):
         raise ValueError("{} is not a file".format(issue_path))
     with open(issue_path, "r", encoding=sys.getdefaultencoding()) as file:
-        return tuples.Issue(file.readline().strip(), file.read())
+        return apimeta.Issue(file.readline().strip(), file.read())
 
 
 def generate_repo_name(team_name: str, master_repo_name: str) -> str:


Test patch:
diff --git a/tests/constants.py b/tests/constants.py
index cf7fc4c..8cd839d 100644
--- a/tests/constants.py
+++ b/tests/constants.py
@@ -5,6 +5,7 @@ from datetime import datetime
 from itertools import permutations
 
 from repobee import tuples
+from repobee import apimeta
 
 USER = "slarse"
 ORG_NAME = "test-org"
@@ -13,9 +14,14 @@ HOST_URL = "https://some_enterprise_host"
 GITHUB_BASE_URL = "{}/api/v3".format(HOST_URL)
 
 # 5! = 120 different students
-STUDENTS = tuple(tuples.Group(members=[''.join(perm)]) for perm in permutations(string.ascii_lowercase[:5]))
+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.")
+ISSUE = apimeta.Issue(
+    title="Best title", body="This is the body of the issue."
+)
 PLUGINS = ["javac", "pylint"]
 TOKEN = "besttoken1337"
 CONFIG_TOKEN = "bestconfigtoken"
diff --git a/tests/functions.py b/tests/functions.py
index 4c0c7e4..4039d9b 100644
--- a/tests/functions.py
+++ b/tests/functions.py
@@ -2,7 +2,7 @@
 import datetime
 import random
 from unittest.mock import MagicMock
-from repobee import tuples
+from repobee import apimeta
 
 import constants
 
@@ -55,8 +55,8 @@ def to_magic_mock_issue(issue):
 
 
 def from_magic_mock_issue(mock_issue):
-    """Convert a MagicMock issue into a tuples.Issue."""
-    return tuples.Issue(
+    """Convert a MagicMock issue into a apimeta.Issue."""
+    return apimeta.Issue(
         title=mock_issue.title,
         body=mock_issue.body,
         number=mock_issue.number,
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 8b0db7f..4f1aa6e 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -8,7 +8,7 @@ import repobee
 from repobee import cli
 from repobee import tuples
 from repobee import exception
-from repobee.tuples import Team
+from repobee import apimeta
 
 import constants
 import functions
@@ -66,7 +66,8 @@ def api_instance_mock(mocker):
         generate_repo_url(rn, org_name) for rn in repo_names
     ]
     instance_mock.ensure_teams_and_members.side_effect = lambda team_dict: [
-        Team(name, members, id=0) for name, members in team_dict.items()
+        apimeta.Team(name=name, members=members, id=0)
+        for name, members in team_dict.items()
     ]
     return instance_mock
 
diff --git a/tests/test_command.py b/tests/test_command.py
index e423071..73ecaf0 100644
--- a/tests/test_command.py
+++ b/tests/test_command.py
@@ -5,10 +5,10 @@ from unittest.mock import patch, MagicMock, call, ANY, PropertyMock
 import repobee
 from repobee import command
 from repobee import git
-from repobee import tuples
 from repobee import util
 from repobee import exception
 from repobee import plugin
+from repobee import apimeta
 
 from repobee_plug import HookResult, repobee_hook, Status
 
@@ -24,10 +24,10 @@ random_date = functions.random_date
 OPEN_ISSUES = [
     to_magic_mock_issue(issue)
     for issue in (
-        tuples.Issue(
+        apimeta.Issue(
             "close this issue", "This is a body", 3, random_date(), "slarse"
         ),
-        tuples.Issue(
+        apimeta.Issue(
             "Don't close this issue",
             "Another body",
             4,
@@ -40,7 +40,7 @@ OPEN_ISSUES = [
 CLOSED_ISSUES = [
     to_magic_mock_issue(issue)
     for issue in (
-        tuples.Issue(
+        apimeta.Issue(
             "This is a closed issue",
             "With an uninteresting body that has a single very,"
             "very long line that would probably break the implementation "
@@ -49,7 +49,7 @@ CLOSED_ISSUES = [
             random_date(),
             "tmore",
         ),
-        tuples.Issue(
+        apimeta.Issue(
             "Yet another closed issue",
             "Even less interesting body",
             2,
@@ -61,7 +61,7 @@ CLOSED_ISSUES = [
 
 ORG_NAME = "test-org"
 GITHUB_BASE_URL = "https://some_enterprise_host/api/v3"
-ISSUE = tuples.Issue(
+ISSUE = apimeta.Issue(
     "Oops, something went wrong!", "This is the body **with some formatting**."
 )
 PLUGINS = constants.PLUGINS
@@ -163,7 +163,7 @@ def students():
 @pytest.fixture
 def ensure_teams_and_members_mock(api_mock, students):
     api_mock.ensure_teams_and_members.side_effect = lambda member_lists: [
-        tuples.Team(student, [student], id)
+        apimeta.Team(name=str(student), members=[student], id=id)
         for id, student in enumerate(students)
     ]
 
@@ -187,7 +187,7 @@ def repo_infos(master_urls, students):
     for url in master_urls:
         repo_base_name = util.repo_name(url)
         repo_infos += [
-            tuples.Repo(
+            apimeta.Repo(
                 name=util.generate_repo_name(student, repo_base_name),
                 description="{} created for {}".format(
                     repo_base_name, student
@@ -346,7 +346,10 @@ class TestUpdateStudentRepos:
     @pytest.mark.nogitmock
     @pytest.mark.parametrize(
         "issue",
-        [tuples.Issue("Oops", "Sorry, we failed to push to your repo!"), None],
+        [
+            apimeta.Issue("Oops", "Sorry, we failed to push to your repo!"),
+            None,
+        ],
     )
     def test_issues_on_exceptions(
         self, issue, mocker, api_mock, repo_infos, push_tuples, rmtree_mock
@@ -445,7 +448,7 @@ class TestOpenIssue:
             "c-week-2",
         ]
 
-        issue = tuples.Issue(
+        issue = apimeta.Issue(
             "A title", "And a nice **formatted** body\n### With headings!"
         )
         command.open_issue(issue, master_names, students, api_mock)
@@ -683,7 +686,7 @@ class TestAssignPeerReviewers:
         assert "num_reviews must be greater than 0"
 
     def test_happy_path(self, master_names, students, api_mock):
-        issue = tuples.Issue("this is a title", "this is a body")
+        issue = apimeta.Issue("this is a title", "this is a body")
         mappings = [
             {
                 util.generate_review_team_name(student, master_name): [
diff --git a/tests/test_github_api.py b/tests/test_github_api.py
index df8729b..9081486 100644
--- a/tests/test_github_api.py
+++ b/tests/test_github_api.py
@@ -8,7 +8,7 @@ import repobee
 from repobee import util
 from repobee import exception
 from repobee import github_api
-from repobee import tuples
+from repobee import apimeta
 from repobee.github_api import REQUIRED_OAUTH_SCOPES
 
 import constants
@@ -44,23 +44,23 @@ from_magic_mock_issue = functions.from_magic_mock_issue
 
 User = constants.User
 
-CLOSE_ISSUE = tuples.Issue(
+CLOSE_ISSUE = apimeta.Issue(
     "close this issue", "This is a body", 3, random_date(), "slarse"
 )
-DONT_CLOSE_ISSUE = tuples.Issue(
+DONT_CLOSE_ISSUE = apimeta.Issue(
     "Don't close this issue", "Another body", 4, random_date(), "glassey"
 )
 OPEN_ISSUES = [CLOSE_ISSUE, DONT_CLOSE_ISSUE]
 
 CLOSED_ISSUES = [
-    tuples.Issue(
+    apimeta.Issue(
         "This is a closed issue",
         "With an uninteresting body",
         1,
         random_date(),
         "tmore",
     ),
-    tuples.Issue(
+    apimeta.Issue(
         "Yet another closed issue",
         "Even less interesting body",
         2,
@@ -235,7 +235,7 @@ def repo_infos(teams_and_members, teams):
     descriptions = ["A nice repo for {}".format(team.name) for team in teams]
     repo_names = ["{}-week-2".format(team.name) for team in teams]
     return [
-        tuples.Repo(name, description, True, team.id)
+        apimeta.Repo(name, description, True, team.id)
         for name, description, team in zip(repo_names, descriptions, teams)
     ]
 
@@ -443,7 +443,7 @@ class TestCreateRepos:
         github.Organization.create_repo must be called without the team_id
         argument (because if it is called with team_id=None, there is a crash).
         """
-        repo = tuples.Repo(
+        repo = apimeta.Repo(
             name="repo",
             description="Some description",
             private=True,
@@ -597,6 +597,23 @@ class TestCloseIssue:
 class TestGetIssues:
     """Tests for get_issues."""
 
+    @staticmethod
+    def assert_issues_equal(actual_issues, expected_issues):
+        """The expected issues don't have the mocked implementation, while the
+        actual issues all should have it.
+        """
+        actual_issues, expected_issues = (
+            list(actual_issues),
+            list(expected_issues),
+        )
+        assert len(actual_issues) == len(expected_issues)
+        for act, exp in zip(sorted(actual_issues), sorted(expected_issues)):
+            assert act.implementation
+            for field_name in apimeta.Issue._fields:
+                if field_name == "implementation":
+                    continue
+                assert getattr(act, field_name) == getattr(exp, field_name)
+
     def test_get_all_open_issues(self, repos, issues, api):
         repo_names = [repo.name for repo in repos]
 
@@ -606,8 +623,9 @@ class TestGetIssues:
         for repo_name, issue_gen in name_issues_pairs:
             found_repos.append(repo_name)
 
-            actual_issues = list(map(from_magic_mock_issue, issue_gen))
-            assert actual_issues == OPEN_ISSUES
+            self.assert_issues_equal(
+                actual_issues=issue_gen, expected_issues=OPEN_ISSUES
+            )
 
         assert sorted(found_repos) == sorted(repo_names)
 
@@ -622,8 +640,9 @@ class TestGetIssues:
         for repo_name, issue_gen in name_issues_pairs:
             found_repos.append(repo_name)
 
-            actual_issues = list(map(from_magic_mock_issue, issue_gen))
-            assert actual_issues == CLOSED_ISSUES
+            self.assert_issues_equal(
+                actual_issues=issue_gen, expected_issues=CLOSED_ISSUES
+            )
 
         assert sorted(found_repos) == sorted(repo_names)
 
@@ -640,8 +659,9 @@ class TestGetIssues:
         for repo_name, issue_gen in name_issues_pairs:
             found_repos.append(repo_name)
 
-            actual_issues = list(map(from_magic_mock_issue, issue_gen))
-            assert actual_issues == OPEN_ISSUES
+            self.assert_issues_equal(
+                actual_issues=issue_gen, expected_issues=OPEN_ISSUES
+            )
 
         assert len(found_repos) + 1 == len(repo_names)
         assert set(found_repos) == set(repo_names) - {non_existing}
@@ -660,8 +680,9 @@ class TestGetIssues:
         for repo_name, issue_gen in name_issues_pairs:
             found_repos.append(repo_name)
 
-            actual_issues = list(map(from_magic_mock_issue, issue_gen))
-            assert actual_issues == [sought_issue]
+            self.assert_issues_equal(
+                actual_issues=issue_gen, expected_issues=[sought_issue]
+            )
 
         assert sorted(found_repos) == sorted(repo_names)
 
diff --git a/tests/test_gitlab_api.py b/tests/test_gitlab_api.py
index de395b1..f91e5d3 100644
--- a/tests/test_gitlab_api.py
+++ b/tests/test_gitlab_api.py
@@ -434,7 +434,7 @@ class TestCreateRepos:
             for group in constants.STUDENTS
         ]
         yield [
-            repobee.tuples.Repo(
+            repobee.apimeta.Repo(
                 name=repobee.util.generate_repo_name(group.name, master_name),
                 description="Student repo",
                 private=True,