Repository: python-gitlab/python-gitlab

Issue:
Add support for bulk imports API
See https://docs.gitlab.com/ee/api/bulk_imports.html


Discussion:

Resolving patch:
diff --git a/gitlab/client.py b/gitlab/client.py
--- a/gitlab/client.py
+++ b/gitlab/client.py
@@ -123,6 +123,10 @@ def __init__(
 
         self.broadcastmessages = objects.BroadcastMessageManager(self)
         """See :class:`~gitlab.v4.objects.BroadcastMessageManager`"""
+        self.bulk_imports = objects.BulkImportManager(self)
+        """See :class:`~gitlab.v4.objects.BulkImportManager`"""
+        self.bulk_import_entities = objects.BulkImportAllEntityManager(self)
+        """See :class:`~gitlab.v4.objects.BulkImportAllEntityManager`"""
         self.ci_lint = objects.CiLintManager(self)
         """See :class:`~gitlab.v4.objects.CiLintManager`"""
         self.deploykeys = objects.DeployKeyManager(self)
diff --git a/gitlab/v4/objects/__init__.py b/gitlab/v4/objects/__init__.py
--- a/gitlab/v4/objects/__init__.py
+++ b/gitlab/v4/objects/__init__.py
@@ -8,6 +8,7 @@
 from .boards import *
 from .branches import *
 from .broadcast_messages import *
+from .bulk_imports import *
 from .ci_lint import *
 from .clusters import *
 from .commits import *
diff --git a/gitlab/v4/objects/bulk_imports.py b/gitlab/v4/objects/bulk_imports.py
new file mode 100644
--- /dev/null
+++ b/gitlab/v4/objects/bulk_imports.py
@@ -0,0 +1,54 @@
+from typing import Any, cast, Union
+
+from gitlab.base import RESTManager, RESTObject
+from gitlab.mixins import CreateMixin, ListMixin, RefreshMixin, RetrieveMixin
+from gitlab.types import RequiredOptional
+
+__all__ = [
+    "BulkImport",
+    "BulkImportManager",
+    "BulkImportAllEntity",
+    "BulkImportAllEntityManager",
+    "BulkImportEntity",
+    "BulkImportEntityManager",
+]
+
+
+class BulkImport(RefreshMixin, RESTObject):
+    entities: "BulkImportEntityManager"
+
+
+class BulkImportManager(CreateMixin, RetrieveMixin, RESTManager):
+    _path = "/bulk_imports"
+    _obj_cls = BulkImport
+    _create_attrs = RequiredOptional(required=("configuration", "entities"))
+    _list_filters = ("sort", "status")
+
+    def get(self, id: Union[str, int], lazy: bool = False, **kwargs: Any) -> BulkImport:
+        return cast(BulkImport, super().get(id=id, lazy=lazy, **kwargs))
+
+
+class BulkImportEntity(RefreshMixin, RESTObject):
+    pass
+
+
+class BulkImportEntityManager(RetrieveMixin, RESTManager):
+    _path = "/bulk_imports/{bulk_import_id}/entities"
+    _obj_cls = BulkImportEntity
+    _from_parent_attrs = {"bulk_import_id": "id"}
+    _list_filters = ("sort", "status")
+
+    def get(
+        self, id: Union[str, int], lazy: bool = False, **kwargs: Any
+    ) -> BulkImportEntity:
+        return cast(BulkImportEntity, super().get(id=id, lazy=lazy, **kwargs))
+
+
+class BulkImportAllEntity(RESTObject):
+    pass
+
+
+class BulkImportAllEntityManager(ListMixin, RESTManager):
+    _path = "/bulk_imports/entities"
+    _obj_cls = BulkImportAllEntity
+    _list_filters = ("sort", "status")


Test patch:
diff --git a/tests/functional/api/test_bulk_imports.py b/tests/functional/api/test_bulk_imports.py
new file mode 100644
--- /dev/null
+++ b/tests/functional/api/test_bulk_imports.py
@@ -0,0 +1,41 @@
+def test_bulk_imports(gl, group):
+    destination = f"{group.full_path}-import"
+    configuration = {
+        "url": gl.url,
+        "access_token": gl.private_token,
+    }
+    migration_entity = {
+        "source_full_path": group.full_path,
+        "source_type": "group_entity",
+        "destination_slug": destination,
+        "destination_namespace": destination,
+    }
+    created_migration = gl.bulk_imports.create(
+        {
+            "configuration": configuration,
+            "entities": [migration_entity],
+        }
+    )
+
+    assert created_migration.source_type == "gitlab"
+    assert created_migration.status == "created"
+
+    migration = gl.bulk_imports.get(created_migration.id)
+    assert migration == created_migration
+
+    migration.refresh()
+    assert migration == created_migration
+
+    migrations = gl.bulk_imports.list()
+    assert migration in migrations
+
+    all_entities = gl.bulk_import_entities.list()
+    entities = migration.entities.list()
+    assert isinstance(entities, list)
+    assert entities[0] in all_entities
+
+    entity = migration.entities.get(entities[0].id)
+    assert entity == entities[0]
+
+    entity.refresh()
+    assert entity.created_at == entities[0].created_at
diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py
--- a/tests/unit/conftest.py
+++ b/tests/unit/conftest.py
@@ -92,3 +92,8 @@ def release(project, tag_name):
 @pytest.fixture
 def user(gl):
     return gl.users.get(1, lazy=True)
+
+
+@pytest.fixture
+def migration(gl):
+    return gl.bulk_imports.get(1, lazy=True)
diff --git a/tests/unit/objects/test_bulk_imports.py b/tests/unit/objects/test_bulk_imports.py
new file mode 100644
--- /dev/null
+++ b/tests/unit/objects/test_bulk_imports.py
@@ -0,0 +1,159 @@
+"""
+GitLab API: https://docs.gitlab.com/ce/api/bulk_imports.html
+"""
+
+import pytest
+import responses
+
+from gitlab.v4.objects import BulkImport, BulkImportAllEntity, BulkImportEntity
+
+migration_content = {
+    "id": 1,
+    "status": "finished",
+    "source_type": "gitlab",
+    "created_at": "2021-06-18T09:45:55.358Z",
+    "updated_at": "2021-06-18T09:46:27.003Z",
+}
+entity_content = {
+    "id": 1,
+    "bulk_import_id": 1,
+    "status": "finished",
+    "source_full_path": "source_group",
+    "destination_slug": "destination_slug",
+    "destination_namespace": "destination_path",
+    "parent_id": None,
+    "namespace_id": 1,
+    "project_id": None,
+    "created_at": "2021-06-18T09:47:37.390Z",
+    "updated_at": "2021-06-18T09:47:51.867Z",
+    "failures": [],
+}
+
+
+@pytest.fixture
+def resp_create_bulk_import():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/bulk_imports",
+            json=migration_content,
+            content_type="application/json",
+            status=201,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_list_bulk_imports():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/bulk_imports",
+            json=[migration_content],
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_get_bulk_import():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/bulk_imports/1",
+            json=migration_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_list_all_bulk_import_entities():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/bulk_imports/entities",
+            json=[entity_content],
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_list_bulk_import_entities():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/bulk_imports/1/entities",
+            json=[entity_content],
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_get_bulk_import_entity():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/bulk_imports/1/entities/1",
+            json=entity_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+def test_create_bulk_import(gl, resp_create_bulk_import):
+    configuration = {
+        "url": gl.url,
+        "access_token": "test-token",
+    }
+    migration_entity = {
+        "source_full_path": "source",
+        "source_type": "group_entity",
+        "destination_slug": "destination",
+        "destination_namespace": "destination",
+    }
+    migration = gl.bulk_imports.create(
+        {
+            "configuration": configuration,
+            "entities": [migration_entity],
+        }
+    )
+    assert isinstance(migration, BulkImport)
+    assert migration.status == "finished"
+
+
+def test_list_bulk_imports(gl, resp_list_bulk_imports):
+    migrations = gl.bulk_imports.list()
+    assert isinstance(migrations[0], BulkImport)
+    assert migrations[0].status == "finished"
+
+
+def test_get_bulk_import(gl, resp_get_bulk_import):
+    migration = gl.bulk_imports.get(1)
+    assert isinstance(migration, BulkImport)
+    assert migration.status == "finished"
+
+
+def test_list_all_bulk_import_entities(gl, resp_list_all_bulk_import_entities):
+    entities = gl.bulk_import_entities.list()
+    assert isinstance(entities[0], BulkImportAllEntity)
+    assert entities[0].bulk_import_id == 1
+
+
+def test_list_bulk_import_entities(gl, migration, resp_list_bulk_import_entities):
+    entities = migration.entities.list()
+    assert isinstance(entities[0], BulkImportEntity)
+    assert entities[0].bulk_import_id == 1
+
+
+def test_get_bulk_import_entity(gl, migration, resp_get_bulk_import_entity):
+    entity = migration.entities.get(1)
+    assert isinstance(entity, BulkImportEntity)
+    assert entity.bulk_import_id == 1