def _delete_anonymous_repos( assignment_names: Iterable[str], student_teams: Iterable[plug.StudentTeam], double_blind_key: str, api: plug.PlatformAPI, ): """Delete any anonymous repos created for these students and assignments. """ anon_repo_names = [ _hash_if_key( plug.generate_repo_name(student_team, assignment_name), key=double_blind_key, ) for student_team, assignment_name in itertools.product( student_teams, assignment_names) ] anon_repo_urls = api.get_repo_urls(anon_repo_names) anon_repos = api.get_repos(anon_repo_urls) anon_repos_progress = plug.cli.io.progress_bar( anon_repos, desc="Deleting anonymous repo copies", total=len(anon_repo_names), ) for repo in anon_repos_progress: api.delete_repo(repo) progresswrappers.end_progress(anon_repos_progress)
def test_assign_to_nonexisting_students( self, with_student_repos, extra_args ): """If you try to assign reviews where one or more of the allocated student repos don't exist, there should be an error. """ assignment_name = assignment_names[1] non_existing_group = "non-existing-group" student_team_names = STUDENT_TEAM_NAMES + [non_existing_group] command = " ".join( [ REPOBEE_GITLAB, *repobee_plug.cli.CoreCommand.reviews.assign.as_name_tuple(), *BASE_ARGS_NO_TB, "-a", assignment_name, "-s", *student_team_names, "-n", "1", ] ) result = run_in_docker_with_coverage(command, extra_args=extra_args) output = result.stdout.decode("utf-8") assert ( "[ERROR] NotFoundError: Can't find repos: {}".format( plug.generate_repo_name(non_existing_group, assignment_name) ) in output ) assert result.returncode == 1 assert_num_issues(STUDENT_TEAMS, [assignment_name], 0)
def assert_cloned_student_repos_match_templates( student_teams: List[plug.StudentTeam], template_repo_names: List[str], workdir: pathlib.Path, ): repos_dict = { plug.generate_repo_name(team.name, template_repo_name): workdir / team.name / plug.generate_repo_name(team.name, template_repo_name) for team in student_teams for template_repo_name in template_repo_names } _assert_repos_match_templates( student_teams, template_repo_names, funcs.template_repo_hashes(), repos_dict, )
def _create_push_tuples( teams: List[plug.Team], template_repos: Iterable[plug.TemplateRepo], api: plug.PlatformAPI, ) -> Iterable[Tuple[bool, PushSpec]]: """Create push tuples for newly created repos. Repos that already exist are ignored. Args: teams: An iterable of teams. template_repos: Template repositories. api: A platform API instance. Returns: A list of tuples (created, push_tuple) for all student repo urls that relate to any of the master repo urls. ``created`` indicates whether or not the student repo was created in this invocation. """ for team, template_repo in itertools.product(teams, template_repos): repo_name = plug.generate_repo_name(team, template_repo.name) created, repo = _create_or_fetch_repo( name=repo_name, description=f"{repo_name} created for {team.name}", private=True, team=team, api=api, ) yield created, PushSpec( local_path=template_repo.path, repo_url=api.insert_auth(repo.url), branch=git.active_branch(template_repo.path), metadata=dict(repo=repo, team=team), )
def test_get_student_repo_urls(self, assignment_names): """When supplied with the students argument, the generated urls should go to the student repos related to the supplied master repos. """ # arrange api = _repobee.ext.gitlab.GitLabAPI(BASE_URL, TOKEN, TARGET_GROUP) expected_urls = [ api._insert_auth("{}/{}/{}/{}.git".format( BASE_URL, TARGET_GROUP, str(student_group), plug.generate_repo_name(str(student_group), mn), )) for student_group in constants.STUDENTS for mn in assignment_names ] assert ( expected_urls ), "there must be at least some urls for this test to make sense" # act actual_urls = api.get_repo_urls( assignment_names, team_names=[t.name for t in constants.STUDENTS], insert_auth=True, ) # assert assert sorted(actual_urls) == sorted(expected_urls)
def test_opens_issue_if_update_rejected(self, tmpdir, with_student_repos): master_repo = assignment_names[0] conflict_repo = plug.generate_repo_name(STUDENT_TEAMS[0], master_repo) filename = "superfile.super" text = "some epic content\nfor this file!" # update the master repo update_repo(master_repo, filename, text) # conflicting update in the student repo update_repo(conflict_repo, "somefile.txt", "some other content") issue = plug.Issue(title="Oops, push was rejected!", body="") issue_file = pathlib.Path(str(tmpdir)) / "issue.md" issue_file.write_text(issue.title) command = " ".join([ *repobee_plug.cli.CoreCommand.repos.update.as_name_tuple(), *TEMPLATE_ORG_ARG, *BASE_ARGS, "-a", master_repo, *STUDENTS_ARG, "--issue", issue_file.name, ]) run_repobee(command, workdir=tmpdir, plugins=[_repobee.ext.gitlab]) assert_repos_contain(STUDENT_TEAMS[1:], [master_repo], filename, text) assert_issues_exist(STUDENT_TEAMS[0:1], [master_repo], issue)
def test_assign_to_nonexisting_students(self, with_student_repos, tmpdir): """If you try to assign reviews where one or more of the allocated student repos don't exist, there should be an error. """ assignment_name = assignment_names[1] non_existing_group = "non-existing-group" student_team_names = STUDENT_TEAM_NAMES + [non_existing_group] command = " ".join([ *repobee_plug.cli.CoreCommand.reviews.assign.as_name_tuple(), *BASE_ARGS_NO_TB, "-a", assignment_name, "-s", *student_team_names, "-n", "1", ]) with pytest.raises(plug.NotFoundError) as exc_info: run_repobee(command, workdir=tmpdir, plugins=[_repobee.ext.gitlab]) non_existing_repo_name = plug.generate_repo_name( non_existing_group, assignment_name) assert f"Can't find repos: {non_existing_repo_name}" in str( exc_info.value) assert_num_issues(STUDENT_TEAMS, [assignment_name], 0)
def test_setup_once(self): """Test that running setup once results in the expected repos.""" command = re.sub( r"\s+", " ", f""" repos setup --bu https://localhost:3000/api/v1 --token {giteamanager.TEACHER_TOKEN} --user {giteamanager.TEACHER_USER} --org-name {giteamanager.TARGET_ORG_NAME} --template-org-name {giteamanager.TEMPLATE_ORG_NAME} --students {' '.join([t.members[0] for t in giteamanager.STUDENT_TEAMS])} --assignments {' '.join(template_helpers.TEMPLATE_REPO_NAMES)} --tb """, ) repobee.run(shlex.split(command), plugins=[gitea]) for team, template_name in itertools.product( giteamanager.STUDENT_TEAMS, template_helpers.TEMPLATE_REPO_NAMES): repo_name = plug.generate_repo_name(team, template_name) sha = git.Repo(giteamanager.REPOSITORIES_ROOT / giteamanager.TARGET_ORG_NAME / (repo_name + ".git")).head.commit.tree.hexsha assert template_helpers.TASK_CONTENTS_SHAS[template_name] == sha
def callback(args: argparse.Namespace, api: plug.PlatformAPI) -> None: repo_name_to_team: Mapping[str, plug.StudentTeam] = { plug.generate_repo_name(student_team.name, assignment_name): student_team for student_team in args.students for assignment_name in args.assignments } repo_names = list(repo_name_to_team.keys()) if "multi_issues_file" in args and args.multi_issues_file is not None: issues_file = pathlib.Path(args.multi_issues_file).resolve() all_issues = _parse_multi_issues_file(issues_file) else: issues_dir = pathlib.Path(args.issues_dir).resolve() all_issues = _collect_issues(repo_names, issues_dir) issues = _extract_expected_issues(all_issues, repo_names, args.allow_missing) for repo_name, issue in issues: open_issue = args.batch_mode or _ask_for_open(issue, repo_name, args.truncation_length) if open_issue: repo = api.get_repo(repo_name, repo_name_to_team[repo_name].name) api.create_issue(issue.title, issue.body, repo) else: plug.echo("Skipping {}".format(repo_name))
def test_empty_student_repos_dont_cause_errors( self, with_student_repos, platform_url, capsys, tmp_path_factory ): """No error messages should be displayed when empty repos are cloned, and the empty repos should be on disk. """ # arrange workdir = tmp_path_factory.mktemp("workdir") task_name = self._setup_empty_task(platform_url) # act funcs.run_repobee( f"repos clone -a {task_name} --base-url {platform_url} ", workdir=workdir, ) # assert assert not capsys.readouterr().err for student_team in STUDENT_TEAMS: repo = ( workdir / student_team.name / plug.generate_repo_name(student_team.name, task_name) ) assert repo.is_dir() assert [f.name for f in repo.iterdir()] == [".git"]
def _repo_tuple_generator( assignment_names: List[str], teams: List[plug.StudentTeam], api: plug.PlatformAPI, ) -> Iterable[plug.StudentRepo]: for assignment_name in assignment_names: for team in teams: url, *_ = api.get_repo_urls([assignment_name], team_names=[team.name]) name = plug.generate_repo_name(team, assignment_name) yield plug.StudentRepo(name=name, url=url, team=team)
def _setup_empty_task(self, platform_url: str) -> str: task_name = "empty-task" api = funcs.get_api(platform_url) for team in api.get_teams([t.name for t in STUDENT_TEAMS]): repo_name = plug.generate_repo_name(team.name, task_name) api.create_repo( name=repo_name, description="An empty task", private=True, team=team, ) return task_name
def test_clone_local_gitconfig(self, platform_url, with_student_repos, tmp_path): funcs.run_repobee( f"repos clone --assignments {TEMPLATE_REPOS_ARG} " f"--base-url {platform_url}", workdir=tmp_path, ) # do a spot check on a single repo team = STUDENT_TEAMS[0] assignment_name = TEMPLATE_REPO_NAMES[0] repo = git.Repo(tmp_path / str(team) / plug.generate_repo_name(team, assignment_name)) assert "pull.ff=only" in repo.git.config("--local", "--list")
def test_aborts_if_issue_is_missing(self, with_issues, parsed_args_issues_dir, api_mock, tmp_path): """Test that the callback exits with a plug.PlugError if any of the expected issues is not found. """ repo_without_issue = plug.generate_repo_name(STUDENT_TEAM_NAMES[-1], ASSIGNMENT_NAMES[0]) missing_file = tmp_path / "{}.md".format(repo_without_issue) missing_file.unlink() with pytest.raises(plug.PlugError) as exc_info: feedback.callback(args=parsed_args_issues_dir, api=api_mock) assert repo_without_issue in str(exc_info.value) assert not api_mock.create_issue.called
def test_ignores_missing_issue_if_allow_missing(self, with_issues, parsed_args_issues_dir, api_mock, tmp_path): """Test that missing issues are ignored if --allow-mising is set.""" repo_without_issue = plug.generate_repo_name(STUDENT_TEAM_NAMES[-1], ASSIGNMENT_NAMES[0]) (tmp_path / "{}.md".format(repo_without_issue)).unlink() expected_calls = [ mock.call(issue.title, issue.body, mock.ANY) for repo_name, issue in with_issues if repo_name != repo_without_issue ] args_dict = vars(parsed_args_issues_dir) args_dict["allow_missing"] = True args = argparse.Namespace(**args_dict) feedback.callback(args=args, api=api_mock) api_mock.create_issue.assert_has_calls(expected_calls, any_order=True)
def repo_names(api, assignment_names): """Setup repo tuples along with groups for the repos to be created in.""" target_group_id = api._gitlab.tests_only_target_group_id groups = [ api._gitlab.groups.create( dict(name=str(team), path=str(team), parent_id=target_group_id)) for team in constants.STUDENTS ] repo_names = [] for group, assignment in itertools.product(groups, assignment_names): repo_name = plug.generate_repo_name(group.name, assignment) api._gitlab.projects.create( dict( name=repo_name, path=repo_name, description="Some description", visibility="private", namespace_id=group.id, )) repo_names.append(repo_name) return repo_names
def test_raises_on_path_clash_with_non_git_directory( self, platform_url, tmp_path, with_student_repos): """Test that an error is raised if there is a path clash between a student repository and a non-git directory. """ # arrange self._clone_all_student_repos(platform_url, tmp_path) non_git_dir = ( tmp_path / str(STUDENT_TEAMS[0]) / plug.generate_repo_name(STUDENT_TEAMS[0], TEMPLATE_REPO_NAMES[0])) shutil.rmtree(non_git_dir / ".git") # act/assert with pytest.raises(exception.RepoBeeException) as exc_info: funcs.run_repobee( f"repos clone -a {const.TEMPLATE_REPOS_ARG} " f"--base-url {platform_url}", workdir=tmp_path, ) assert (f"name clash with directory that is not a Git repository: " f"'{non_git_dir}'" in str(exc_info))
user=USER, template_repo_urls=REPO_URLS, assignments=ASSIGNMENT_NAMES, students=list(STUDENTS), issue=ISSUE, title_regex="some regex", traceback=False, state=plug.IssueState.OPEN, show_body=True, author=None, token=TOKEN, num_reviews=1, hook_results_file=None, repos=[ plug.StudentRepo( name=plug.generate_repo_name(team, master_name), team=team, url=generate_repo_url( plug.generate_repo_name(team, master_name), ORG_NAME ), ) for team, master_name in itertools.product(STUDENTS, ASSIGNMENT_NAMES) ], secrets=False, update_local=False, double_blind_key=None, directory_layout=fileutil.DirectoryLayout.BY_TEAM, ) @pytest.fixture(autouse=True)
def assign_peer_reviews( assignment_names: Iterable[str], teams: Iterable[plug.StudentTeam], num_reviews: int, issue: Optional[plug.Issue], api: plug.PlatformAPI, ) -> None: """Assign peer reviewers among the students to each student repo. Each student is assigned to review num_reviews repos, and consequently, each repo gets reviewed by num_reviews reviewers. In practice, each student repo has a review team generated (called <student-repo-name>-review), to which num_reviews _other_ students are assigned. The team itself is given pull-access to the student repo, so that reviewers can view code and open issues, but cannot modify the contents of the repo. Args: assignment_names: Names of assginments. teams: Team objects specifying student groups. num_reviews: Amount of reviews each student should perform (consequently, the amount of reviews of each repo) issue: An issue with review instructions to be opened in the considered repos. api: An implementation of :py:class:`repobee_plug.PlatformAPI` used to interface with the platform (e.g. GitHub or GitLab) instance. """ issue = issue or DEFAULT_REVIEW_ISSUE expected_repo_names = plug.generate_repo_names(teams, assignment_names) fetched_teams = progresswrappers.get_teams(teams, api, desc="Fetching teams and repos") fetched_repos = list( itertools.chain.from_iterable(map(api.get_team_repos, fetched_teams))) fetched_repo_dict = {r.name: r for r in fetched_repos} missing = set(expected_repo_names) - set(fetched_repo_dict.keys()) if missing: raise plug.NotFoundError(f"Can't find repos: {', '.join(missing)}") for assignment_name in assignment_names: plug.echo("Allocating reviews") allocations = plug.manager.hook.generate_review_allocations( teams=teams, num_reviews=num_reviews) # adjust names of review teams review_team_specs, reviewed_team_names = list( zip(*[( plug.StudentTeam( members=alloc.review_team.members, name=plug.generate_review_team_name( str(alloc.reviewed_team), assignment_name), ), alloc.reviewed_team, ) for alloc in allocations])) review_teams = _repobee.command.teams.create_teams( review_team_specs, plug.TeamPermission.PULL, api) review_teams_progress = plug.cli.io.progress_bar( review_teams, desc="Creating review teams", total=len(review_team_specs), ) for review_team, reviewed_team_name in zip(review_teams_progress, reviewed_team_names): reviewed_repo = fetched_repo_dict[plug.generate_repo_name( reviewed_team_name, assignment_name)] review_teams_progress.write( # type: ignore f"Assigning {' and '.join(review_team.members)} " f"to review {reviewed_repo.name}") api.assign_repo(review_team, reviewed_repo, plug.TeamPermission.PULL) api.create_issue( issue.title, issue.body, reviewed_repo, assignees=review_team.members, )
def command(self): nonlocal actual_repo_name actual_repo_name = plug.generate_repo_name( team_name=student, assignment_name=assignment)
def assign_peer_reviews( assignment_names: Iterable[str], teams: Iterable[plug.StudentTeam], num_reviews: int, issue: Optional[plug.Issue], double_blind_key: Optional[str], api: plug.PlatformAPI, ) -> None: """Assign peer reviewers among the students to each student repo. Each student is assigned to review num_reviews repos, and consequently, each repo gets reviewed by num_reviews reviewers. In practice, each student repo has a review team generated (called <student-repo-name>-review), to which num_reviews _other_ students are assigned. The team itself is given pull-access to the student repo, so that reviewers can view code and open issues, but cannot modify the contents of the repo. Args: assignment_names: Names of assginments. teams: Team objects specifying student groups. num_reviews: Amount of reviews each student should perform (consequently, the amount of reviews of each repo) issue: An issue with review instructions to be opened in the considered repos. double_blind_key: If provided, use key to make double-blind review allocation. api: An implementation of :py:class:`repobee_plug.PlatformAPI` used to interface with the platform (e.g. GitHub or GitLab) instance. """ issue = issue or DEFAULT_REVIEW_ISSUE expected_repo_names = set(plug.generate_repo_names(teams, assignment_names)) fetched_teams = progresswrappers.get_teams(teams, api, desc="Fetching teams and repos") team_repo_tuples = [(team, list(api.get_team_repos(team))) for team in fetched_teams] fetched_repos = list( itertools.chain.from_iterable(repos for _, repos in team_repo_tuples)) fetched_repo_dict = {r.name: r for r in fetched_repos} missing = expected_repo_names - fetched_repo_dict.keys() if missing: raise plug.NotFoundError(f"Can't find repos: {', '.join(missing)}") if double_blind_key: plug.log.info(f"Creating anonymous repos with key: {double_blind_key}") fetched_repo_dict = _create_anonymized_repos( [(team, _only_expected_repos(repos, expected_repo_names)) for team, repos in team_repo_tuples], double_blind_key, api, ) allocations_for_output = [] for assignment_name in assignment_names: plug.echo("Allocating reviews") allocations = plug.manager.hook.generate_review_allocations( teams=teams, num_reviews=num_reviews) # adjust names of review teams review_team_specs, reviewed_team_names = list( zip(*[( plug.StudentTeam( members=alloc.review_team.members, name=_review_team_name( alloc.reviewed_team, assignment_name, key=double_blind_key, ), ), alloc.reviewed_team, ) for alloc in allocations])) review_teams = _repobee.command.teams.create_teams( review_team_specs, plug.TeamPermission.PULL, api) review_teams_progress = plug.cli.io.progress_bar( review_teams, desc="Creating review teams", total=len(review_team_specs), ) for review_team, reviewed_team_name in zip(review_teams_progress, reviewed_team_names): reviewed_repo = fetched_repo_dict[plug.generate_repo_name( reviewed_team_name, assignment_name)] review_teams_progress.write( # type: ignore f"Assigning {' and '.join(review_team.members)} " f"to review {reviewed_repo.name}") api.assign_repo(review_team, reviewed_repo, plug.TeamPermission.PULL) api.create_issue( issue.title, issue.body, reviewed_repo, # It's not possible to assign users with read-access in Gitea # FIXME redesign so Gitea does not require special handling assignees=review_team.members if not isinstance(api, _repobee.ext.gitea.GiteaAPI) else None, ) allocations_for_output.append({ "reviewed_repo": { "name": reviewed_repo.name, "url": reviewed_repo.url, }, "review_team": { "name": review_team.name, "members": review_team.members, }, }) if featflags.is_feature_enabled( featflags.FeatureFlag.REPOBEE_4_REVIEW_COMMANDS): output = dict(allocations=allocations_for_output, num_reviews=num_reviews) pathlib.Path("review_allocations.json").write_text( json.dumps(output, indent=4), encoding=sys.getdefaultencoding(), )