diff --git a/conftest.py b/conftest.py index 47c714845b94ce09e66cc34f15de176992f2278a..fe7021db623af73f0dfd0643e17b0109b8f334f3 100644 --- a/conftest.py +++ b/conftest.py @@ -177,7 +177,7 @@ def to_be_teared_down() -> Iterator[Callable[[Resource], None]]: def standalone_assignment_submission( standalone_assignment: Assignment, ) -> Iterator[Submission]: - standalone_assignment.ensure_submission_repo() + standalone_assignment.ensure_submission_repo(initialized=True) yield standalone_assignment.submission() standalone_assignment.forge.remove_project(standalone_assignment.submission_path()) diff --git a/travo/assignment.py b/travo/assignment.py index f546b5afc3fda1d39ec3c52f99196eea9331c026..3bb16d3e631a54656a6d95acb91aa4edbd166fc5 100755 --- a/travo/assignment.py +++ b/travo/assignment.py @@ -201,12 +201,18 @@ class Assignment: except ResourceNotFoundError: return False - def ensure_submission_repo(self, leader_name: Optional[str] = None) -> Project: + def ensure_submission_repo( + self, leader_name: Optional[str] = None, initialized: bool = False + ) -> Project: """ Return the submission repository for this assignment Creating it and configuring it if needed. + Beware that, by default, this does not initialize the repository content from + the assignment upon creation. + + Example:: >>> assignment = getfixture("standalone_assignment") @@ -241,6 +247,7 @@ class Assignment: emails_disabled=True, default_branch=repo.default_branch, jobs_enabled=self.jobs_enabled_for_students, + initialized=initialized, ) if my_repo.default_branch == "null": web_url = my_repo.web_url @@ -981,23 +988,21 @@ class Submission: second entry is a dictionary mapping usernames of team mates to their submissions. """ - repo = self.repo + # Fetch the repo and user name of the leader forked_from_project = self.repo.forked_from_project - if forked_from_project is None: - raise ValueError( - "Team information not available " - "for a project with no forked_from information" - ) - leader_name = self.assignment.get_submission_username(forked_from_project) - if leader_name is not None: - repo = forked_from_project + if forked_from_project is not None: + leader_repo = forked_from_project + leader_name = self.assignment.get_submission_username(leader_repo) else: - leader_name = self.assignment.get_submission_username(repo) + leader_name = None + if leader_name is None: + leader_repo = self.repo + leader_name = self.assignment.get_submission_username(leader_repo) assert leader_name is not None - - team = {leader_name: repo} - if repo.forks_count > 0: - for fork in repo.get_forks(simple=True): + # Fetch the team information + team = {leader_name: leader_repo} + if leader_repo.forks_count > 0: + for fork in leader_repo.get_forks(simple=True): username = self.assignment.get_submission_username(fork) assert username is not None team[username] = fork diff --git a/travo/gitlab.py b/travo/gitlab.py index 4ee2f188bba01c092058c656ad5438abd6577def..2f9531673de81f3509c8289eeadf16bed5fdffc5 100644 --- a/travo/gitlab.py +++ b/travo/gitlab.py @@ -1301,15 +1301,69 @@ class Project(Resource): time.sleep(1) return forge.get_project(id) + def ensure_is_fork_of(self, forked_from: Union["Project", str]) -> "Project": + """ + Ensure that this project is a fork of the given project + + Return this project, or an updated copy of it if needed + """ + forked_from_path = ( + forked_from.path_with_namespace + if isinstance(forked_from, Project) + else forked_from + ) + + # Check the fork relationship and update it if needed + if ( + self.forked_from_project is not None + and self.forked_from_project.path_with_namespace == forked_from_path + ): + return self + # The fork relationship needs to be set or updated + self.gitlab.log.info( + "Setting fork relation " + f"from {self.path_with_namespace} " + f"to {forked_from_path}" + ) + + if not isinstance(forked_from, Project): + # This is both to check the existence of the requested + # forked from project and recover its id + forked_from = self.gitlab.get_project(path=forked_from_path) + + # In some cases, fork.forked_from_project may be None even if + # the project actually has a fork relation set. This happens for + # exemple with GitLab 15.3.3 in the following scenario + # - C is a fork of B which is a fork of A + # - B gets deleted + # - C still appears as fork of A in the user interface; trying + # to set its fork relation to something else fails; C does not + # appear in the list of forks of A + # We therefore systematically try to delete the fork relation; it + # fails silently if there is none. + self.gitlab.delete(f"/projects/{self.id}/fork") + + json = self.gitlab.post(f"/projects/{self.id}/fork/{forked_from.id}").json() + if "message" in json: + raise RuntimeError(f"failed: {json['message']}") + self = Project(gitlab=self.gitlab, **json) + assert self.forked_from_project is not None + assert self.forked_from_project.path_with_namespace == forked_from_path + return self + + # Note: the types are set to Any for the optional arguments for + # compatibility with passing **attributes which are of type Any def ensure_fork( self, path: str, name: str, forked_from_path: Any = None, # Optional[Union[str, Unknown]] forked_from_missing: Any = None, # Callable[] + initialized: Any = False, # bool **attributes: Any, ) -> "Project": - """Ensure that `path` is a fork of `self` with given name and attributes + """ + Ensure that `path` is a fork of `self` with given name and attributes Creating the fork and configuring it if needed. @@ -1355,11 +1409,15 @@ class Project(Resource): support choosing the target path and namespace (nor setting attributes at once?). As a workaround, the current implementation creates the target project independently, and - then set the fork relationship. + then sets the fork relationship. Caveats: - The operation is not atomic, therefore more fragile - - The repository data is *not* transferred + - By default, the repository of the fork is *not* + initialized. If `initialized` is set to True, then upon + creation the default branch of the repository is initialized + with that of the origin. + Bonus: - Unlike the forking operation in the API, this is a @@ -1382,6 +1440,8 @@ class Project(Resource): # namespace=os.path.dirname(path), # name=name, # **attributes) + if forked_from_path is None: + forked_from_path = self.path_with_namespace if forked_from_path is unknown: # Won't be able to create the fork or to set the fork relationship # Complain if this is required @@ -1391,7 +1451,43 @@ class Project(Resource): forked_from_missing() if fork.forked_from_project is None: forked_from_missing() + fork = self.gitlab.ensure_project(path=path, name=name, **attributes) + + # If the repository is empty (the default branch does not + # exist) then initialize the repository with the content + # of the default branch of the original repository + def is_initialized(repo: Project) -> bool: + try: + fork.get_branch(fork.default_branch) + return True + except ResourceNotFoundError: + return False + + if initialized and not is_initialized(fork): + if forked_from_path is unknown: + forked_from_missing() + self.gitlab.log.info(_("initializing submission")) + forked_from = self.gitlab.get_project(forked_from_path) + with tempfile.TemporaryDirectory() as tmpdirname: + assignment_dir = os.path.join(tmpdirname, self.name) + branch = forked_from.default_branch + fork.gitlab.git( + [ + "clone", + forked_from.http_url_with_base_to_repo(), + assignment_dir, + ] + ) + assert os.path.isdir(assignment_dir) + fork.gitlab.git( + ["push", fork.http_url_with_base_to_repo(), branch], + cwd=assignment_dir, + ) + # Reload project, since the default branch (and other + # properties?) may have changed + fork = fork.gitlab.get_project(fork.id) + if forked_from_path is unknown: # Just check that the fork of fork relationship is consistent f = fork @@ -1408,49 +1504,9 @@ class Project(Resource): f"is not a fork of fork of {self.path_with_namespace}" ) - if forked_from_path is None: - forked_from_path = self.path_with_namespace assert isinstance(forked_from_path, str) - # Check the fork relationship and update it if needed - if ( - fork.forked_from_project is not None - and fork.forked_from_project.path_with_namespace == forked_from_path - ): - return fork - # The fork relationship needs to be set or updated - self.gitlab.log.info( - "Setting fork relation " - f"from {fork.path_with_namespace} " - f"to {forked_from_path}" - ) - - if forked_from_path != self.path_with_namespace: - # This is both to check the existence of the requested - # forked from project and recover its id - forked_from = self.gitlab.get_project(path=forked_from_path) - else: - forked_from = self - - # In some cases, fork.forked_from_project may be None even if - # the project actually has a fork relation set. This happens for - # exemple with GitLab 15.3.3 in the following scenario - # - C is a fork of B which is a fork of A - # - B gets deleted - # - C still appears as fork of A in the user interface; trying - # to set its fork relation to something else fails; C does not - # appear in the list of forks of A - # We therefore systematically try to delete the fork relation; it - # fails silently if there is none. - self.gitlab.delete(f"/projects/{fork.id}/fork") - - json = self.gitlab.post(f"/projects/{fork.id}/fork/{forked_from.id}").json() - if "message" in json: - raise RuntimeError(f"failed: {json['message']}") - fork = Project(gitlab=self.gitlab, **json) - assert fork.forked_from_project is not None - assert fork.forked_from_project.path_with_namespace == forked_from_path - return fork + return fork.ensure_is_fork_of(forked_from_path) def share_with( self, @@ -1718,10 +1774,12 @@ class Project(Resource): """ if branch_name is None: branch_name = self.default_branch - # Could raise a ResourceNotFoundError on missing branches - return self.gitlab.get_json( - f"/projects/{self.id}/repository/branches/{branch_name}" - ) + + res = self.gitlab.get(f"/projects/{self.id}/repository/branches/{branch_name}") + json = res.json() + if json.get("message") == "404 Branch Not Found": + raise ResourceNotFoundError(f"Branch {branch_name} not found") + return json def ensure_branch( self, branch_name: Optional[str] = None, ref: Optional[str] = None @@ -1735,9 +1793,7 @@ class Project(Resource): ref = self.default_branch try: return self.get_branch(branch_name) - except requests.HTTPError: - # Could raise again if failure had an other cause than a - # missing branch + except ResourceNotFoundError: pass return self.gitlab.post_json( f"/projects/{self.id}/repository/branches", @@ -2657,6 +2713,17 @@ class GitLabTest(GitLab): >>> gitlab = getfixture('gitlab') >>> gitlab.login() + + Caveat: due to the token management (stored on file in the home + directory), there currently should not be two instances of the + class `GitLabTest` (more generally of `GitLab` for a given forge) + logged in with different GitLab users. It is therefore recommended + to construct instances of `GitLabTest` as above via the `gitlab` + test fixture which guarantees that it is unique (Singleton). + + To enable test scenarios involving several GitLab users, a context + manager `gitlab.logged_as` is provided to temporarily switch the + currently logged in user. """ base_url: str = "http://gitlab/" diff --git a/travo/tests/test_assignement.py b/travo/tests/test_assignement.py index e50585190b3e78e2c44223d41309c4e8e2051571..dad8cc8d54e4f3f98c9ca0c322f330692bd2a090 100644 --- a/travo/tests/test_assignement.py +++ b/travo/tests/test_assignement.py @@ -15,6 +15,7 @@ def test_collect( with working_directory(tmp_path): assignment.collect() assert os.path.isdir(os.path.join(tmp_path, student)) + assert os.path.isfile(os.path.join(tmp_path, student, "README.md")) assignment.collect(template="foo/bar-{path}-{username}") assert os.path.isdir(f"foo/bar-{assignment.name}-{student}") @@ -45,6 +46,8 @@ def test_fetch_from_empty_submission_repo( # The submission repository should now have a single branch named # master, and be a fork of the assignment repository my_repo = forge.get_project(path=assignment.submission_path()) + # There may be a race condition here; on at least one occasion + # the branch was not yet available when running the tests locally (branch,) = my_repo.get_branches() assert branch["name"] == "master" assert my_repo.forked_from_project is not None