codecov-io: # [Codecov](https://codecov.io/gh/repobee/repobee/pull/186?src=pr&el=h1) Report
> Merging [#186](https://codecov.io/gh/repobee/repobee/pull/186?src=pr&el=desc) into [master](https://codecov.io/gh/repobee/repobee/commit/aa55df0f0e245e67b07938e9aac1f6299064da06?src=pr&el=desc) will **decrease** coverage by `0.17%`.
> The diff coverage is `100%`.

[![Impacted file tree graph](https://codecov.io/gh/repobee/repobee/pull/186/graphs/tree.svg?width=650&token=5vOKgkphZe&height=150&src=pr)](https://codecov.io/gh/repobee/repobee/pull/186?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master     #186      +/-   ##
==========================================
- Coverage   92.94%   92.77%   -0.17%     
==========================================
  Files          17       17              
  Lines        1134     1135       +1     
  Branches      198      198              
==========================================
- Hits         1054     1053       -1     
- Misses         65       67       +2     
  Partials       15       15
```


| [Impacted Files](https://codecov.io/gh/repobee/repobee/pull/186?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [repobee/github\_api.py](https://codecov.io/gh/repobee/repobee/pull/186/diff?src=pr&el=tree#diff-cmVwb2JlZS9naXRodWJfYXBpLnB5) | `92.27% <100%> (+0.06%)` | :arrow_up: |
| [repobee/command.py](https://codecov.io/gh/repobee/repobee/pull/186/diff?src=pr&el=tree#diff-cmVwb2JlZS9jb21tYW5kLnB5) | `95.34% <100%> (-0.03%)` | :arrow_down: |
| [repobee/cli.py](https://codecov.io/gh/repobee/repobee/pull/186/diff?src=pr&el=tree#diff-cmVwb2JlZS9jbGkucHk=) | `97.46% <0%> (-0.85%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/repobee/repobee/pull/186?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/repobee/repobee/pull/186?src=pr&el=footer). Last update [aa55df0...81ac240](https://codecov.io/gh/repobee/repobee/pull/186?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

diff --git a/repobee/command.py b/repobee/command.py
index 7a11982..86887b6 100644
--- a/repobee/command.py
+++ b/repobee/command.py
@@ -166,9 +166,8 @@ def update_student_repos(
         raise ValueError("master_repo_urls contains duplicates")
 
     master_repo_names = [util.repo_name(url) for url in urls]
-    student_repo_names = util.generate_repo_names(students, master_repo_names)
 
-    repo_urls = api.get_repo_urls(student_repo_names)
+    repo_urls = api.get_repo_urls(master_repo_names, students=students)
 
     with tempfile.TemporaryDirectory() as tmpdir:
         LOGGER.info("cloning into master repos ...")
@@ -356,8 +355,7 @@ def clone_repos(
         students: Student usernames.
         api: A GitHubAPI instance.
     """
-    repo_names = util.generate_repo_names(students, master_repo_names)
-    repo_urls = api.get_repo_urls(repo_names)
+    repo_urls = api.get_repo_urls(master_repo_names, students=students)
 
     LOGGER.info("cloning into student repos ...")
     git.clone(repo_urls)
@@ -365,6 +363,7 @@ def clone_repos(
     if (
         len(plug.manager.get_plugins()) > 1
     ):  # something else than the default loaded
+        repo_names = util.generate_repo_names(students, master_repo_names)
         _execute_post_clone_hooks(repo_names, api)
 
 
diff --git a/repobee/github_api.py b/repobee/github_api.py
index d16e6e1..01e8353 100644
--- a/repobee/github_api.py
+++ b/repobee/github_api.py
@@ -20,6 +20,7 @@ import github
 
 from repobee import exception
 from repobee import tuples
+from repobee import util
 
 REQUIRED_OAUTH_SCOPES = {"admin:org", "repo"}
 
@@ -327,16 +328,23 @@ class GitHubAPI:
         return [self._insert_auth(url) for url in repo_urls]
 
     def get_repo_urls(
-        self, repo_names: Iterable[str], org_name: Optional[str] = None
+        self,
+        master_repo_names: Iterable[str],
+        org_name: Optional[str] = None,
+        students: Optional[List[tuples.Group]] = None,
     ) -> List[str]:
-        """Get repo urls for all specified repo names in organization.  Assumes
+        """Get repo urls for all specified repo names in organization. Assumes
         that the repos exist, there is no guarantee that they actually do as
         checking this with the REST API takes too much time.
 
+        If the `students` argument is supplied, student repo urls are
+        computed instead of master repo urls.
+
         Args:
-            repo_names: A list of repository names.
+            master_repo_names: A list of master repository names.
             org_name: Organization in which repos are expected. Defaults to the
                 target organization of the API instance.
+            students: A list of student groups.
 
         Returns:
             a list of urls corresponding to the repo names.
@@ -347,6 +355,11 @@ class GitHubAPI:
                 if not org_name
                 else self._github.get_organization(org_name)
             )
+        repo_names = (
+            master_repo_names
+            if not students
+            else util.generate_repo_names(students, master_repo_names)
+        )
         return [
             self._insert_auth(url)
             for url in (

Pass student usernames + master repo names to get_repo_urls
This is required for GitLab support to work, as the url to a GitLab project in a subgroup is `https://HOST/GROUP/SUBGROUP/REPO_NAME`

`GROUP` corresponds to the target organization, and SUBGROUP to the student's username.

The GitHub API can simply discard the excess information. Cant' really think of a smoother way to do it at the moment.
diff --git a/tests/test_command.py b/tests/test_command.py
index 9fa6929..9d09848 100644
--- a/tests/test_command.py
+++ b/tests/test_command.py
@@ -78,6 +78,17 @@ def generate_repo_url(name):
     return generate_team_repo_url(name, "d")[:-2]
 
 
+def get_repo_urls_fake(master_repo_names, org_name=None, students=None):
+    return list(
+        map(
+            generate_repo_url,
+            master_repo_names
+            if not students
+            else util.generate_repo_names(students, master_repo_names),
+        )
+    )
+
+
 MASTER_NAMES = ("week-1", "week-2", "week-3")
 MASTER_URLS = tuple(generate_repo_url(name) for name in MASTER_NAMES)
 
@@ -125,16 +136,15 @@ def _get_issues(repo_names, state="open", title_regex=""):
 def api_mock(request, mocker):
     if "noapimock" in request.keywords:
         return
-    mock = MagicMock(spec=repobee.command.GitHubAPI)
-    api_class = mocker.patch("repobee.command.GitHubAPI", autospec=True)
-    api_class.return_value = mock
 
     def url_from_repo_info(repo_info):
         return generate_repo_url(repo_info.name)
 
-    mock.get_repo_urls.side_effect = lambda repo_names: list(
-        map(generate_repo_url, repo_names)
-    )
+    mock = MagicMock(spec=repobee.command.GitHubAPI)
+    api_class = mocker.patch("repobee.command.GitHubAPI", autospec=True)
+    api_class.return_value = mock
+
+    mock.get_repo_urls.side_effect = get_repo_urls_fake
     mock.create_repos.side_effect = lambda repo_infos: list(
         map(url_from_repo_info, repo_infos)
     )
@@ -328,10 +338,6 @@ class TestUpdateStudentRepos:
             call(url, cwd=str(tmpdir)) for url in master_urls
         ]
 
-        api_mock.get_repo_urls.side_effect = lambda repo_names: list(
-            map(generate_repo_url, repo_names)
-        )
-
         command.update_student_repos(master_urls, students, api_mock)
 
         git_mock.clone_single.assert_has_calls(expected_clone_calls)
@@ -358,14 +364,13 @@ class TestUpdateStudentRepos:
             for name in [master_name, "week-3"]
         ]
 
-        fail_repo_names = [
-            util.generate_repo_name(stud, master_name) for stud in ["a", "c"]
-        ]
-        fail_repo_urls = [self.generate_url(name) for name in fail_repo_names]
-
-        api_mock.get_repo_urls.side_effect = lambda repo_names: [
-            self.generate_url(name) for name in repo_names
-        ]
+        fail_students = ["a", "c"]
+        fail_repo_names = util.generate_repo_names(
+            fail_students, [master_name]
+        )
+        fail_repo_urls = get_repo_urls_fake(
+            [master_name], students=fail_students
+        )
 
         async def raise_specific(pt):
             if pt.repo_url in fail_repo_urls:
@@ -411,10 +416,6 @@ class TestUpdateStudentRepos:
         ]
         fail_repo_urls = [self.generate_url(name) for name in fail_repo_names]
 
-        api_mock.get_repo_urls.side_effect = lambda repo_names: [
-            self.generate_url(name) for name in repo_names
-        ]
-
         async def raise_specific(pt):
             if pt.repo_url in fail_repo_urls:
                 raise exception.PushFailedError(
diff --git a/tests/test_github_api.py b/tests/test_github_api.py
index c087bff..5985226 100644
--- a/tests/test_github_api.py
+++ b/tests/test_github_api.py
@@ -471,6 +471,24 @@ class TestGetRepoUrls:
         for url in urls:
             assert "{}:{}".format(USER, TOKEN) in url
 
+    def test_with_students(self, repos, api):
+        """Test that supplying students causes student repo names to be
+        generated as the Cartesian product of the supplied repo names and the
+        students.
+        """
+        students = list(constants.STUDENTS)
+        master_repo_names = [repo.name for repo in repos]
+        expected_repo_names = util.generate_repo_names(
+            students, master_repo_names
+        )
+        # assume works correctly when called with just repo names
+        expected_urls = api.get_repo_urls(expected_repo_names)
+
+        actual_urls = api.get_repo_urls(master_repo_names, students=students)
+
+        assert len(actual_urls) == len(students) * len(master_repo_names)
+        assert sorted(expected_urls) == sorted(actual_urls)
+
 
 class TestOpenIssue:
     """Tests for open_issue."""

pip install -e .
parse_log_pytest
requirements.txt
pytest pytest-cov pytest-mock codecov
3.7
requirements.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
appdirs==1.4.4
certifi @ file:///croot/certifi_1671487769961/work/certifi
cffi==1.15.1
charset-normalizer==3.4.1
codecov==2.1.13
colored==1.4.4
coverage==7.2.7
cryptography==44.0.2
daiquiri==3.2.1
Deprecated==1.2.18
exceptiongroup==1.2.2
idna==3.10
importlib-metadata==6.7.0
iniconfig==2.0.0
packaging==24.0
pluggy==1.2.0
pycparser==2.21
PyGithub==2.3.0
PyJWT==2.8.0
PyNaCl==1.5.0
pytest==7.4.4
pytest-cov==4.1.0
pytest-mock==3.11.1
python-json-logger==3.0.1
-e git+https://github.com/repobee/repobee.git@aa55df0f0e245e67b07938e9aac1f6299064da06#egg=repobee
repobee-plug==0.5.1
requests==2.31.0
tomli==2.0.1
typing_extensions==4.7.1
urllib3==2.0.7
wrapt==1.16.0
zipp==3.15.0

name: repobee
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - certifi=2022.12.7=py37h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=1.1.1w=h7f8727e_0
  - pip=22.3.1=py37h06a4308_0
  - python=3.7.16=h7a1cb2a_0
  - readline=8.2=h5eee18b_0
  - setuptools=65.6.3=py37h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - wheel=0.38.4=py37h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - appdirs==1.4.4
      - cffi==1.15.1
      - charset-normalizer==3.4.1
      - codecov==2.1.13
      - colored==1.4.4
      - coverage==7.2.7
      - cryptography==44.0.2
      - daiquiri==3.2.1
      - deprecated==1.2.18
      - exceptiongroup==1.2.2
      - idna==3.10
      - importlib-metadata==6.7.0
      - iniconfig==2.0.0
      - packaging==24.0
      - pluggy==1.2.0
      - pycparser==2.21
      - pygithub==2.3.0
      - pyjwt==2.8.0
      - pynacl==1.5.0
      - pytest==7.4.4
      - pytest-cov==4.1.0
      - pytest-mock==3.11.1
      - python-json-logger==3.0.1
      - repobee==1.3.2
      - repobee-plug==0.5.1
      - requests==2.31.0
      - tomli==2.0.1
      - typing-extensions==4.7.1
      - urllib3==2.0.7
      - wrapt==1.16.0
      - zipp==3.15.0
prefix: /opt/conda/envs/repobee

tests/test_github_api.py::TestGetRepoUrls::test_with_students
tests/test_command.py::TestSetupStudentRepos::test_raises_on_clone_failure tests/test_command.py::TestSetupStudentRepos::test_raises_on_duplicate_master_urls tests/test_command.py::TestSetupStudentRepos::test_happy_path tests/test_command.py::TestUpdateStudentRepos::test_raises_on_duplicate_master_urls tests/test_command.py::TestUpdateStudentRepos::test_happy_path tests/test_command.py::TestUpdateStudentRepos::test_issues_on_exceptions[issue0] tests/test_command.py::TestUpdateStudentRepos::test_issues_on_exceptions[None] tests/test_command.py::TestUpdateStudentRepos::test_issues_arent_opened_on_exceptions_if_unspeficied tests/test_command.py::TestOpenIssue::test_happy_path tests/test_command.py::TestCloseIssue::test_happy_path tests/test_command.py::TestCloneRepos::test_happy_path tests/test_command.py::TestCloneRepos::test_executes_act_hooks tests/test_command.py::TestMigrateRepo::test_happy_path tests/test_command.py::TestListIssues::test_happy_path[None-True--open] tests/test_command.py::TestListIssues::test_happy_path[None-True--closed] tests/test_command.py::TestListIssues::test_happy_path[None-True--all] tests/test_command.py::TestListIssues::test_happy_path[None-True-^.*$-open] tests/test_command.py::TestListIssues::test_happy_path[None-True-^.*$-closed] tests/test_command.py::TestListIssues::test_happy_path[None-True-^.*$-all] tests/test_command.py::TestListIssues::test_happy_path[None-False--open] tests/test_command.py::TestListIssues::test_happy_path[None-False--closed] tests/test_command.py::TestListIssues::test_happy_path[None-False--all] tests/test_command.py::TestListIssues::test_happy_path[None-False-^.*$-open] tests/test_command.py::TestListIssues::test_happy_path[None-False-^.*$-closed] tests/test_command.py::TestListIssues::test_happy_path[None-False-^.*$-all] tests/test_command.py::TestListIssues::test_happy_path[slarse-True--open] tests/test_command.py::TestListIssues::test_happy_path[slarse-True--closed] tests/test_command.py::TestListIssues::test_happy_path[slarse-True--all] tests/test_command.py::TestListIssues::test_happy_path[slarse-True-^.*$-open] tests/test_command.py::TestListIssues::test_happy_path[slarse-True-^.*$-closed] tests/test_command.py::TestListIssues::test_happy_path[slarse-True-^.*$-all] tests/test_command.py::TestListIssues::test_happy_path[slarse-False--open] tests/test_command.py::TestListIssues::test_happy_path[slarse-False--closed] tests/test_command.py::TestListIssues::test_happy_path[slarse-False--all] tests/test_command.py::TestListIssues::test_happy_path[slarse-False-^.*$-open] tests/test_command.py::TestListIssues::test_happy_path[slarse-False-^.*$-closed] tests/test_command.py::TestListIssues::test_happy_path[slarse-False-^.*$-all] tests/test_command.py::test_purge_review_teams tests/test_command.py::TestAssignPeerReviewers::test_too_few_students_raises[3-3] tests/test_command.py::TestAssignPeerReviewers::test_too_few_students_raises[3-4] tests/test_command.py::TestAssignPeerReviewers::test_zero_reviews_raises tests/test_command.py::TestAssignPeerReviewers::test_happy_path tests/test_command.py::TestCheckPeerReviewProgress::test_happy_path tests/test_github_api.py::TestEnsureTeamsAndMembers::test_no_previous_teams tests/test_github_api.py::TestEnsureTeamsAndMembers::test_all_teams_exist_but_without_members tests/test_github_api.py::TestEnsureTeamsAndMembers::test_raises_on_non_422_exception[unexpected_exc0] tests/test_github_api.py::TestEnsureTeamsAndMembers::test_raises_on_non_422_exception[unexpected_exc1] tests/test_github_api.py::TestEnsureTeamsAndMembers::test_raises_on_non_422_exception[unexpected_exc2] tests/test_github_api.py::TestEnsureTeamsAndMembers::test_running_twice_has_no_ill_effects tests/test_github_api.py::TestCreateRepos::test_creates_correct_repos tests/test_github_api.py::TestCreateRepos::test_skips_existing_repos tests/test_github_api.py::TestCreateRepos::test_raises_on_unexpected_error[unexpected_exception0] tests/test_github_api.py::TestCreateRepos::test_raises_on_unexpected_error[unexpected_exception1] tests/test_github_api.py::TestCreateRepos::test_raises_on_unexpected_error[unexpected_exception2] tests/test_github_api.py::TestCreateRepos::test_returns_all_urls tests/test_github_api.py::TestGetRepoUrls::test_with_token_without_user tests/test_github_api.py::TestGetRepoUrls::test_with_token_and_user tests/test_github_api.py::TestOpenIssue::test_on_existing_repos tests/test_github_api.py::TestOpenIssue::test_on_some_non_existing_repos tests/test_github_api.py::TestOpenIssue::test_no_crash_when_no_repos_are_found tests/test_github_api.py::TestCloseIssue::test_closes_correct_issues tests/test_github_api.py::TestCloseIssue::test_no_crash_if_no_repos_found tests/test_github_api.py::TestCloseIssue::test_no_crash_if_no_issues_found tests/test_github_api.py::TestGetIssues::test_get_all_open_issues tests/test_github_api.py::TestGetIssues::test_get_all_closed_issues tests/test_github_api.py::TestGetIssues::test_get_issues_when_one_repo_doesnt_exist tests/test_github_api.py::TestGetIssues::test_get_open_issues_by_regex tests/test_github_api.py::TestAddReposToReviewTeams::test_with_default_issue tests/test_github_api.py::TestAddReposToTeams::test_happy_path tests/test_github_api.py::TestDeleteTeams::test_delete_non_existing_teams_does_not_crash tests/test_github_api.py::TestDeleteTeams::test_delete_existing_teams tests/test_github_api.py::TestVerifySettings::test_happy_path tests/test_github_api.py::TestVerifySettings::test_empty_token_raises_bad_credentials tests/test_github_api.py::TestVerifySettings::test_incorrect_info_raises_not_found_error[get_user] tests/test_github_api.py::TestVerifySettings::test_incorrect_info_raises_not_found_error[get_organization] tests/test_github_api.py::TestVerifySettings::test_bad_token_scope_raises tests/test_github_api.py::TestVerifySettings::test_not_owner_raises tests/test_github_api.py::TestVerifySettings::test_raises_unexpected_exception_on_unexpected_status tests/test_github_api.py::TestVerifySettings::test_none_user_raises tests/test_github_api.py::TestVerifySettings::test_mismatching_user_login_raises tests/test_github_api.py::TestGetPeerReviewProgress::test_nothing_returns tests/test_github_api.py::TestGetPeerReviewProgress::test_with_review_teams_but_no_repos
repobee/github_api.py repobee/command.py
repobee/github_api.py repobee/command.py