From c10058e690c2de140805baa4a08a15c47e5a381a Mon Sep 17 00:00:00 2001
From: George Koltsov <gkoltsov@gitlab.com>
Date: Thu, 17 Jun 2021 16:59:31 +0100
Subject: [PATCH] Add Bulk Imports API to view user initiated imports

  - Add a few new endpoints to allow users to view
    their import attempts
  - View overall import status as well as individual
    entities import progress
  - Main purpose of this API is to allow UI to show
    more details information about the imports user
    made

Changelog: added
---
 app/finders/bulk_imports/entities_finder.rb   |  35 ++++
 app/finders/bulk_imports/imports_finder.rb    |  24 +++
 app/models/bulk_import.rb                     |   4 +
 app/models/bulk_imports/entity.rb             |   6 +
 ...t_entities_on_bulk_import_id_and_status.rb |  20 ++
 db/schema_migrations/20210629153519           |   1 +
 db/structure.sql                              |   2 +-
 doc/api/bulk_imports.md                       | 193 ++++++++++++++++++
 lib/api/api.rb                                |   1 +
 lib/api/bulk_imports.rb                       |  91 +++++++++
 lib/api/entities/bulk_import.rb               |  13 ++
 lib/api/entities/bulk_imports/entity.rb       |  22 ++
 .../entities/bulk_imports/entity_failure.rb   |  15 ++
 .../bulk_imports/entities_finder_spec.rb      |  84 ++++++++
 .../bulk_imports/imports_finder_spec.rb       |  34 +++
 spec/lib/api/entities/bulk_import_spec.rb     |  19 ++
 .../bulk_imports/entity_failure_spec.rb       |  19 ++
 .../api/entities/bulk_imports/entity_spec.rb  |  26 +++
 spec/models/bulk_import_spec.rb               |   6 +
 spec/models/bulk_imports/entity_spec.rb       |  20 ++
 spec/requests/api/bulk_imports_spec.rb        |  67 ++++++
 21 files changed, 701 insertions(+), 1 deletion(-)
 create mode 100644 app/finders/bulk_imports/entities_finder.rb
 create mode 100644 app/finders/bulk_imports/imports_finder.rb
 create mode 100644 db/migrate/20210629153519_add_index_to_bulk_import_entities_on_bulk_import_id_and_status.rb
 create mode 100644 db/schema_migrations/20210629153519
 create mode 100644 doc/api/bulk_imports.md
 create mode 100644 lib/api/bulk_imports.rb
 create mode 100644 lib/api/entities/bulk_import.rb
 create mode 100644 lib/api/entities/bulk_imports/entity.rb
 create mode 100644 lib/api/entities/bulk_imports/entity_failure.rb
 create mode 100644 spec/finders/bulk_imports/entities_finder_spec.rb
 create mode 100644 spec/finders/bulk_imports/imports_finder_spec.rb
 create mode 100644 spec/lib/api/entities/bulk_import_spec.rb
 create mode 100644 spec/lib/api/entities/bulk_imports/entity_failure_spec.rb
 create mode 100644 spec/lib/api/entities/bulk_imports/entity_spec.rb
 create mode 100644 spec/requests/api/bulk_imports_spec.rb

diff --git a/app/finders/bulk_imports/entities_finder.rb b/app/finders/bulk_imports/entities_finder.rb
new file mode 100644
index 0000000000000000..2947d1556687b300
--- /dev/null
+++ b/app/finders/bulk_imports/entities_finder.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+module BulkImports
+  class EntitiesFinder
+    def initialize(user:, bulk_import: nil, status: nil)
+      @user = user
+      @bulk_import = bulk_import
+      @status = status
+    end
+
+    def execute
+      ::BulkImports::Entity
+        .preload(:failures) # rubocop: disable CodeReuse/ActiveRecord
+        .by_user_id(user.id)
+        .then(&method(:filter_by_bulk_import))
+        .then(&method(:filter_by_status))
+    end
+
+    private
+
+    attr_reader :user, :bulk_import, :status
+
+    def filter_by_bulk_import(entities)
+      return entities unless bulk_import
+
+      entities.where(bulk_import_id: bulk_import.id) # rubocop: disable CodeReuse/ActiveRecord
+    end
+
+    def filter_by_status(entities)
+      return entities unless ::BulkImports::Entity.all_human_statuses.include?(status)
+
+      entities.with_status(status)
+    end
+  end
+end
diff --git a/app/finders/bulk_imports/imports_finder.rb b/app/finders/bulk_imports/imports_finder.rb
new file mode 100644
index 0000000000000000..b554bbfa5e7d8200
--- /dev/null
+++ b/app/finders/bulk_imports/imports_finder.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module BulkImports
+  class ImportsFinder
+    def initialize(user:, status: nil)
+      @user = user
+      @status = status
+    end
+
+    def execute
+      filter_by_status(user.bulk_imports)
+    end
+
+    private
+
+    attr_reader :user, :status
+
+    def filter_by_status(imports)
+      return imports unless BulkImport.all_human_statuses.include?(status)
+
+      imports.with_status(status)
+    end
+  end
+end
diff --git a/app/models/bulk_import.rb b/app/models/bulk_import.rb
index 04e660b418e7bac3..dee55675304f7c18 100644
--- a/app/models/bulk_import.rb
+++ b/app/models/bulk_import.rb
@@ -33,4 +33,8 @@ class BulkImport < ApplicationRecord
       transition any => :failed
     end
   end
+
+  def self.all_human_statuses
+    state_machine.states.map(&:human_name)
+  end
 end
diff --git a/app/models/bulk_imports/entity.rb b/app/models/bulk_imports/entity.rb
index bb543b39a795930c..24f86b4484163c2c 100644
--- a/app/models/bulk_imports/entity.rb
+++ b/app/models/bulk_imports/entity.rb
@@ -48,6 +48,8 @@ class BulkImports::Entity < ApplicationRecord
 
   enum source_type: { group_entity: 0, project_entity: 1 }
 
+  scope :by_user_id, ->(user_id) { joins(:bulk_import).where(bulk_imports: { user_id: user_id }) }
+
   state_machine :status, initial: :created do
     state :created, value: 0
     state :started, value: 1
@@ -68,6 +70,10 @@ class BulkImports::Entity < ApplicationRecord
     end
   end
 
+  def self.all_human_statuses
+    state_machine.states.map(&:human_name)
+  end
+
   def encoded_source_full_path
     ERB::Util.url_encode(source_full_path)
   end
diff --git a/db/migrate/20210629153519_add_index_to_bulk_import_entities_on_bulk_import_id_and_status.rb b/db/migrate/20210629153519_add_index_to_bulk_import_entities_on_bulk_import_id_and_status.rb
new file mode 100644
index 0000000000000000..c84a42cbea425b43
--- /dev/null
+++ b/db/migrate/20210629153519_add_index_to_bulk_import_entities_on_bulk_import_id_and_status.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+class AddIndexToBulkImportEntitiesOnBulkImportIdAndStatus < ActiveRecord::Migration[6.1]
+  include Gitlab::Database::MigrationHelpers
+
+  disable_ddl_transaction!
+
+  NEW_INDEX_NAME = 'index_bulk_import_entities_on_bulk_import_id_and_status'
+  OLD_INDEX_NAME = 'index_bulk_import_entities_on_bulk_import_id'
+
+  def up
+    add_concurrent_index :bulk_import_entities, [:bulk_import_id, :status], name: NEW_INDEX_NAME
+    remove_concurrent_index_by_name :bulk_import_entities, name: OLD_INDEX_NAME
+  end
+
+  def down
+    add_concurrent_index :bulk_import_entities, :bulk_import_id, name: OLD_INDEX_NAME
+    remove_concurrent_index_by_name :bulk_import_entities, name: NEW_INDEX_NAME
+  end
+end
diff --git a/db/schema_migrations/20210629153519 b/db/schema_migrations/20210629153519
new file mode 100644
index 0000000000000000..304ff5c9fa6f10e1
--- /dev/null
+++ b/db/schema_migrations/20210629153519
@@ -0,0 +1 @@
+cba36a2e8bedd70f8ccaca47517314d0a3c75a9b8d90715a29919247aa686835
\ No newline at end of file
diff --git a/db/structure.sql b/db/structure.sql
index a20a1190d21ce930..617de57688f345b5 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -22821,7 +22821,7 @@ CREATE INDEX index_broadcast_message_on_ends_at_and_broadcast_type_and_id ON bro
 
 CREATE INDEX index_bulk_import_configurations_on_bulk_import_id ON bulk_import_configurations USING btree (bulk_import_id);
 
-CREATE INDEX index_bulk_import_entities_on_bulk_import_id ON bulk_import_entities USING btree (bulk_import_id);
+CREATE INDEX index_bulk_import_entities_on_bulk_import_id_and_status ON bulk_import_entities USING btree (bulk_import_id, status);
 
 CREATE INDEX index_bulk_import_entities_on_namespace_id ON bulk_import_entities USING btree (namespace_id);
 
diff --git a/doc/api/bulk_imports.md b/doc/api/bulk_imports.md
new file mode 100644
index 0000000000000000..9521c769d490ff0d
--- /dev/null
+++ b/doc/api/bulk_imports.md
@@ -0,0 +1,193 @@
+---
+stage: Manage
+group: Import
+info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments
+---
+
+# GitLab Migrations (Bulk Imports) API
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/64335) in GitLab 14.1.
+
+With the GitLab Migrations API, you can view the progress of migrations initiated with
+[GitLab Group Migration](../user/group/import/index.md).
+
+## List all GitLab migrations
+
+```plaintext
+GET /bulk_imports
+```
+
+| Attribute  | Type    | Required | Description                            |
+|:-----------|:--------|:---------|:---------------------------------------|
+| `per_page` | integer | no       | Number of records to return per page.  |
+| `page`     | integer | no       | Page to retrieve.                      |
+| `status`   | string  | no       | Import status.                         |
+
+The status can be one of the following:
+
+- `created`
+- `started`
+- `finished`
+- `failed`
+
+```shell
+curl --request GET --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/bulk_imports?per_page=2&page=1"
+```
+
+```json
+[
+    {
+        "id": 1,
+        "status": "finished",
+        "source_type": "gitlab",
+        "created_at": "2021-06-18T09:45:55.358Z",
+        "updated_at": "2021-06-18T09:46:27.003Z"
+    },
+    {
+        "id": 2,
+        "status": "started",
+        "source_type": "gitlab",
+        "created_at": "2021-06-18T09:47:36.581Z",
+        "updated_at": "2021-06-18T09:47:58.286Z"
+    }
+]
+```
+
+## List all GitLab migrations' entities
+
+```plaintext
+GET /bulk_imports/entities
+```
+
+| Attribute  | Type    | Required | Description                            |
+|:-----------|:--------|:---------|:---------------------------------------|
+| `per_page` | integer | no       | Number of records to return per page.  |
+| `page`     | integer | no       | Page to retrieve.                      |
+| `status`   | string  | no       | Import status.                         |
+
+The status can be one of the following:
+
+- `created`
+- `started`
+- `finished`
+- `failed`
+
+```shell
+curl --request GET --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/bulk_imports/entities?per_page=2&page=1&status=started"
+```
+
+```json
+[
+    {
+        "id": 1,
+        "bulk_import_id": 1,
+        "status": "finished",
+        "source_full_path": "source_group",
+        "destination_name": "destination_name",
+        "destination_namespace": "destination_path",
+        "parent_id": null,
+        "namespace_id": 1,
+        "project_id": null,
+        "created_at": "2021-06-18T09:47:37.390Z",
+        "updated_at": "2021-06-18T09:47:51.867Z",
+        "failures": []
+    },
+    {
+        "id": 2,
+        "bulk_import_id": 2,
+        "status": "failed",
+        "source_full_path": "another_group",
+        "destination_name": "another_name",
+        "destination_namespace": "another_namespace",
+        "parent_id": null,
+        "namespace_id": null,
+        "project_id": null,
+        "created_at": "2021-06-24T10:40:20.110Z",
+        "updated_at": "2021-06-24T10:40:46.590Z",
+        "failures": [
+            {
+                "pipeline_class": "BulkImports::Groups::Pipelines::GroupPipeline",
+                "pipeline_step": "extractor",
+                "exception_class": "Exception",
+                "correlation_id_value": "dfcf583058ed4508e4c7c617bd7f0edd",
+                "created_at": "2021-06-24T10:40:46.495Z"
+            }
+        ]
+    }
+]
+```
+
+## Get GitLab migration details
+
+```plaintext
+GET /bulk_imports/:id
+```
+
+```shell
+curl --request GET --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/bulk_imports/1"
+```
+
+```json
+{
+  "id": 1,
+  "status": "finished",
+  "source_type": "gitlab",
+  "created_at": "2021-06-18T09:45:55.358Z",
+  "updated_at": "2021-06-18T09:46:27.003Z"
+}
+```
+
+## List GitLab migration entities
+
+```plaintext
+GET /bulk_imports/:id/entities
+```
+
+| Attribute  | Type    | Required | Description                            |
+|:-----------|:--------|:---------|:---------------------------------------|
+| `per_page` | integer | no       | Number of records to return per page.  |
+| `page`     | integer | no       | Page to retrieve.                      |
+| `status`   | string  | no       | Import status.                         |
+
+The status can be one of the following:
+
+- `created`
+- `started`
+- `finished`
+- `failed`
+
+```shell
+curl --request GET --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/bulk_imports/1/entities?per_page=2&page=1&status=finished"
+```
+
+```json
+[
+    {
+        "id": 1,
+        "status": "finished",
+        "source_type": "gitlab",
+        "created_at": "2021-06-18T09:45:55.358Z",
+        "updated_at": "2021-06-18T09:46:27.003Z"
+    }
+]
+```
+
+## Get GitLab migration entity details
+
+```plaintext
+GET /bulk_imports/:id/entities/:entity_id
+```
+
+```shell
+curl --request GET --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/bulk_imports/1/entities/2"
+```
+
+```json
+{
+  "id": 1,
+  "status": "finished",
+  "source_type": "gitlab",
+  "created_at": "2021-06-18T09:45:55.358Z",
+  "updated_at": "2021-06-18T09:46:27.003Z"
+}
+```
diff --git a/lib/api/api.rb b/lib/api/api.rb
index 88343384f07b9c7c..659af98f86116968 100644
--- a/lib/api/api.rb
+++ b/lib/api/api.rb
@@ -152,6 +152,7 @@ class API < ::API::Base
       mount ::API::Boards
       mount ::API::Branches
       mount ::API::BroadcastMessages
+      mount ::API::BulkImports
       mount ::API::Ci::Pipelines
       mount ::API::Ci::PipelineSchedules
       mount ::API::Ci::Runner
diff --git a/lib/api/bulk_imports.rb b/lib/api/bulk_imports.rb
new file mode 100644
index 0000000000000000..189851cee65b47c3
--- /dev/null
+++ b/lib/api/bulk_imports.rb
@@ -0,0 +1,91 @@
+# frozen_string_literal: true
+
+module API
+  class BulkImports < ::API::Base
+    include PaginationParams
+
+    feature_category :importers
+
+    helpers do
+      def bulk_imports
+        @bulk_imports ||= ::BulkImports::ImportsFinder.new(user: current_user, status: params[:status]).execute
+      end
+
+      def bulk_import
+        @bulk_import ||= bulk_imports.find(params[:import_id])
+      end
+
+      def bulk_import_entities
+        @bulk_import_entities ||= ::BulkImports::EntitiesFinder.new(user: current_user, bulk_import: bulk_import, status: params[:status]).execute
+      end
+
+      def bulk_import_entity
+        @bulk_import_entity ||= bulk_import_entities.find(params[:entity_id])
+      end
+    end
+
+    before { authenticate! }
+
+    resource :bulk_imports do
+      desc 'List all GitLab Migrations' do
+        detail 'This feature was introduced in GitLab 14.1.'
+      end
+      params do
+        use :pagination
+        optional :status, type: String, values: BulkImport.all_human_statuses,
+                 desc: 'Return GitLab Migrations with specified status'
+      end
+      get do
+        present paginate(bulk_imports), with: Entities::BulkImport
+      end
+
+      desc "List all GitLab Migrations' entities" do
+        detail 'This feature was introduced in GitLab 14.1.'
+      end
+      params do
+        use :pagination
+        optional :status, type: String, values: ::BulkImports::Entity.all_human_statuses,
+                 desc: "Return all GitLab Migrations' entities with specified status"
+      end
+      get :entities do
+        entities = ::BulkImports::EntitiesFinder.new(user: current_user, status: params[:status]).execute
+
+        present paginate(entities), with: Entities::BulkImports::Entity
+      end
+
+      desc 'Get GitLab Migration details' do
+        detail 'This feature was introduced in GitLab 14.1.'
+      end
+      params do
+        requires :import_id, type: Integer, desc: "The ID of user's GitLab Migration"
+      end
+      get ':import_id' do
+        present bulk_import, with: Entities::BulkImport
+      end
+
+      desc "List GitLab Migration entities" do
+        detail 'This feature was introduced in GitLab 14.1.'
+      end
+      params do
+        requires :import_id, type: Integer, desc: "The ID of user's GitLab Migration"
+        optional :status, type: String, values: ::BulkImports::Entity.all_human_statuses,
+                 desc: 'Return import entities with specified status'
+        use :pagination
+      end
+      get ':import_id/entities' do
+        present paginate(bulk_import_entities), with: Entities::BulkImports::Entity
+      end
+
+      desc 'Get GitLab Migration entity details' do
+        detail 'This feature was introduced in GitLab 14.1.'
+      end
+      params do
+        requires :import_id, type: Integer, desc: "The ID of user's GitLab Migration"
+        requires :entity_id, type: Integer, desc: "The ID of GitLab Migration entity"
+      end
+      get ':import_id/entities/:entity_id' do
+        present bulk_import_entity, with: Entities::BulkImports::Entity
+      end
+    end
+  end
+end
diff --git a/lib/api/entities/bulk_import.rb b/lib/api/entities/bulk_import.rb
new file mode 100644
index 0000000000000000..373ae486dcfd610d
--- /dev/null
+++ b/lib/api/entities/bulk_import.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module API
+  module Entities
+    class BulkImport < Grape::Entity
+      expose :id
+      expose :status_name, as: :status
+      expose :source_type
+      expose :created_at
+      expose :updated_at
+    end
+  end
+end
diff --git a/lib/api/entities/bulk_imports/entity.rb b/lib/api/entities/bulk_imports/entity.rb
new file mode 100644
index 0000000000000000..e8c31256b17c3dbb
--- /dev/null
+++ b/lib/api/entities/bulk_imports/entity.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module API
+  module Entities
+    module BulkImports
+      class Entity < Grape::Entity
+        expose :id
+        expose :bulk_import_id
+        expose :status_name, as: :status
+        expose :source_full_path
+        expose :destination_name
+        expose :destination_namespace
+        expose :parent_id
+        expose :namespace_id
+        expose :project_id
+        expose :created_at
+        expose :updated_at
+        expose :failures, using: EntityFailure
+      end
+    end
+  end
+end
diff --git a/lib/api/entities/bulk_imports/entity_failure.rb b/lib/api/entities/bulk_imports/entity_failure.rb
new file mode 100644
index 0000000000000000..a3dbe3280ee8902a
--- /dev/null
+++ b/lib/api/entities/bulk_imports/entity_failure.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module API
+  module Entities
+    module BulkImports
+      class EntityFailure < Grape::Entity
+        expose :pipeline_class
+        expose :pipeline_step
+        expose :exception_class
+        expose :correlation_id_value
+        expose :created_at
+      end
+    end
+  end
+end
diff --git a/spec/finders/bulk_imports/entities_finder_spec.rb b/spec/finders/bulk_imports/entities_finder_spec.rb
new file mode 100644
index 0000000000000000..e053011b60d3ee77
--- /dev/null
+++ b/spec/finders/bulk_imports/entities_finder_spec.rb
@@ -0,0 +1,84 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe BulkImports::EntitiesFinder do
+  let_it_be(:user) { create(:user) }
+
+  let_it_be(:user_import_1) { create(:bulk_import, user: user) }
+  let_it_be(:started_entity_1) { create(:bulk_import_entity, :started, bulk_import: user_import_1) }
+  let_it_be(:finished_entity_1) { create(:bulk_import_entity, :finished, bulk_import: user_import_1) }
+  let_it_be(:failed_entity_1) { create(:bulk_import_entity, :failed, bulk_import: user_import_1) }
+
+  let_it_be(:user_import_2) { create(:bulk_import, user: user) }
+  let_it_be(:started_entity_2) { create(:bulk_import_entity, :started, bulk_import: user_import_2) }
+  let_it_be(:finished_entity_2) { create(:bulk_import_entity, :finished, bulk_import: user_import_2) }
+  let_it_be(:failed_entity_2) { create(:bulk_import_entity, :failed, bulk_import: user_import_2) }
+
+  let_it_be(:not_user_import) { create(:bulk_import) }
+  let_it_be(:started_entity_3) { create(:bulk_import_entity, :started, bulk_import: not_user_import) }
+  let_it_be(:finished_entity_3) { create(:bulk_import_entity, :finished, bulk_import: not_user_import) }
+  let_it_be(:failed_entity_3) { create(:bulk_import_entity, :failed, bulk_import: not_user_import) }
+
+  subject { described_class.new(user: user) }
+
+  describe '#execute' do
+    it 'returns a list of import entities associated with user' do
+      expect(subject.execute)
+        .to contain_exactly(
+          started_entity_1, finished_entity_1, failed_entity_1,
+          started_entity_2, finished_entity_2, failed_entity_2
+        )
+    end
+
+    context 'when bulk import is specified' do
+      subject { described_class.new(user: user, bulk_import: user_import_1) }
+
+      it 'returns a list of import entities filtered by bulk import' do
+        expect(subject.execute)
+          .to contain_exactly(
+            started_entity_1, finished_entity_1, failed_entity_1
+          )
+      end
+
+      context 'when specified import is not associated with user' do
+        subject { described_class.new(user: user, bulk_import: not_user_import) }
+
+        it 'does not return entities' do
+          expect(subject.execute).to be_empty
+        end
+      end
+    end
+
+    context 'when status is specified' do
+      subject { described_class.new(user: user, status: 'failed') }
+
+      it 'returns a list of import entities filtered by status' do
+        expect(subject.execute)
+          .to contain_exactly(
+            failed_entity_1, failed_entity_2
+          )
+      end
+
+      context 'when invalid status is specified' do
+        subject { described_class.new(user: user, status: 'invalid') }
+
+        it 'does not filter entities by status' do
+          expect(subject.execute)
+            .to contain_exactly(
+              started_entity_1, finished_entity_1, failed_entity_1,
+              started_entity_2, finished_entity_2, failed_entity_2
+            )
+        end
+      end
+    end
+
+    context 'when bulk import and status are specified' do
+      subject { described_class.new(user: user, bulk_import: user_import_2, status: 'finished') }
+
+      it 'returns matched import entities' do
+        expect(subject.execute).to contain_exactly(finished_entity_2)
+      end
+    end
+  end
+end
diff --git a/spec/finders/bulk_imports/imports_finder_spec.rb b/spec/finders/bulk_imports/imports_finder_spec.rb
new file mode 100644
index 0000000000000000..aac83c86c8439f01
--- /dev/null
+++ b/spec/finders/bulk_imports/imports_finder_spec.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe BulkImports::ImportsFinder do
+  let_it_be(:user) { create(:user) }
+  let_it_be(:started_import) { create(:bulk_import, :started, user: user) }
+  let_it_be(:finished_import) { create(:bulk_import, :finished, user: user) }
+  let_it_be(:not_user_import) { create(:bulk_import) }
+
+  subject { described_class.new(user: user) }
+
+  describe '#execute' do
+    it 'returns a list of imports associated with user' do
+      expect(subject.execute).to contain_exactly(started_import, finished_import)
+    end
+
+    context 'when status is specified' do
+      subject { described_class.new(user: user, status: 'started') }
+
+      it 'returns a list of import entities filtered by status' do
+        expect(subject.execute).to contain_exactly(started_import)
+      end
+
+      context 'when invalid status is specified' do
+        subject { described_class.new(user: user, status: 'invalid') }
+
+        it 'does not filter entities by status' do
+          expect(subject.execute).to contain_exactly(started_import, finished_import)
+        end
+      end
+    end
+  end
+end
diff --git a/spec/lib/api/entities/bulk_import_spec.rb b/spec/lib/api/entities/bulk_import_spec.rb
new file mode 100644
index 0000000000000000..2db6862b0799a007
--- /dev/null
+++ b/spec/lib/api/entities/bulk_import_spec.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::Entities::BulkImport do
+  let_it_be(:import) { create(:bulk_import) }
+
+  subject { described_class.new(import).as_json }
+
+  it 'has the correct attributes' do
+    expect(subject).to include(
+      :id,
+      :status,
+      :source_type,
+      :created_at,
+      :updated_at
+    )
+  end
+end
diff --git a/spec/lib/api/entities/bulk_imports/entity_failure_spec.rb b/spec/lib/api/entities/bulk_imports/entity_failure_spec.rb
new file mode 100644
index 0000000000000000..adc8fdcdd9ce6292
--- /dev/null
+++ b/spec/lib/api/entities/bulk_imports/entity_failure_spec.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::Entities::BulkImports::EntityFailure do
+  let_it_be(:failure) { create(:bulk_import_failure) }
+
+  subject { described_class.new(failure).as_json }
+
+  it 'has the correct attributes' do
+    expect(subject).to include(
+      :pipeline_class,
+      :pipeline_step,
+      :exception_class,
+      :correlation_id_value,
+      :created_at
+    )
+  end
+end
diff --git a/spec/lib/api/entities/bulk_imports/entity_spec.rb b/spec/lib/api/entities/bulk_imports/entity_spec.rb
new file mode 100644
index 0000000000000000..f91ae1fc5a113349
--- /dev/null
+++ b/spec/lib/api/entities/bulk_imports/entity_spec.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::Entities::BulkImports::Entity do
+  let_it_be(:entity) { create(:bulk_import_entity) }
+
+  subject { described_class.new(entity).as_json }
+
+  it 'has the correct attributes' do
+    expect(subject).to include(
+      :id,
+      :bulk_import_id,
+      :status,
+      :source_full_path,
+      :destination_name,
+      :destination_namespace,
+      :parent_id,
+      :namespace_id,
+      :project_id,
+      :created_at,
+      :updated_at,
+      :failures
+    )
+  end
+end
diff --git a/spec/models/bulk_import_spec.rb b/spec/models/bulk_import_spec.rb
index 1a7e1ed8119c43cb..4cfec6b20b75c013 100644
--- a/spec/models/bulk_import_spec.rb
+++ b/spec/models/bulk_import_spec.rb
@@ -15,4 +15,10 @@
 
     it { is_expected.to define_enum_for(:source_type).with_values(%i[gitlab]) }
   end
+
+  describe '.all_human_statuses' do
+    it 'returns all human readable entity statuses' do
+      expect(described_class.all_human_statuses).to contain_exactly('created', 'started', 'finished', 'failed')
+    end
+  end
 end
diff --git a/spec/models/bulk_imports/entity_spec.rb b/spec/models/bulk_imports/entity_spec.rb
index d1b7125a6e6277ef..11a3e53dd167eb15 100644
--- a/spec/models/bulk_imports/entity_spec.rb
+++ b/spec/models/bulk_imports/entity_spec.rb
@@ -134,4 +134,24 @@
       expect(entity.encoded_source_full_path).to eq(expected)
     end
   end
+
+  describe 'scopes' do
+    describe '.by_user_id' do
+      it 'returns entities associated with specified user' do
+        user = create(:user)
+        import = create(:bulk_import, user: user)
+        entity_1 = create(:bulk_import_entity, bulk_import: import)
+        entity_2 = create(:bulk_import_entity, bulk_import: import)
+        create(:bulk_import_entity)
+
+        expect(described_class.by_user_id(user.id)).to contain_exactly(entity_1, entity_2)
+      end
+    end
+  end
+
+  describe '.all_human_statuses' do
+    it 'returns all human readable entity statuses' do
+      expect(described_class.all_human_statuses).to contain_exactly('created', 'started', 'finished', 'failed')
+    end
+  end
 end
diff --git a/spec/requests/api/bulk_imports_spec.rb b/spec/requests/api/bulk_imports_spec.rb
new file mode 100644
index 0000000000000000..f0edfa6f227dbf8e
--- /dev/null
+++ b/spec/requests/api/bulk_imports_spec.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::BulkImports do
+  let_it_be(:user) { create(:user) }
+  let_it_be(:import_1) { create(:bulk_import, user: user) }
+  let_it_be(:import_2) { create(:bulk_import, user: user) }
+  let_it_be(:entity_1) { create(:bulk_import_entity, bulk_import: import_1) }
+  let_it_be(:entity_2) { create(:bulk_import_entity, bulk_import: import_1) }
+  let_it_be(:entity_3) { create(:bulk_import_entity, bulk_import: import_2) }
+  let_it_be(:failure_3) { create(:bulk_import_failure, entity: entity_3) }
+
+  describe 'GET /bulk_imports' do
+    it 'returns a list of bulk imports authored by the user' do
+      get api('/bulk_imports', user)
+
+      expect(response).to have_gitlab_http_status(:ok)
+      expect(json_response.pluck('id')).to contain_exactly(import_1.id, import_2.id)
+    end
+  end
+
+  describe 'GET /bulk_imports/entities' do
+    it 'returns a list of all import entities authored by the user' do
+      get api('/bulk_imports/entities', user)
+
+      expect(response).to have_gitlab_http_status(:ok)
+      expect(json_response.pluck('id')).to contain_exactly(entity_1.id, entity_2.id, entity_3.id)
+    end
+  end
+
+  describe 'GET /bulk_imports/:id' do
+    it 'returns specified bulk import' do
+      get api("/bulk_imports/#{import_1.id}", user)
+
+      expect(response).to have_gitlab_http_status(:ok)
+      expect(json_response['id']).to eq(import_1.id)
+    end
+  end
+
+  describe 'GET /bulk_imports/:id/entities' do
+    it 'returns specified bulk import entities with failures' do
+      get api("/bulk_imports/#{import_2.id}/entities", user)
+
+      expect(response).to have_gitlab_http_status(:ok)
+      expect(json_response.pluck('id')).to contain_exactly(entity_3.id)
+      expect(json_response.first['failures'].first['exception_class']).to eq(failure_3.exception_class)
+    end
+  end
+
+  describe 'GET /bulk_imports/:id/entities/:entity_id' do
+    it 'returns specified bulk import entity' do
+      get api("/bulk_imports/#{import_1.id}/entities/#{entity_2.id}", user)
+
+      expect(response).to have_gitlab_http_status(:ok)
+      expect(json_response['id']).to eq(entity_2.id)
+    end
+  end
+
+  context 'when user is unauthenticated' do
+    it 'returns 401' do
+      get api('/bulk_imports', nil)
+
+      expect(response).to have_gitlab_http_status(:unauthorized)
+    end
+  end
+end
-- 
GitLab