diff --git a/docs/reference/webhooks.md b/docs/reference/webhooks.md deleted file mode 100644 index d0c0a8dad..000000000 --- a/docs/reference/webhooks.md +++ /dev/null @@ -1,20 +0,0 @@ -# Webhooks - -This section purpose is to manage the [Webhooks](https://docs.gitlab.com/ee/user/project/integrations/webhooks.html) (aka Project Hooks). - -The key names here are webhook URLs and values are as parameters described at [edit project hooks endpoint](https://docs.gitlab.com/ee/api/projects.html#edit-project-hook), **except the id and hook_id**. - -If the only value is `delete: true` then the given webhook is going to be removed. - -Example: -```yaml -projects_and_groups: - group_1/project_1: - hooks: - "http://host.domain.com/some-old-hook-you-want-to-remove-from-config": - delete: true - "http://127.0.0.1:5000/hooks/merge-request": - push_events: false # this is set to true by GitLab API by default - merge_requests_events: true - token: some_secret_auth_token -``` diff --git a/gitlabform/configuration/transform.py b/gitlabform/configuration/transform.py index 618fea83e..9003e03d9 100644 --- a/gitlabform/configuration/transform.py +++ b/gitlabform/configuration/transform.py @@ -225,9 +225,9 @@ def _do_transform(self, configuration: Configuration): for node_coordinate in processor.get_nodes(path, mustexist=True): try: access_level_string = str(node_coordinate.node) - node_coordinate.parent[ - node_coordinate.parentref - ] = AccessLevel.get_value(access_level_string) + node_coordinate.parent[node_coordinate.parentref] = ( + AccessLevel.get_value(access_level_string) + ) except KeyError: fatal( f"Configuration string '{access_level_string}' is not one of the valid access levels:" @@ -255,9 +255,9 @@ def _do_transform(self, configuration: Configuration): if node_coordinate.parentref == "access_level": try: access_level_string = str(node_coordinate.node) - node_coordinate.parent[ - node_coordinate.parentref - ] = AccessLevel.get_value(access_level_string) + node_coordinate.parent[node_coordinate.parentref] = ( + AccessLevel.get_value(access_level_string) + ) except KeyError: fatal( f"Configuration string '{access_level_string}' is not one of the valid access levels:" @@ -313,9 +313,9 @@ def _do_transform(self, configuration: Configuration) -> None: if old_syntax["approvals"]: # add the settings, if there are left any after removing 'approvals_before_merge' - where_to_add_new_syntax[ - "merge_requests_approvals" - ] = old_syntax["approvals"] + where_to_add_new_syntax["merge_requests_approvals"] = ( + old_syntax["approvals"] + ) # approval rules if ( diff --git a/gitlabform/gitlab/__init__.py b/gitlabform/gitlab/__init__.py index 4932879b8..2e9b47441 100644 --- a/gitlabform/gitlab/__init__.py +++ b/gitlabform/gitlab/__init__.py @@ -23,7 +23,6 @@ from gitlabform.gitlab.resource_groups import GitLabResourceGroups from gitlabform.gitlab.schedules import GitLabPipelineSchedules from gitlabform.gitlab.integrations import GitLabIntegrations -from gitlabform.gitlab.tags import GitLabTags from gitlabform.gitlab.users import GitLabUsers from gitlab import Gitlab @@ -60,7 +59,6 @@ class GitLab( GitLabRepositories, GitLabResourceGroups, GitLabIntegrations, - GitLabTags, GitLabGroupLDAPLinks, GitLabGroupBadges, GitLabGroupVariables, diff --git a/gitlabform/gitlab/projects.py b/gitlabform/gitlab/projects.py index 4481b9fb9..09b07a661 100644 --- a/gitlabform/gitlab/projects.py +++ b/gitlabform/gitlab/projects.py @@ -177,41 +177,6 @@ def post_project_push_rules(self, project_and_group_name: str, push_rules): "projects/%s/push_rule", pid, "POST", push_rules, expected_codes=201 ) - def get_hook_id(self, project_and_group_name, url): - hooks = self._make_requests_to_api( - "projects/%s/hooks", project_and_group_name, "GET" - ) - for hook in hooks: - if hook["url"] == url: - return hook["id"] - return None - - def delete_hook(self, project_and_group_name, hook_id): - self._make_requests_to_api( - "projects/%s/hooks/%s", - (project_and_group_name, hook_id), - "DELETE", - expected_codes=[200, 204], - ) - - def put_hook(self, project_and_group_name, hook_id, url, data): - data_required = {"url": url} - data = {**data, **data_required} - self._make_requests_to_api( - "projects/%s/hooks/%s", (project_and_group_name, hook_id), "PUT", data - ) - - def post_hook(self, project_and_group_name, url, data): - data_required = {"url": url} - data = {**data, **data_required} - self._make_requests_to_api( - "projects/%s/hooks", - project_and_group_name, - "POST", - data, - expected_codes=201, - ) - def get_groups_from_project(self, project_and_group_name): # couldn't find an API call that was giving me directly # the shared groups, so I'm using directly the GET /projects/:id call diff --git a/gitlabform/gitlab/tags.py b/gitlabform/gitlab/tags.py deleted file mode 100644 index 6285e1395..000000000 --- a/gitlabform/gitlab/tags.py +++ /dev/null @@ -1,45 +0,0 @@ -from gitlabform.gitlab.core import GitLabCore - - -class GitLabTags(GitLabCore): - def get_tags(self, project_and_group_name): - return self._make_requests_to_api( - "projects/%s/repository/tags", project_and_group_name - ) - - def delete_tag(self, project_and_group_name, tag_name): - self._make_requests_to_api( - "projects/%s/repository/tags/%s", - (project_and_group_name, tag_name), - method="DELETE", - expected_codes=[200, 204], - ) - - def get_protected_tags(self, project_and_group_name): - return self._make_requests_to_api( - "projects/%s/protected_tags", - project_and_group_name, - ) - - def protect_tag( - self, project_and_group_name, tag_name, allowed_to_create, create_access_level - ): - data = {} - if allowed_to_create is not None: - data["allowed_to_create"] = allowed_to_create - if create_access_level is not None: - data["create_access_level"] = create_access_level - - url = "projects/%s/protected_tags?name=%s" - parameters_list = [project_and_group_name, tag_name] - return self._make_requests_to_api( - url, tuple(parameters_list), method="POST", expected_codes=201, json=data - ) - - def unprotect_tag(self, project_and_group_name, tag_name): - return self._make_requests_to_api( - "projects/%s/protected_tags/%s", - (project_and_group_name, tag_name), - method="DELETE", - expected_codes=[201, 204], - ) diff --git a/gitlabform/processors/group/group_members_processor.py b/gitlabform/processors/group/group_members_processor.py index 102093cf6..7bdd2f11c 100644 --- a/gitlabform/processors/group/group_members_processor.py +++ b/gitlabform/processors/group/group_members_processor.py @@ -70,9 +70,9 @@ def _process_groups( groups_before_by_group_path = dict() for share_details in groups_before: - groups_before_by_group_path[ - share_details["group_full_path"] - ] = share_details + groups_before_by_group_path[share_details["group_full_path"]] = ( + share_details + ) for share_with_group_path in groups_to_set_by_group_path: group_access_to_set = groups_to_set_by_group_path[share_with_group_path][ diff --git a/gitlabform/processors/project/hooks_processor.py b/gitlabform/processors/project/hooks_processor.py index 57f35bc1a..b448e0d2b 100644 --- a/gitlabform/processors/project/hooks_processor.py +++ b/gitlabform/processors/project/hooks_processor.py @@ -1,4 +1,8 @@ from logging import debug +from typing import Dict, Any, List + +from gitlab.base import RESTObject, RESTObjectList +from gitlab.v4.objects import Project from gitlabform.gitlab import GitLab from gitlabform.processors.abstract_processor import AbstractProcessor @@ -9,23 +13,54 @@ def __init__(self, gitlab: GitLab): super().__init__("hooks", gitlab) def _process_configuration(self, project_and_group: str, configuration: dict): - for hook in sorted(configuration["hooks"]): + debug("Processing hooks...") + project: Project = self.gl.projects.get(project_and_group) + hooks_list: RESTObjectList | List[RESTObject] = project.hooks.list() + config_hooks: tuple[str, ...] = tuple( + x for x in sorted(configuration["hooks"]) if x != "enforce" + ) + + for hook in config_hooks: + gitlab_hook: RESTObject | None = next( + (h for h in hooks_list if h.url == hook), None + ) + hook_id = gitlab_hook.id if gitlab_hook else None if configuration.get("hooks|" + hook + "|delete"): - hook_id = self.gitlab.get_hook_id(project_and_group, hook) if hook_id: debug("Deleting hook '%s'", hook) - self.gitlab.delete_hook(project_and_group, hook_id) + project.hooks.delete(hook_id) else: debug("Not deleting hook '%s', because it doesn't exist", hook) else: - hook_id = self.gitlab.get_hook_id(project_and_group, hook) - if hook_id: - debug("Changing existing hook '%s'", hook) - self.gitlab.put_hook( - project_and_group, hook_id, hook, configuration["hooks"][hook] + hook_config = {"url": hook} + hook_config.update(configuration["hooks"][hook]) + gl_hook_dict = gitlab_hook.asdict() if gitlab_hook else {} + diffs = ( + map( + lambda k: hook_config[k] != gl_hook_dict[k], + hook_config.keys(), ) - else: + if gl_hook_dict + else iter(()) + ) + if not hook_id: debug("Creating hook '%s'", hook) - self.gitlab.post_hook( - project_and_group, hook, configuration["hooks"][hook] + created_hook: RESTObject = project.hooks.create(hook_config) + debug("Created hook '%s'", created_hook) + elif hook_id and any(diffs): + debug("Changing existing hook '%s'", hook) + changed_hook: Dict[str, Any] = project.hooks.update( + hook_id, hook_config + ) + debug("Changed hook to '%s'", changed_hook) + elif hook_id and not any(diffs): + debug(f"Hook {hook} remains unchanged") + + if configuration.get("hooks|enforce"): + for gh in hooks_list: + if gh.url not in config_hooks: + debug( + "Deleting hook '%s' currently setup in the project but it is not in the configuration and enforce is enabled", + gh.url, ) + project.hooks.delete(gh.id) diff --git a/gitlabform/processors/project/tags_processor.py b/gitlabform/processors/project/tags_processor.py index 43c52d64c..7290ed143 100644 --- a/gitlabform/processors/project/tags_processor.py +++ b/gitlabform/processors/project/tags_processor.py @@ -3,7 +3,7 @@ from gitlabform.constants import EXIT_PROCESSING_ERROR from gitlabform.gitlab import GitLab -from gitlabform.gitlab.core import NotFoundException +from gitlab import GitlabDeleteError, GitlabGetError from gitlabform.processors.abstract_processor import AbstractProcessor @@ -12,7 +12,17 @@ def __init__(self, gitlab: GitLab, strict: bool): super().__init__("tags", gitlab) self.strict = strict + def _get_user_by_username(self, username): + user = self.gl.users.list(username=username) + if len(user) == 0: + raise GitlabGetError( + "No users found when searching for username '%s'" % username, 404 + ) + return user[0] + def _process_configuration(self, project_and_group: str, configuration: dict): + project = self.gl.projects.get(id=project_and_group, lazy=True) + for tag in sorted(configuration["tags"]): try: if configuration["tags"][tag]["protected"]: @@ -33,13 +43,13 @@ def _process_configuration(self, project_and_group: str, configuration: dict): elif "user_id" in config: user_ids.add(config["user_id"]) elif "user" in config: - user_ids.add(self.gitlab._get_user_id(config["user"])) + gitlab_user = self._get_user_by_username(config["user"]) + user_ids.add(gitlab_user.get_id()) elif "group_id" in config: group_ids.add(config["group_id"]) elif "group" in config: - group_ids.add( - self.gitlab._get_group_id(config["group"]) - ) + gitlab_group = self.gl.groups.get(config["group"]) + group_ids.add(gitlab_group.get_id()) for val in access_levels: allowed_to_create.append({"access_level": val}) @@ -55,20 +65,26 @@ def _process_configuration(self, project_and_group: str, configuration: dict): if "create_access_level" in configuration["tags"][tag] else None ) + debug("Setting tag '%s' as *protected*", tag) try: # try to unprotect first - self.gitlab.unprotect_tag(project_and_group, tag) - except NotFoundException: + project.protectedtags.delete(tag) + except GitlabDeleteError: pass - self.gitlab.protect_tag( - project_and_group, tag, allowed_to_create, create_access_level - ) + + data = {} + data["name"] = tag + if allowed_to_create is not None: + data["allowed_to_create"] = allowed_to_create + if create_access_level is not None: + data["create_access_level"] = create_access_level + project.protectedtags.create(data) else: debug("Setting tag '%s' as *unprotected*", tag) - self.gitlab.unprotect_tag(project_and_group, tag) - except NotFoundException: - message = f"Tag '{tag}' not found when trying to set it as protected/unprotected!" + project.protectedtags.delete(tag) + except GitlabDeleteError: + message = f"Tag '{tag}' not found when trying to unprotect it!" if self.strict: fatal( message, @@ -76,3 +92,11 @@ def _process_configuration(self, project_and_group: str, configuration: dict): ) else: warning(message) + except GitlabGetError as e: + if self.strict: + fatal( + e, + exit_code=EXIT_PROCESSING_ERROR, + ) + else: + warning(message) diff --git a/gitlabform/processors/shared/protected_environments_processor.py b/gitlabform/processors/shared/protected_environments_processor.py index cf6476818..e4c1432f8 100644 --- a/gitlabform/processors/shared/protected_environments_processor.py +++ b/gitlabform/processors/shared/protected_environments_processor.py @@ -17,6 +17,6 @@ def __init__(self, gitlab: GitLab): required_to_create_or_update=And(Key("name"), Key("deploy_access_levels")), ) - self.custom_diff_analyzers[ - "deploy_access_levels" - ] = self.recursive_diff_analyzer + self.custom_diff_analyzers["deploy_access_levels"] = ( + self.recursive_diff_analyzer + ) diff --git a/gitlabform/processors/util/difference_logger.py b/gitlabform/processors/util/difference_logger.py index 2f782028b..55ba8e5ea 100644 --- a/gitlabform/processors/util/difference_logger.py +++ b/gitlabform/processors/util/difference_logger.py @@ -43,9 +43,9 @@ def log_diff( if hide_entries: changes = list( map( - lambda i: [i[0], hide(i[1]), hide(i[2])] - if i[0] in hide_entries - else i, + lambda i: ( + [i[0], hide(i[1]), hide(i[2])] if i[0] in hide_entries else i + ), changes, ) ) diff --git a/setup.py b/setup.py index 737cde191..f58bf7c23 100644 --- a/setup.py +++ b/setup.py @@ -47,26 +47,26 @@ def get_version_file_path(): "certifi", # we want the latest root certs for security "cli-ui==0.17.2", "ez-yaml==1.2.0", - "Jinja2==3.1.2", + "Jinja2==3.1.3", "luddite==1.0.2", - "MarkupSafe==2.1.3", + "MarkupSafe==2.1.5", "mergedeep==1.3.4", "packaging==23.2", - "python-gitlab==4.2.0", + "python-gitlab==4.4.0", "requests==2.31.0", "ruamel.yaml==0.17.21", - "types-requests==2.31.0.10", - "yamlpath==3.8.1", + "types-requests==2.31.0.20240125", + "yamlpath==3.8.2", ], extras_require={ "test": [ - "coverage==7.3.2", - "cryptography==41.0.5", + "coverage==7.4.1", + "cryptography==42.0.2", "deepdiff==6.7.1", - "mypy==1.7.1", + "mypy==1.8.0", "mypy-extensions==1.0.0", "pre-commit==2.21.0", # not really for tests, but for development - "pytest==7.4.3", + "pytest==7.4.4", "pytest-cov==4.1.0", "pytest-rerunfailures==13.0", "xkcdpass==1.19.8", diff --git a/tests/acceptance/standard/test_hooks.py b/tests/acceptance/standard/test_hooks.py new file mode 100644 index 000000000..8182f5cb2 --- /dev/null +++ b/tests/acceptance/standard/test_hooks.py @@ -0,0 +1,188 @@ +import logging +import pytest +from typing import TYPE_CHECKING + +from tests.acceptance import run_gitlabform, get_random_name + + +LOGGER = logging.getLogger(__name__) + + +@pytest.fixture(scope="class") +def urls(): + first_name = get_random_name("hook") + second_name = get_random_name("hook") + first_url = f"http://hooks/{first_name}.org" + second_url = f"http://hooks/{second_name}.org" + return first_url, second_url + + +class TestHooksProcessor: + def get_hook_from_url(self, project, url): + return next(h for h in project.hooks.list() if h.url == url) + + def test_hooks_create(self, gl, project, urls): + target = project.path_with_namespace + first_url, second_url = urls + + test_yaml = f""" + projects_and_groups: + {target}: + hooks: + {first_url}: + push_events: false + merge_requests_events: true + {second_url}: + job_events: true + note_events: true + """ + + run_gitlabform(test_yaml, target) + + first_created_hook = self.get_hook_from_url(project, first_url) + second_created_hook = self.get_hook_from_url(project, second_url) + if TYPE_CHECKING: + assert isinstance(first_created_hook, ProjectHook) + assert isinstance(second_created_hook, ProjectHook) + assert len(project.hooks.list()) == 2 + assert ( + first_created_hook.push_events, + first_created_hook.merge_requests_events, + ) == (False, True) + assert (second_created_hook.job_events, second_created_hook.note_events) == ( + True, + True, + ) + + def test_hooks_update(self, caplog, gl, project, urls): + first_url, second_url = urls + target = project.path_with_namespace + first_hook = self.get_hook_from_url(project, first_url) + second_hook = self.get_hook_from_url(project, second_url) + + update_yaml = f""" + projects_and_groups: + {target}: + hooks: + {first_url}: + id: {first_hook.id} + merge_requests_events: false + note_events: true + {second_url}: + job_events: true + note_events: true + """ + + run_gitlabform(update_yaml, target) + updated_first_hook = self.get_hook_from_url(project, first_url) + updated_second_hook = self.get_hook_from_url(project, second_url) + + with caplog.at_level(logging.DEBUG): + assert f"Hook {second_url} remains unchanged" in caplog.text + assert f"Changing existing hook '{first_url}'" in caplog.text + assert updated_first_hook.asdict() != first_hook.asdict() + assert updated_second_hook.asdict() == second_hook.asdict() + # push events defaults to True, but it stays False + assert updated_first_hook.push_events == False + assert updated_first_hook.merge_requests_events == False + assert updated_first_hook.note_events == True + + def test_hooks_delete(self, gl, project, urls): + target = project.path_with_namespace + first_url, second_url = urls + orig_second_hook = self.get_hook_from_url(project, second_url) + + delete_yaml = f""" + projects_and_groups: + {target}: + hooks: + {first_url}: + delete: true + {second_url}: + job_events: false + note_events: false + """ + + run_gitlabform(delete_yaml, target) + hooks = project.hooks.list() + second_hook = self.get_hook_from_url(project, second_url) + + assert len(hooks) == 1 + assert first_url not in (h.url for h in hooks) + assert second_hook in hooks + assert second_hook == orig_second_hook + + def test_hooks_enforce(self, gl, group, project, urls): + target = project.path_with_namespace + first_url, second_url = urls + hooks_before_test = [h.url for h in project.hooks.list()] + assert len(hooks_before_test) == 1 + assert second_url == hooks_before_test[0] + + enforce_yaml = f""" + projects_and_groups: + {target}: + hooks: + enforce: true + {first_url}: + merge_requests_events: false + note_events: true + """ + + run_gitlabform(enforce_yaml, target) + hooks_after_test = [h.url for h in project.hooks.list()] + assert len(hooks_after_test) == 1 + assert first_url in hooks_after_test + assert second_url not in hooks_after_test + + not_enforce_yaml = f""" + projects_and_groups: + {target}: + hooks: + enforce: false + http://www.newhook.org: + merge_requests_events: false + note_events: true + """ + + run_gitlabform(not_enforce_yaml, target) + hooks_after_test = [h.url for h in project.hooks.list()] + assert len(hooks_after_test) == 2 + assert ( + first_url in hooks_after_test + and "http://www.newhook.org" in hooks_after_test + ) + + parent_target = f"{group.path}/*" + enforce_star_yaml = f""" + projects_and_groups: + {parent_target}: + hooks: + enforce: true + {first_url}: + push_events: true + {target}: + hooks: + {second_url}: + job_events: true + """ + + run_gitlabform(enforce_star_yaml, target) + hooks_after_test = [h.url for h in project.hooks.list()] + + assert len(hooks_after_test) == 2 + assert first_url in hooks_after_test and second_url in hooks_after_test + assert "http://www.newhook.org" not in hooks_after_test + + enforce_delete_yaml = f""" + projects_and_groups: + {target}: + hooks: + enforce: true + {first_url}: + delete: true + """ + + run_gitlabform(enforce_delete_yaml, target) + hooks_after_test = [h.url for h in project.hooks.list()] + assert len(hooks_after_test) == 0 diff --git a/tests/acceptance/standard/test_tags.py b/tests/acceptance/standard/test_tags.py index 40c935242..53270f1f3 100755 --- a/tests/acceptance/standard/test_tags.py +++ b/tests/acceptance/standard/test_tags.py @@ -122,6 +122,19 @@ def test__unprotect_the_same_tag(self, project, tags): protected_tags = project.protectedtags.list() assert len(protected_tags) == 0 + def test__unprotect_unknown_tag(self, project, tags): + config = f""" + projects_and_groups: + {project.path_with_namespace}: + tags: + "tag_unknown": + protected: false + """ + + # In strict mode, processor should exit immediately + with pytest.raises(SystemExit): + run_gitlabform(config, project) + def test__protect_single_tag_no_access(self, project, tags): config = f""" projects_and_groups: diff --git a/tests/acceptance/standard/test_transfer_project.py b/tests/acceptance/standard/test_transfer_project.py index c4bac4ddb..526df32d8 100644 --- a/tests/acceptance/standard/test_transfer_project.py +++ b/tests/acceptance/standard/test_transfer_project.py @@ -9,15 +9,19 @@ class TestTransferProject: - def test__transfer_between_two_root_groups(self, project, group, other_group): - project_new_path_with_namespace = f"{other_group.path}/{project.name}" + def test__transfer_between_two_root_groups( + self, project_for_function, group, other_group + ): + project_new_path_with_namespace = ( + f"{other_group.path}/{project_for_function.name}" + ) projects_in_destination_before_transfer = other_group.projects.list() config = f""" projects_and_groups: {project_new_path_with_namespace}: project: - transfer_from: {project.path_with_namespace} + transfer_from: {project_for_function.path_with_namespace} """ run_gitlabform(config, project_new_path_with_namespace) @@ -28,28 +32,6 @@ def test__transfer_between_two_root_groups(self, project, group, other_group): == len(projects_in_destination_before_transfer) + 1 ) - # Now test the transfer the opposite direction by transferring the same project back to the original group - # Transferring the project to the original location also helps because the fixtures used in this test is shared - # with other tests and they will need the fixture in its original form. - project_new_path_with_namespace = f"{group.path}/{project.name}" - project_source = f"{other_group.path}/{project.name}" - projects_in_destination_before_transfer = group.projects.list() - - config = f""" - projects_and_groups: - {project_new_path_with_namespace}: - project: - transfer_from: {project_source} - """ - - run_gitlabform(config, project_new_path_with_namespace) - projects_in_destination_after_transfer = group.projects.list() - - assert ( - len(projects_in_destination_after_transfer) - == len(projects_in_destination_before_transfer) + 1 - ) - def test__transfer_between_root_group_and_subgroup( self, project_in_subgroup, group, subgroup ): @@ -71,41 +53,19 @@ def test__transfer_between_root_group_and_subgroup( == len(projects_in_destination_before_transfer) + 1 ) - # Now test the transfer the opposite direction by transferring the same project back to the original group - # Transferring the project to the original location also helps because the fixtures used in this test is shared - # with other tests and they will need the fixture in its original form. - project_new_path_with_namespace = ( - f"{group.path}/{subgroup.path}/{project_in_subgroup.name}" - ) - project_source = f"{group.path}/{project_in_subgroup.name}" - projects_in_destination_before_transfer = subgroup.projects.list() - - config = f""" - projects_and_groups: - {project_new_path_with_namespace}: - project: - transfer_from: {project_source} - """ - - run_gitlabform(config, project_new_path_with_namespace) - projects_in_destination_after_transfer = subgroup.projects.list() - - assert ( - len(projects_in_destination_after_transfer) - == len(projects_in_destination_before_transfer) + 1 - ) - def test__transfer_as_same_path_at_namespae_already_exist( - self, project, group, other_group + self, project_for_function, group, other_group ): - project_new_path_with_namespace = f"{other_group.path}/{project.name}" + project_new_path_with_namespace = ( + f"{other_group.path}/{project_for_function.name}" + ) projects_in_destination_before_transfer = other_group.projects.list() config = f""" projects_and_groups: {project_new_path_with_namespace}: project: - transfer_from: {project.path_with_namespace} + transfer_from: {project_for_function.path_with_namespace} """ run_gitlabform(config, project_new_path_with_namespace)