Repository: repobee/repobee (language: python)

Issue:
Extended syntax of students file
After having used GitHub Classroom last year for my course of about 160 students, I intend to switch to Repobee this year. After some preliminary tests, repobee appears to satisfy my needs and has a big advantage in that I can use GitLab instead of GitHub.

I have, however, encountered one problem. TUE students use their 8 digits TUE login id also for gitlab, which means that for a group of two students working on an assignment task1, I end up with an unintuitive repo name of the form 20173795-20172456-task1. 

Each student group also has a group id, such as 412. This groupId is useful in many ways, such as publishing group grades without compromizing privacy. What I would like to see as a repo name is then something like 412-JohnWilliams-MaryBrooks-task1.

This would require extending the syntax of the student file for example as follows:
groupId: studentLogIn1:studentName1 studentLogIn2:studentName2 ...,
where groupId: would be optional.

To achieve this, I see three possibilities:
1) I write a Python script, to rename the local student repos after having cloned them.
2) The repobee teams extends Repobee to extend the syntax of the students file.
3) I ask a programmer to extend Repobee. Although I do have the means for this (https://boost.tue.nl/projects/ict-tools-to-support-tpil-in-project-groups/), it does not seem the most efficient way to achieve my goal.

The first option is of course always possible. The second option would be preferable from a usability perspective, making Repobee more user friendly and more accessible for a larger audience. What are your thoughts on this? If adding student names would be too much work, just extending the students file with group ids would already be a big help.


Discussion / hints:
Hi @davbeek,

Thanks for the very elaborate problem description!

While most of what you need can already be achieved with some relatively simple plugins, I think it's about time this functionality is added to RepoBee itself (so, option 2). This is not the first time I've gotten the request.

I will think about the best way to do this and get back to you. I think a standardized format (e.g. YAML) for the students file would be better than a custom one. Then, further extensions could also be made without too much effort.

I'll get back to you once I've put some thought into this!
Sounds good, thanks for taking this into consideration.
@davbeek Finally coming back around to this problem :). It's not a super easy problem to solve, but it is doable.

How high of a priority is this to you, and when would you need it to be done?
It would be very nice to have, but I could run my course based on Repobee this year in September without it. 

I would need it for my course that starts in the first week of September, so perhaps the last week of August to leave so room for testing. If it would not be ready by then, it would be useful again one year later: September 2022.
If you would start this, it would be useful to keep Huub de Beer in the loop, so that he can provide the output of his [plugin for Canvas](https://github.com/htdebeer-tue/repobee-canvas) in the new format.
I would like to, at the very least, have a preview version of this in RepoBee 3.7 which must be released before the next semester at KTH (starts late August/early September). So reasonably I'll need to start sketching ideas pretty soon, but I'm still thinking a format like YAML might be good. With something along those lines, it's pretty much endlessly extensible.
BTW, "preview version" means that it needs to be enabled with a feature flag (currently works by setting an environment variable), so I'm free to make breaking changes if I feel it's necessary.
Sounds great, would make use of Repobee a lot more user-friendly. Looking forward to it, but if it doesn't work in time, no problem!
I don't know if this would simplify your work, but it would already be a big help if I could specify for each repo a groupID for the repo name, so that each student repo could get a groupID as name, instead of the concatenation of the student IDs.
Hi @davbeek,

> so that each student repo could get a groupID as name, instead of the concatenation of the student IDs.

You could technically do this with a plugin, as the name of student repos can be overridden by implementing the [`generate_repo_name` hook function](https://docs.repobee.org/en/stable/repobee_plug/code.html#repobee_plug._corehooks.generate_repo_name).

But I'll take it into consideration when tinkering with the extended syntax, I hope to get it done this month (August) :)
> You could technically do this with a plugin, as the name of student repos can be overridden by implementing the generate_repo_name hook function.

Good to know. I might ask JingJing to do this, in case you work on the extended syntax takes longer than expected.
I'm looking at it this week. I have a few different ideas. I'll let you know what I come up with by the weekend.
Hi @davbeek,

I still haven't quite decided how to incorporate the extended students file parsing into RepoBee. However, if you need it for this term, I created a very simple plugin that implements the functionality you need (name the team anything, have any members in it). This relies on the YMAL parsing library `pyyaml`, so you need to do a `pip install pyyaml` for this to work.

To use it, just drop the code below into a Python file, run `repobee plugin install --local path/to/python_file.py` and the activate it with `repobee plugin activate`. Or run every command with the `--plug` option, that also works.

You may want to ask your TA to add some basic error handling (e.g. verify that there are no duplicated team names, ensure that each team has a non-empty list of members, etc). But given a correctly formed YAML file, this will work.

I may get around to this properly over the weekend, but in case I don't, this is at least something.

```python
"""Plugin that parses a students file in YAML format.

Top-level keys should be the name of the teams, and in turn they should contain
a subkey `members` with an array of member usernames. Here's an example:

.. code-block:: yaml

    some-team:
        members: [alice, bob, eve]
    other-team:
        members: [simon, ric]

This will parse to a team ``some-team`` with members alice, bob and eve, and
another team ``other-team`` with members simon and ric.
"""

import sys
import pathlib

import yaml
import repobee_plug as plug

from typing import Iterable


@plug.repobee_hook
def parse_students_file(
    students_file: pathlib.Path,
) -> Iterable[plug.StudentTeam]:
    students_dict = yaml.load(
        students_file.read_text(encoding=sys.getdefaultencoding()),
        Loader=yaml.SafeLoader,
    )
    return [
        plug.StudentTeam(name=name, members=data["members"])
        for name, data in students_dict.items()
    ]
```
Thanks! I am going to try and get this to work before setting up the student repos, or if I can't find the time, will ask my TA to have a look at this.
FYI I'm planning to release RepoBee 3.7 this weekend, to get all of the improvements made over the summer into general availability.

Also FYI, if you want to play around before setting up your real student repos, there's always https://gitea.repobee.org to use as a playground :)
Thanks for the upcoming new release and for the gitea playground tip. My TA has already started with Repobee and will help me setting up the student repos, using the new Repobee functionality.
Hi @davbeek,

I decided to include this functionality directly into RepoBee, and to that end created a parser for a tiny subset of YAML (see [YAMLiny](https://github.com/slarse/YAMLiny)). It took a tad bit longer than I expected (as with all things), so I'll delay the release of RepoBee 3.7 untill (hopefully) tomorrow, then including this extended syntax as a built-in plugin.

Eventually, this will just be built-in without the need for a plugin, but I don't want to lock in the exact functionality until it's been battle-tested :)

Fix patch:
diff --git a/setup.py b/setup.py
index 3a598248..2cbbe3bd 100644
--- a/setup.py
+++ b/setup.py
@@ -64,6 +64,7 @@
     "tabulate",
     "tqdm>=4.48.2",
     "typing-extensions",
+    "yamliny>=0.0.2",
 ]
 
 testhelper_resources_dir = pathlib.Path("src/repobee_testhelpers/resources")
diff --git a/src/_repobee/ext/studentsyml.py b/src/_repobee/ext/studentsyml.py
new file mode 100644
index 00000000..0bd415ef
--- /dev/null
+++ b/src/_repobee/ext/studentsyml.py
@@ -0,0 +1,61 @@
+"""Plugin that enables an extended, YAML-based syntax for the students file.
+
+.. warning::
+
+    This plugin is in early development, and may change without notice. The
+    plan is to integrate this functionality directly into RepoBee once we're
+    satisfied with how it works.
+
+The point of this plugin is to allow one to specify student teams with more
+granularity. In particular, it allows one to specify names independently of
+the members of each team. For example, to have a team ``some-team`` with
+members alice and eve and another team ``other-team`` with sole member bob,
+the following file would do the trick:
+
+.. code-block:: yml
+    :caption: students.yml
+
+    some-team:
+        members: [alice, bob]
+    other-team:
+        members: [eve]
+
+Then provide it as the ``--students-file`` with this plugin active, and all
+is well!
+"""
+
+
+import sys
+import pathlib
+
+import yamliny
+import repobee_plug as plug
+
+from typing import Iterable
+
+_MEMBERS_KEY = "members"
+
+
+@plug.repobee_hook
+def parse_students_file(
+    students_file: pathlib.Path,
+) -> Iterable[plug.StudentTeam]:
+    return [
+        _to_student_team(name=name, data=data)
+        for name, data in _parse_yamliny(students_file).items()
+    ]
+
+
+def _parse_yamliny(students_file: pathlib.Path) -> dict:
+    try:
+        return yamliny.loads(
+            students_file.read_text(encoding=sys.getdefaultencoding())
+        )
+    except yamliny.YamlinyError as exc:
+        raise plug.PlugError(f"Parse error '{students_file}': {exc}") from exc
+
+
+def _to_student_team(name: str, data: dict) -> plug.StudentTeam:
+    if _MEMBERS_KEY not in data:
+        raise plug.PlugError(f"Missing members mapping for '{name}'")
+    return plug.StudentTeam(name=name, members=data[_MEMBERS_KEY])


Test patch:
diff --git a/src/repobee_testhelpers/funcs.py b/src/repobee_testhelpers/funcs.py
index b2e0b087..b36dc18e 100644
--- a/src/repobee_testhelpers/funcs.py
+++ b/src/repobee_testhelpers/funcs.py
@@ -192,7 +192,9 @@ def get_student_teams(
     """
     api = get_api(platform_url, org_name=org_name)
     return [
-        plug.StudentTeam(members=[usr.username for usr in team.members])
+        plug.StudentTeam(
+            name=team.name, members=[usr.username for usr in team.members]
+        )
         for team in api._teams[org_name].values()
     ]
 
diff --git a/tests/new_integration_tests/test_repos.py b/tests/new_integration_tests/test_repos.py
index e46cd242..ad23c4f4 100644
--- a/tests/new_integration_tests/test_repos.py
+++ b/tests/new_integration_tests/test_repos.py
@@ -2,6 +2,7 @@
 import pathlib
 import itertools
 import shutil
+import sys
 
 from typing import List, Mapping, Tuple, Iterable
 
@@ -12,6 +13,7 @@
 import _repobee.ext.javac
 import _repobee.ext.pylint
 import _repobee.ext.squash
+import _repobee.ext.studentsyml
 
 import repobee_plug as plug
 from repobee_testhelpers import localapi
@@ -432,6 +434,34 @@ def _setup_local_repo_with_multiple_commits(self, repo_path: pathlib.Path):
         repo.git.commit("--allow-empty", "-m", "Second commit")
         repo.git.commit("--allow-empty", "-m", "Third commit")
 
+    def test_use_extended_students_syntax(self, platform_url, tmp_path):
+        students_file = tmp_path / "students.yml"
+        students_file.write_text(
+            """
+some-team:
+    members: [simon]
+other-team:
+    members: [eve, alice]
+        """.strip(),
+            encoding=sys.getdefaultencoding(),
+        )
+        expected_repo_names = plug.generate_repo_names(
+            team_names=["some-team", "other-team"],
+            assignment_names=const.TEMPLATE_REPO_NAMES,
+        )
+
+        funcs.run_repobee(
+            f"{plug.cli.CoreCommand.repos.setup} --base-url {platform_url} "
+            f"--students-file {students_file} "
+            f"--assignments {const.TEMPLATE_REPOS_ARG}",
+            plugins=[_repobee.ext.studentsyml],
+        )
+
+        actual_repo_names = [
+            repo.name for repo in funcs.get_repos(platform_url)
+        ]
+        assert sorted(actual_repo_names) == sorted(expected_repo_names)
+
 
 class TestClone:
     """Tests for the ``repos clone`` command."""
diff --git a/tests/new_integration_tests/test_teams.py b/tests/new_integration_tests/test_teams.py
index a0701631..e85ba599 100644
--- a/tests/new_integration_tests/test_teams.py
+++ b/tests/new_integration_tests/test_teams.py
@@ -1,8 +1,13 @@
 """Tests for the teams category of commands."""
+import sys
+
+import repobee_plug as plug
 
 from repobee_testhelpers import funcs
 from repobee_testhelpers import const
 
+import _repobee.ext.studentsyml
+
 
 class TestCreate:
     """Tests for ``teams create``."""
@@ -20,3 +25,29 @@ def test_run_twice(self, platform_url):
         assert sorted(funcs.get_student_teams(platform_url)) == sorted(
             const.STUDENT_TEAMS
         )
+
+    def test_run_with_extended_students_syntax(self, platform_url, tmp_path):
+        students_file = tmp_path / "students.yml"
+        students_file.write_text(
+            """
+some-team:
+    members: [simon]
+other-team:
+    members: [eve, alice]
+        """.strip(),
+            encoding=sys.getdefaultencoding(),
+        )
+        expected_teams = [
+            plug.StudentTeam(name="some-team", members=["simon"]),
+            plug.StudentTeam(name="other-team", members=["eve", "alice"]),
+        ]
+
+        funcs.run_repobee(
+            f"{plug.cli.CoreCommand.teams.create} --base-url {platform_url} "
+            f"--students-file {students_file}",
+            plugins=[_repobee.ext.studentsyml],
+        )
+
+        assert sorted(funcs.get_student_teams(platform_url)) == sorted(
+            expected_teams
+        )
diff --git a/tests/unit_tests/repobee/plugin_tests/test_studentsyml.py b/tests/unit_tests/repobee/plugin_tests/test_studentsyml.py
new file mode 100644
index 00000000..94ec0d73
--- /dev/null
+++ b/tests/unit_tests/repobee/plugin_tests/test_studentsyml.py
@@ -0,0 +1,41 @@
+import sys
+
+import pytest
+import repobee_plug as plug
+
+from _repobee.ext import studentsyml
+
+
+def test_raises_PlugError_on_bad_syntax(tmp_path):
+    students_file = tmp_path / "students.yml"
+    students_file.write_text(
+        """
+first-team:
+    members: [first, second, third
+)
+""",
+        encoding=sys.getdefaultencoding(),
+    )
+
+    with pytest.raises(plug.PlugError) as exc_info:
+        studentsyml.parse_students_file(students_file)
+
+    assert f"Parse error '{students_file}': Line 2" in str(exc_info.value)
+
+
+def test_raises_PlugError_on_misspelled_members_mapping(tmp_path):
+    students_file = tmp_path / "students.yml"
+    students_file.write_text(
+        """
+first-team:
+    members: [first, second]
+second-team:
+    member: [third]
+""",
+        encoding=sys.getdefaultencoding(),
+    )
+
+    with pytest.raises(plug.PlugError) as exc_info:
+        studentsyml.parse_students_file(students_file)
+
+    assert "Missing members mapping for 'second-team'" in str(exc_info.value)