Commit e5118a42 authored by Adrian DC's avatar Adrian DC
Browse files

feat(entrypoint, gitlab): add support for user namespace projects

Signed-off-by: default avatarAdrian DC <radian.dc@gmail.com>
parent 524eb145
Loading
Loading
Loading
Loading
Loading
+3 −3
Changes for README.md: 3 added lines, 3 removed lines.
Original line number Diff line number Diff line
@@ -49,7 +49,7 @@ gitlab-projects-migrate 'https://old.gitlab.com' 'group/subgroup' 'https://new.g
usage: gitlab-projects-migrate [-h] [--version] [-I INPUT_TOKEN] [-O OUTPUT_TOKEN] [--delete-sources] [--dry-run]
                               [--exclude-group] [--exclude-subgroups] [--exclude-projects] [--keep-members]
                               [--overwrite] [--set-avatar FILE] [--update-description]
                               [input_gitlab] [input_path] [output_gitlab] [output_group]
                               [input_gitlab] [input_path] [output_gitlab] [output_namespace]

gitlab-projects-migrate: Migrate GitLab projects from a GitLab group to another GitLab's group

@@ -76,9 +76,9 @@ general settings arguments:

positional arguments:
  input_gitlab          # Input GitLab URL (default: https://gitlab.com)
  input_path            # Input GitLab group or project path
  input_path            # Input GitLab group, user namespace or project path
  output_gitlab         # Output GitLab URL (default: https://gitlab.com)
  output_group          # Output GitLab group
  output_namespace      # Output GitLab group or user namespace
```

<!-- readme-help-stop -->
+114 −36
Changes for src/cli/entrypoint.py: 114 added lines, 36 removed lines.
Original line number Diff line number Diff line
@@ -6,7 +6,12 @@ from tempfile import NamedTemporaryFile

# Modules libraries
from gitlab.exceptions import GitlabGetError
from gitlab.v4.objects import Group, Project
from gitlab.v4.objects import (
    Group as GitLabGroup,
    Namespace as GitLabNamespace,
    Project as GitLabProject,
    User as GitLabUser,
)

# Components
from ..features.gitlab import GitLabFeature
@@ -22,10 +27,11 @@ class Entrypoint:
    def cli(options: Namespace) -> bool:

        # Variables
        input_group: Group = None
        input_project: Project = None
        input_group: GitLabGroup = None
        input_project: GitLabProject = None
        input_user: GitLabUser = None
        output_exists: bool = False
        output_group: Group = None
        output_namespace: GitLabNamespace = None

        # Header
        print(' ')
@@ -58,7 +64,19 @@ class Entrypoint:
                  f'{Colors.CYAN} ({Namespaces.text(input_group.description)})'
                  f'{Colors.RESET}')
            Platform.flush()
        except GitlabGetError:
        except GitlabGetError as exception:
            try:
                if '/' in options.input_path:
                    raise TypeError from exception
                input_user = input_gitlab.user(options.input_path)
                input_namespace = input_gitlab.namespace(options.input_path)
                print(f'{Colors.BOLD} - GitLab input user namespace: '
                      f'{Colors.GREEN}{input_namespace.full_path}'
                      f'{Colors.CYAN} ({input_namespace.name})'
                      f'{Colors.RESET}')
                print(' ')
                Platform.flush()
            except (GitlabGetError, TypeError):
                input_project = input_gitlab.project(options.input_path)
                print(f'{Colors.BOLD} - GitLab input project: '
                      f'{Colors.GREEN}{input_project.path_with_namespace}'
@@ -69,7 +87,8 @@ class Entrypoint:
        # Output group
        try:
            output_exists = False
            output_group = output_gitlab.group(options.output_group)
            output_group = output_gitlab.group(options.output_namespace)
            output_namespace = output_gitlab.namespace(options.output_namespace)
            output_exists = True
            print(f'{Colors.BOLD} - GitLab output group: '
                  f'{Colors.GREEN}{output_group.full_path}'
@@ -78,16 +97,30 @@ class Entrypoint:
            print(' ')
            Platform.flush()

        # Output parent group
        # Output namespace
        except GitlabGetError as exception:
            try:
                if '/' in options.output_namespace:
                    raise TypeError from exception
                _output_user = output_gitlab.user(options.output_namespace)
                output_namespace = output_gitlab.namespace(options.output_namespace)
                print(f'{Colors.BOLD} - GitLab output user namespace: '
                      f'{Colors.GREEN}{output_namespace.full_path}'
                      f'{Colors.CYAN} ({output_namespace.name})'
                      f'{Colors.RESET}')
                print(' ')
                Platform.flush()

            # Output parent group
            except (GitlabGetError, TypeError): # pylint: disable=raise-missing-from

                # Validate options
            if not input_group or options.exclude_group:
                if (not input_group and not input_user) or options.exclude_group:
                    raise exception

                # Missing output group
                print(f'{Colors.BOLD} - GitLab output group: '
                  f'{Colors.GREEN}{options.output_group}'
                      f'{Colors.GREEN}{options.output_namespace}'
                      f'{Colors.RED} (Non-existent output group)'
                      f'{Colors.RESET}')
                print(' ')
@@ -103,7 +136,7 @@ class Entrypoint:
                output_gitlab,
                input_project.path_with_namespace,
                input_project.namespace['id'],
                output_group.full_path,
                output_namespace.full_path,
            )

            # Delete input project
@@ -130,13 +163,16 @@ class Entrypoint:
                        input_gitlab,
                        output_gitlab,
                        input_group.full_path,
                        options.output_group,
                        options.output_namespace,
                        migration=not output_exists,
                ):
                    return False

                # Acquire output group
                output_group = output_gitlab.group(options.output_group, optional=True)
                # Acquire output namespace
                output_namespace = output_gitlab.namespace(
                    options.output_namespace,
                    optional=True,
                )

            # Iterate through subgroups
            if not options.exclude_subgroups or not output_exists:
@@ -155,7 +191,7 @@ class Entrypoint:
                            output_gitlab,
                            input_group.full_path,
                            input_subgroup.full_path,
                            output_group.full_path,
                            output_namespace.full_path,
                            migration=output_exists,
                    ):
                        return False
@@ -177,7 +213,7 @@ class Entrypoint:
                            output_gitlab,
                            input_project.path_with_namespace,
                            input_group.full_path,
                            output_group.full_path,
                            output_namespace.full_path,
                    ):
                        return False

@@ -195,6 +231,44 @@ class Entrypoint:
                print(' ')
                Platform.flush()

        # Handle user
        elif input_user:

            # Iterate through projects
            if not options.exclude_projects:
                for input_project in sorted(
                        input_user.projects.list(
                            get_all=True,
                            order_by='path',
                            sort='asc',
                        ),
                        key=lambda item: item.path_with_namespace,
                ):
                    if not Entrypoint.project(
                            options,
                            input_gitlab,
                            output_gitlab,
                            input_project.path_with_namespace,
                            input_namespace.full_path,
                            output_namespace.full_path,
                    ):
                        return False

                    # Delete input project
                    if options.delete_sources:
                        print(
                            f'{Colors.BOLD} - GitLab input project: '
                            f'{Colors.GREEN}{input_project.path_with_namespace}'
                            f'{Colors.CYAN} ({Namespaces.text(input_project.description)})'
                            f'{Colors.RESET}')
                        Platform.flush()
                        input_gitlab.project_delete(input_project.path_with_namespace)
                        print(f'{Colors.BOLD}   - Delete sources project: '
                              f'{Colors.GREEN}Success'
                              f'{Colors.RESET}')
                        print(' ')
                        Platform.flush()

        # Result
        return True

@@ -314,18 +388,21 @@ class Entrypoint:
        input_gitlab: GitLabFeature,
        output_gitlab: GitLabFeature,
        criteria_project: str,
        criteria_input_group: str,
        criteria_output_group: str,
        criteria_input_namespace: str,
        criteria_output_namespace: str,
    ) -> bool:

        # Acquire input project
        input_project = input_gitlab.project(criteria_project)

        # Acquire input group
        input_group = input_gitlab.group(criteria_input_group)
        # Acquire input namespace
        input_namespace = input_gitlab.namespace(criteria_input_namespace)

        # Acquire output group
        output_group = output_gitlab.group(criteria_output_group, optional=True)
        # Acquire output namespace
        output_namespace = output_gitlab.namespace(
            criteria_output_namespace,
            optional=True,
        )

        # Show project details
        print(f'{Colors.BOLD} - GitLab input project: '
@@ -336,20 +413,18 @@ class Entrypoint:

        # Ignore existing projects
        input_subpath = Namespaces.subpath(
            input_group.full_path,
            input_namespace.full_path,
            input_project.path_with_namespace,
        )
        if not options.overwrite and input_subpath in [
                Namespaces.subpath(
                    output_group.full_path,
                    output_namespace.full_path,
                    output_project.path_with_namespace,
                ) for output_project in output_group.projects.list(
                    get_all=True,
                    include_subgroups=True,
                )
                ) for output_project in output_gitlab.namespace_projects(
                    criteria_output_namespace)
        ]:
            output_project = output_gitlab.project(
                f'{output_group.full_path}/{input_subpath}')
                f'{output_namespace.full_path}/{input_subpath}')
            print(f'{Colors.BOLD}   - Already exists in GitLab output: '
                  f'{Colors.GREEN}{output_project.path_with_namespace}'
                  f'{Colors.CYAN} ({Namespaces.text(output_project.description)})'
@@ -373,21 +448,22 @@ class Entrypoint:

            # Existing project removal
            if options.overwrite:
                output_gitlab.project_delete(f'{output_group.full_path}/{input_subpath}')
                output_gitlab.project_delete(
                    f'{output_namespace.full_path}/{input_subpath}')

            # Import project
            print(f'{Colors.BOLD}   - Importing to: '
                  f'{Colors.GREEN}{options.output_group}'
                  f'{Colors.GREEN}{options.output_namespace}'
                  f'{Colors.CYAN} / {input_subpath}'
                  f'{Colors.RESET}')
            Platform.flush()
            output_namespace, output_path = Namespaces.split_namespace(
            output_subnamespace, output_path = Namespaces.split_namespace(
                input_subpath,
                relative=True,
            )
            output_project = output_gitlab.project_import(
                file_export.name,
                f'{options.output_group}{output_namespace}',
                f'{options.output_namespace}{output_subnamespace}',
                output_path,
                input_project.name,
                options.overwrite,
@@ -395,12 +471,14 @@ class Entrypoint:

        # Acquire subgroup description
        output_subgroup_description: str
        if not options.dry_run:
        if options.dry_run:
            output_subgroup_description = input_project.description
        elif output_namespace.kind == 'user':
            output_subgroup_description = output_namespace.name
        else:
            output_subgroup = output_gitlab.group(
                f'{options.output_group}{output_namespace}')
                f'{options.output_namespace}{output_subnamespace}')
            output_subgroup_description = output_subgroup.description
        else:
            output_subgroup_description = input_project.description

        # Update project description
        if options.update_description:
+4 −4
Changes for src/cli/main.py: 4 added lines, 4 removed lines.
Original line number Diff line number Diff line
@@ -156,7 +156,7 @@ def main() -> None:
        dest='input_path',
        action='store',
        nargs='?',
        help='Input GitLab group or project path',
        help='Input GitLab group, user namespace or project path',
    )
    group.add_argument(
        dest='output_gitlab',
@@ -166,11 +166,11 @@ def main() -> None:
        help='Output GitLab URL (default: https://gitlab.com)',
    )
    group.add_argument(
        dest='output_group',
        dest='output_namespace',
        action='store',
        nargs='?',
        default='',
        help='Output GitLab group',
        help='Output GitLab group or user namespace',
    )

    # Arguments parser
@@ -197,7 +197,7 @@ def main() -> None:

    # Arguments validation
    if not options.input_token or not options.input_gitlab or not options.input_path \
            or not options.output_gitlab or not options.output_group:
            or not options.output_gitlab or not options.output_namespace:
        print(' ')
        parser.print_help()
        print(' ')
+54 −2
Changes for src/features/gitlab.py: 54 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -13,7 +13,7 @@ from typing import Any, List, Optional
# Modules libraries
from gitlab import Gitlab
from gitlab.exceptions import GitlabDeleteError, GitlabGetError
from gitlab.v4.objects import Group, Project
from gitlab.v4.objects import Group, Namespace, Project, User

# Components
from ..types.namespaces import Namespaces
@@ -34,7 +34,7 @@ class GitLabFeature:
    # Group
    def group(self, criteria: str, optional: bool = False) -> Group:

        # Fake group for dry run
        # Optional group for dry run
        if self.__dry_run and optional:
            try:
                return self.group(criteria, optional=False)
@@ -53,6 +53,7 @@ class GitLabFeature:
                    def list(self, **_kwargs: Optional[Any]) -> List[str]:
                        return []

                # Create fake group
                _, path = Namespaces.split_namespace(
                    criteria,
                    relative=False,
@@ -181,6 +182,53 @@ class GitLabFeature:
            # Save group
            group.save()

    # Namespace
    def namespace(self, criteria: str, optional: bool = False) -> Namespace:

        # Optional namespace for dry run
        if self.__dry_run and optional:
            try:
                return self.namespace(criteria, optional=False)

            # Simulate namespace
            except GitlabGetError:
                NamespaceTuple = namedtuple('NamespaceTuple', [
                    'id',
                    'name',
                    'full_path',
                ])

                # Create fake namespace
                _, path = Namespaces.split_namespace(
                    criteria,
                    relative=False,
                )
                return NamespaceTuple(
                    id='dry-run',
                    name=path,
                    full_path=criteria,
                )

        # Get namespace
        return self.__gitlab.namespaces.get(criteria)

    # Namespace projects
    def namespace_projects(self, criteria: str) -> Any:

        # Acquire namespace
        namespace = self.namespace(criteria)

        # Get user projects
        if namespace.kind == 'user':
            user = self.user(criteria)
            return user.projects.list(get_all=True)

        # Get group projects
        return self.group(criteria).projects.list(
            get_all=True,
            include_subgroups=True,
        )

    # Project
    def project(self, criteria: str) -> Project:
        return self.__gitlab.projects.get(criteria)
@@ -332,6 +380,10 @@ class GitLabFeature:
            })
            sleep(5)

    # User
    def user(self, criteria: str) -> User:
        return self.__gitlab.users.list(username=criteria)[0]

    # URL
    @property
    def url(self) -> str: