diff --git a/app/controllers/import/gitea_controller.rb b/app/controllers/import/gitea_controller.rb
index f067ef625aa0019fd454105601565d5db1eb8849..68ad8650dbac1ba119cc1bdef06d1641be0dcc48 100644
--- a/app/controllers/import/gitea_controller.rb
+++ b/app/controllers/import/gitea_controller.rb
@@ -1,8 +1,10 @@
 # frozen_string_literal: true
 
 class Import::GiteaController < Import::GithubController
+  extend ::Gitlab::Utils::Override
+
   def new
-    if session[access_token_key].present? && session[host_key].present?
+    if session[access_token_key].present? && provider_url.present?
       redirect_to status_import_url
     end
   end
@@ -12,8 +14,8 @@ def personal_access_token
     super
   end
 
+  # Must be defined or it will 404
   def status
-    @gitea_host_url = session[host_key]
     super
   end
 
@@ -23,25 +25,33 @@ def host_key
     :"#{provider}_host_url"
   end
 
-  # Overridden methods
+  override :provider
   def provider
     :gitea
   end
 
+  override :provider_url
+  def provider_url
+    session[host_key]
+  end
+
   # Gitea is not yet an OAuth provider
   # See https://github.com/go-gitea/gitea/issues/27
+  override :logged_in_with_provider?
   def logged_in_with_provider?
     false
   end
 
+  override :provider_auth
   def provider_auth
-    if session[access_token_key].blank? || session[host_key].blank?
+    if session[access_token_key].blank? || provider_url.blank?
       redirect_to new_import_gitea_url,
         alert: 'You need to specify both an Access Token and a Host URL.'
     end
   end
 
+  override :client_options
   def client_options
-    { host: session[host_key], api_version: 'v1' }
+    { host: provider_url, api_version: 'v1' }
   end
 end
diff --git a/app/controllers/import/github_controller.rb b/app/controllers/import/github_controller.rb
index e81ef6a3362810240dc5bf41f51f88c5dbeeba29..f52b41d76b1c49a2af4b4c81c63f1e3d7b8a77f5 100644
--- a/app/controllers/import/github_controller.rb
+++ b/app/controllers/import/github_controller.rb
@@ -1,8 +1,11 @@
 # frozen_string_literal: true
 
 class Import::GithubController < Import::BaseController
+  include ImportHelper
+
   before_action :verify_import_enabled
-  before_action :provider_auth, only: [:status, :jobs, :create]
+  before_action :provider_auth, only: [:status, :realtime_changes, :create]
+  before_action :expire_etag_cache, only: [:status, :create]
 
   rescue_from Octokit::Unauthorized, with: :provider_unauthorized
 
@@ -24,30 +27,37 @@ def personal_access_token
     redirect_to status_import_url
   end
 
-  # rubocop: disable CodeReuse/ActiveRecord
   def status
-    @repos = client.repos
-    @already_added_projects = find_already_added_projects(provider)
-    already_added_projects_names = @already_added_projects.pluck(:import_source)
-
-    @repos.reject! { |repo| already_added_projects_names.include? repo.full_name }
-  end
-  # rubocop: enable CodeReuse/ActiveRecord
-
-  def jobs
-    render json: find_jobs(provider)
+    # Request repos to display error page if provider token is invalid
+    # Improving in https://gitlab.com/gitlab-org/gitlab-ce/issues/55585
+    client_repos
+
+    respond_to do |format|
+      format.json do
+        render json: { imported_projects: serialized_imported_projects,
+                       provider_repos: serialized_provider_repos,
+                       namespaces: serialized_namespaces }
+      end
+      format.html
+    end
   end
 
   def create
     result = Import::GithubService.new(client, current_user, import_params).execute(access_params, provider)
 
     if result[:status] == :success
-      render json: ProjectSerializer.new.represent(result[:project])
+      render json: serialized_imported_projects(result[:project])
     else
       render json: { errors: result[:message] }, status: result[:http_status]
     end
   end
 
+  def realtime_changes
+    Gitlab::PollingInterval.set_header(response, interval: 3_000)
+
+    render json: find_jobs(provider)
+  end
+
   private
 
   def import_params
@@ -58,10 +68,45 @@ def permitted_import_params
     [:repo_id, :new_name, :target_namespace]
   end
 
+  def serialized_imported_projects(projects = already_added_projects)
+    ProjectSerializer.new.represent(projects, serializer: :import, provider_url: provider_url)
+  end
+
+  def serialized_provider_repos
+    repos = client_repos.reject { |repo| already_added_project_names.include? repo.full_name }
+    ProviderRepoSerializer.new(current_user: current_user).represent(repos, provider: provider, provider_url: provider_url)
+  end
+
+  def serialized_namespaces
+    NamespaceSerializer.new.represent(namespaces)
+  end
+
+  def already_added_projects
+    @already_added_projects ||= find_already_added_projects(provider)
+  end
+
+  def already_added_project_names
+    @already_added_projects_names ||= already_added_projects.pluck(:import_source) # rubocop:disable CodeReuse/ActiveRecord
+  end
+
+  def namespaces
+    current_user.manageable_groups_with_routes
+  end
+
+  def expire_etag_cache
+    Gitlab::EtagCaching::Store.new.tap do |store|
+      store.touch(realtime_changes_path)
+    end
+  end
+
   def client
     @client ||= Gitlab::LegacyGithubImport::Client.new(session[access_token_key], client_options)
   end
 
+  def client_repos
+    @client_repos ||= client.repos
+  end
+
   def verify_import_enabled
     render_404 unless import_enabled?
   end
@@ -74,6 +119,10 @@ def import_enabled?
     __send__("#{provider}_import_enabled?") # rubocop:disable GitlabSecurity/PublicSend
   end
 
+  def realtime_changes_path
+    public_send("realtime_changes_import_#{provider}_path", format: :json) # rubocop:disable GitlabSecurity/PublicSend
+  end
+
   def new_import_url
     public_send("new_import_#{provider}_url", extra_import_params) # rubocop:disable GitlabSecurity/PublicSend
   end
@@ -105,6 +154,14 @@ def provider
     :github
   end
 
+  def provider_url
+    strong_memoize(:provider_url) do
+      provider = Gitlab::Auth::OAuth::Provider.config_for('github')
+
+      provider&.dig('url').presence || 'https://github.com'
+    end
+  end
+
   # rubocop: disable CodeReuse/ActiveRecord
   def logged_in_with_provider?
     current_user.identities.exists?(provider: provider)
diff --git a/app/helpers/import_helper.rb b/app/helpers/import_helper.rb
index d3befd87cccd95af4bc344b4e27e132f1f48c529..2306963347e5ace5cdefca99412064c3b6b88d77 100644
--- a/app/helpers/import_helper.rb
+++ b/app/helpers/import_helper.rb
@@ -18,10 +18,8 @@ def import_project_target(owner, name)
     "#{namespace}/#{name}"
   end
 
-  def provider_project_link(provider, full_path)
-    url = __send__("#{provider}_project_url", full_path) # rubocop:disable GitlabSecurity/PublicSend
-
-    link_to full_path, url, target: '_blank', rel: 'noopener noreferrer'
+  def provider_project_link_url(provider_url, full_path)
+    Gitlab::Utils.append_path(provider_url, full_path)
   end
 
   def import_will_timeout_message(_ci_cd_only)
@@ -81,22 +79,4 @@ def import_githubish_choose_repository_message
   def import_all_githubish_repositories_button_label
     _('Import all repositories')
   end
-
-  private
-
-  def github_project_url(full_path)
-    Gitlab::Utils.append_path(github_root_url, full_path)
-  end
-
-  def github_root_url
-    strong_memoize(:github_url) do
-      provider = Gitlab::Auth::OAuth::Provider.config_for('github')
-
-      provider&.dig('url').presence || 'https://github.com'
-    end
-  end
-
-  def gitea_project_url(full_path)
-    Gitlab::Utils.append_path(@gitea_host_url, full_path)
-  end
 end
diff --git a/app/helpers/namespaces_helper.rb b/app/helpers/namespaces_helper.rb
index 6dfb417198d786cabaec64d964da14fb438d0a72..477d6ed45e2e9315d04415dd6f87a47a5ba35cfb 100644
--- a/app/helpers/namespaces_helper.rb
+++ b/app/helpers/namespaces_helper.rb
@@ -5,11 +5,8 @@ def namespace_id_from(params)
     params.dig(:project, :namespace_id) || params[:namespace_id]
   end
 
-  # rubocop: disable CodeReuse/ActiveRecord
   def namespaces_options(selected = :current_user, display_path: false, groups: nil, extra_group: nil, groups_only: false)
-    groups ||= current_user.manageable_groups
-                 .eager_load(:route)
-                 .order('routes.path')
+    groups ||= current_user.manageable_groups_with_routes
     users = [current_user.namespace]
     selected_id = selected
 
@@ -43,7 +40,6 @@ def namespaces_options(selected = :current_user, display_path: false, groups: ni
 
     grouped_options_for_select(options, selected_id)
   end
-  # rubocop: enable CodeReuse/ActiveRecord
 
   def namespace_icon(namespace, size = 40)
     if namespace.is_a?(Group)
diff --git a/app/models/user.rb b/app/models/user.rb
index 486ba2dfae5ae4bd4742e20d1481241265523e03..29a5c2051d6e547b109c705a047ed93c50f0357e 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -1167,6 +1167,10 @@ def manageable_groups
     Gitlab::ObjectHierarchy.new(owned_or_maintainers_groups).base_and_descendants
   end
 
+  def manageable_groups_with_routes
+    manageable_groups.eager_load(:route).order('routes.path')
+  end
+
   def namespaces
     namespace_ids = groups.pluck(:id)
     namespace_ids.push(namespace.id)
diff --git a/app/serializers/namespace_basic_entity.rb b/app/serializers/namespace_basic_entity.rb
new file mode 100644
index 0000000000000000000000000000000000000000..8bcbb2bca603aabba71d5f9146dcd09533e867b9
--- /dev/null
+++ b/app/serializers/namespace_basic_entity.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+class NamespaceBasicEntity < Grape::Entity
+  expose :id
+  expose :full_path
+end
diff --git a/app/serializers/namespace_serializer.rb b/app/serializers/namespace_serializer.rb
new file mode 100644
index 0000000000000000000000000000000000000000..bf3f154b558fa100f6756f835deb447f4b09069d
--- /dev/null
+++ b/app/serializers/namespace_serializer.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class NamespaceSerializer < BaseSerializer
+  entity NamespaceBasicEntity
+end
diff --git a/app/serializers/project_import_entity.rb b/app/serializers/project_import_entity.rb
new file mode 100644
index 0000000000000000000000000000000000000000..9b51af685e767e0cec3e820a084fcc779dc22339
--- /dev/null
+++ b/app/serializers/project_import_entity.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class ProjectImportEntity < ProjectEntity
+  include ImportHelper
+
+  expose :import_source
+  expose :import_status
+  expose :human_import_status_name
+
+  expose :provider_link do |project, options|
+    provider_project_link_url(options[:provider_url], project[:import_source])
+  end
+end
diff --git a/app/serializers/project_serializer.rb b/app/serializers/project_serializer.rb
index 23b96c2fc9e2397d6a77f377a56b8b96bee197be..52ac2fa0e0987f3059c575803e5eef199f8e0956 100644
--- a/app/serializers/project_serializer.rb
+++ b/app/serializers/project_serializer.rb
@@ -1,5 +1,15 @@
 # frozen_string_literal: true
 
 class ProjectSerializer < BaseSerializer
-  entity ProjectEntity
+  def represent(project, opts = {})
+    entity =
+      case opts[:serializer]
+      when :import
+        ProjectImportEntity
+      else
+        ProjectEntity
+      end
+
+    super(project, opts, entity)
+  end
 end
diff --git a/app/serializers/provider_repo_entity.rb b/app/serializers/provider_repo_entity.rb
new file mode 100644
index 0000000000000000000000000000000000000000..d70aaa913241861eae1749d4becc8bae8f183501
--- /dev/null
+++ b/app/serializers/provider_repo_entity.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+class ProviderRepoEntity < Grape::Entity
+  include ImportHelper
+
+  expose :id
+  expose :full_name
+  expose :owner_name do |provider_repo, options|
+    owner_name(provider_repo, options[:provider])
+  end
+
+  expose :sanitized_name do |provider_repo|
+    sanitize_project_name(provider_repo[:name])
+  end
+
+  expose :provider_link do |provider_repo, options|
+    provider_project_link_url(options[:provider_url], provider_repo[:full_name])
+  end
+
+  private
+
+  def owner_name(provider_repo, provider)
+    provider_repo.dig(:owner, :login) if provider == :github
+  end
+end
diff --git a/app/serializers/provider_repo_serializer.rb b/app/serializers/provider_repo_serializer.rb
new file mode 100644
index 0000000000000000000000000000000000000000..8a73f6fe6df84868bc31bf4749e5d561921d847c
--- /dev/null
+++ b/app/serializers/provider_repo_serializer.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class ProviderRepoSerializer < BaseSerializer
+  entity ProviderRepoEntity
+end
diff --git a/app/views/import/_githubish_status.html.haml b/app/views/import/_githubish_status.html.haml
index f4a29ed18dcfc207c511d953c6bcb2fd2e921a50..08349ce8a68c1e9473f2014809a9e70e9ee54be2 100644
--- a/app/views/import/_githubish_status.html.haml
+++ b/app/views/import/_githubish_status.html.haml
@@ -1,56 +1,5 @@
 - provider = local_assigns.fetch(:provider)
 - provider_title = Gitlab::ImportSources.title(provider)
 
-%p.light
-  = import_githubish_choose_repository_message
-%hr
-%p
-  = button_tag class: "btn btn-import btn-success js-import-all" do
-    = import_all_githubish_repositories_button_label
-    = icon("spinner spin", class: "loading-icon")
+%h1= provider_title
 
-.table-responsive
-  %table.table.import-jobs
-    %colgroup.import-jobs-from-col
-    %colgroup.import-jobs-to-col
-    %colgroup.import-jobs-status-col
-    %thead
-      %tr
-        %th= _('From %{provider_title}') % { provider_title: provider_title }
-        %th= _('To GitLab')
-        %th= _('Status')
-    %tbody
-      - @already_added_projects.each do |project|
-        %tr{ id: "project_#{project.id}", class: project_status_css_class(project.import_status) }
-          %td
-            = provider_project_link(provider, project.import_source)
-          %td
-            = link_to project.full_path, [project.namespace.becomes(Namespace), project]
-          %td.job-status
-            = render 'import/project_status', project: project
-
-      - @repos.each do |repo|
-        %tr{ id: "repo_#{repo.id}", data: { qa: { repo_path: repo.full_name } } }
-          %td
-            = provider_project_link(provider, repo.full_name)
-          %td.import-target
-            %fieldset.row
-            .input-group
-              .project-path.input-group-prepend
-                - if current_user.can_select_namespace?
-                  - selected = params[:namespace_id] || :current_user
-                  - opts = current_user.can_create_group? ? { extra_group: Group.new(name: repo.owner.login, path: repo.owner.login) } : {}
-                  = select_tag :namespace_id, namespaces_options(selected, opts.merge({ display_path: true })), { class: 'input-group-text select2 js-select-namespace qa-project-namespace-select', tabindex: 1 }
-                - else
-                  = text_field_tag :path, current_user.namespace_path, class: "input-group-text input-large form-control", tabindex: 1, disabled: true
-              %span.input-group-prepend
-                .input-group-text /
-              = text_field_tag :path, sanitize_project_name(repo.name), class: "input-mini form-control", tabindex: 2, autofocus: true, required: true
-          %td.import-actions.job-status
-            = button_tag class: "btn btn-import js-add-to-import" do
-              = has_ci_cd_only_params? ? _('Connect') : _('Import')
-              = icon("spinner spin", class: "loading-icon")
-
-.js-importer-status{ data: { jobs_import_path: url_for([:jobs, :import, provider]),
-                             import_path: url_for([:import, provider]),
-                             ci_cd_only: has_ci_cd_only_params?.to_s } }
diff --git a/changelogs/unreleased/import-go-to-project-cta.yml b/changelogs/unreleased/import-go-to-project-cta.yml
new file mode 100644
index 0000000000000000000000000000000000000000..ae719f087901a55774579de9ea082783f78c3fd6
--- /dev/null
+++ b/changelogs/unreleased/import-go-to-project-cta.yml
@@ -0,0 +1,5 @@
+---
+title: Improve GitHub and Gitea project import table UI
+merge_request: 24606
+author:
+type: other
diff --git a/config/routes/import.rb b/config/routes/import.rb
index da5c31d0062492381a0e88ddda3b3ed5da2af178..24013eb2c88609e50987fbdaaa1bc110287eb02b 100644
--- a/config/routes/import.rb
+++ b/config/routes/import.rb
@@ -12,13 +12,13 @@
     post :personal_access_token
     get :status
     get :callback
-    get :jobs
+    get :realtime_changes
   end
 
   resource :gitea, only: [:create, :new], controller: :gitea do
     post :personal_access_token
     get :status
-    get :jobs
+    get :realtime_changes
   end
 
   resource :gitlab, only: [:create], controller: :gitlab do
diff --git a/ee/spec/features/projects/new_project_spec.rb b/ee/spec/features/projects/new_project_spec.rb
index 81bc64a843fc2467ebebb52157e6ca0b9b01e66b..aa783a2aae399349564437b908762e7927f9755a 100644
--- a/ee/spec/features/projects/new_project_spec.rb
+++ b/ee/spec/features/projects/new_project_spec.rb
@@ -122,46 +122,6 @@
         end
       end
 
-      it 'creates CI/CD project from GitHub' do
-        visit new_project_path
-        find('#ci-cd-project-tab').click
-
-        page.within '#ci-cd-project-pane' do
-          find('.js-import-github').click
-        end
-
-        expect(page).to have_text('Connect repositories from GitHub')
-
-        allow_any_instance_of(Gitlab::LegacyGithubImport::Client).to receive(:repos).and_return([repo])
-
-        fill_in 'personal_access_token', with: 'fake-token'
-        click_button 'List your GitHub repositories'
-        wait_for_requests
-
-        # Mock the POST `/import/github`
-        allow_any_instance_of(Gitlab::LegacyGithubImport::Client).to receive(:repo).and_return(repo)
-        project = create(:project, name: 'some-github-repo', creator: user, import_type: 'github')
-        create(:import_state, :finished, import_url: repo.clone_url, project: project)
-        allow_any_instance_of(CiCd::SetupProject).to receive(:setup_external_service)
-        CiCd::SetupProject.new(project, user).execute
-        allow_any_instance_of(Gitlab::LegacyGithubImport::ProjectCreator)
-          .to receive(:execute).with(hash_including(ci_cd_only: true))
-          .and_return(project)
-
-        click_button 'Connect'
-        wait_for_requests
-
-        expect(page).to have_text('Started')
-        wait_for_requests
-
-        expect(page).to have_text('Done')
-
-        created_project = Project.last
-        expect(created_project.name).to eq('some-github-repo')
-        expect(created_project.mirror).to eq(true)
-        expect(created_project.project_feature).not_to be_issues_enabled
-      end
-
       it 'new GitHub CI/CD project page has link to status page with ?ci_cd_only=true param' do
         visit new_import_github_path(ci_cd_only: true)
 
diff --git a/lib/gitlab/etag_caching/router.rb b/lib/gitlab/etag_caching/router.rb
index aced02eed3874f629923f76e98cb49265d240a0f..1d2820fcab154294c79a833b890d8ed52bd9f179 100644
--- a/lib/gitlab/etag_caching/router.rb
+++ b/lib/gitlab/etag_caching/router.rb
@@ -54,6 +54,14 @@ class Router
         Gitlab::EtagCaching::Router::Route.new(
           %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/environments\.json\z),
           'environments'
+        ),
+        Gitlab::EtagCaching::Router::Route.new(
+          %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/import/github/realtime_changes\.json\z),
+          'realtime_changes_import_github'
+        ),
+        Gitlab::EtagCaching::Router::Route.new(
+          %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/import/gitea/realtime_changes\.json\z),
+          'realtime_changes_import_gitea'
         )
       ].freeze
 
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 3c2bdacfc76e07acf4976383b235034348b154e6..cbf6caafa40d638d262a38e309b69a935f4d7050 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -2589,9 +2589,6 @@ msgstr ""
 msgid "Configure the way a user creates a new account."
 msgstr ""
 
-msgid "Connect"
-msgstr ""
-
 msgid "Connect all repositories"
 msgstr ""
 
@@ -4231,9 +4228,6 @@ msgstr ""
 msgid "Free Trial of GitLab.com Gold"
 msgstr ""
 
-msgid "From %{provider_title}"
-msgstr ""
-
 msgid "From Bitbucket"
 msgstr ""
 
diff --git a/spec/controllers/import/gitea_controller_spec.rb b/spec/controllers/import/gitea_controller_spec.rb
index 5ba64ab3eed71119b067cf683c5339dabab5098f..8cbec79095f3766db2aa2e1a55786e95e3b50a32 100644
--- a/spec/controllers/import/gitea_controller_spec.rb
+++ b/spec/controllers/import/gitea_controller_spec.rb
@@ -40,4 +40,12 @@ def assign_host_url
       end
     end
   end
+
+  describe "GET realtime_changes" do
+    it_behaves_like 'a GitHub-ish import controller: GET realtime_changes' do
+      before do
+        assign_host_url
+      end
+    end
+  end
 end
diff --git a/spec/controllers/import/github_controller_spec.rb b/spec/controllers/import/github_controller_spec.rb
index bca5f3f65891987ef98a3b5e12d6b7001574df31..162dff98ec5cf50cc5ae360ef12bc5e142c9e940 100644
--- a/spec/controllers/import/github_controller_spec.rb
+++ b/spec/controllers/import/github_controller_spec.rb
@@ -60,4 +60,8 @@
   describe "POST create" do
     it_behaves_like 'a GitHub-ish import controller: POST create'
   end
+
+  describe "GET realtime_changes" do
+    it_behaves_like 'a GitHub-ish import controller: GET realtime_changes'
+  end
 end
diff --git a/spec/helpers/import_helper_spec.rb b/spec/helpers/import_helper_spec.rb
index af4931e3370da1177b92f1695b595e5db21fe794..6e8c13db9fe15d14c4231052233b0b43c4f12f2d 100644
--- a/spec/helpers/import_helper_spec.rb
+++ b/spec/helpers/import_helper_spec.rb
@@ -39,59 +39,12 @@
     end
   end
 
-  describe '#provider_project_link' do
-    context 'when provider is "github"' do
-      let(:github_server_url) { nil }
-      let(:provider) { OpenStruct.new(name: 'github', url: github_server_url) }
+  describe '#provider_project_link_url' do
+    let(:full_path) { '/repo/path' }
+    let(:host_url) { 'http://provider.com/' }
 
-      before do
-        stub_omniauth_setting(providers: [provider])
-      end
-
-      context 'when provider does not specify a custom URL' do
-        it 'uses default GitHub URL' do
-          expect(helper.provider_project_link('github', 'octocat/Hello-World'))
-          .to include('href="https://github.com/octocat/Hello-World"')
-        end
-      end
-
-      context 'when provider specify a custom URL' do
-        let(:github_server_url) { 'https://github.company.com' }
-
-        it 'uses custom URL' do
-          expect(helper.provider_project_link('github', 'octocat/Hello-World'))
-          .to include('href="https://github.company.com/octocat/Hello-World"')
-        end
-      end
-
-      context "when custom URL contains a '/' char at the end" do
-        let(:github_server_url) { 'https://github.company.com/' }
-
-        it "doesn't render double slash" do
-          expect(helper.provider_project_link('github', 'octocat/Hello-World'))
-          .to include('href="https://github.company.com/octocat/Hello-World"')
-        end
-      end
-
-      context 'when provider is missing' do
-        it 'uses the default URL' do
-          allow(Gitlab.config.omniauth).to receive(:providers).and_return([])
-
-          expect(helper.provider_project_link('github', 'octocat/Hello-World'))
-          .to include('href="https://github.com/octocat/Hello-World"')
-        end
-      end
-    end
-
-    context 'when provider is "gitea"' do
-      before do
-        assign(:gitea_host_url, 'https://try.gitea.io/')
-      end
-
-      it 'uses given host' do
-        expect(helper.provider_project_link('gitea', 'octocat/Hello-World'))
-        .to include('href="https://try.gitea.io/octocat/Hello-World"')
-      end
+    it 'appends repo full path to provider host url' do
+      expect(helper.provider_project_link_url(host_url, full_path)).to match('http://provider.com/repo/path')
     end
   end
 end
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index bd8c9459c51b334bc4557e0ea9d0536bb085713f..efae57cfd601a8ab9d1ae8a572b5a5057395390a 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -953,6 +953,21 @@
           expect(user.manageable_groups).to contain_exactly(group, subgroup)
         end
       end
+
+      describe '#manageable_groups_with_routes' do
+        it 'eager loads routes from manageable groups' do
+          control_count =
+            ActiveRecord::QueryRecorder.new(skip_cached: false) do
+              user.manageable_groups_with_routes.map(&:route)
+            end.count
+
+          create(:group, parent: subgroup)
+
+          expect do
+            user.manageable_groups_with_routes.map(&:route)
+          end.not_to exceed_all_query_limit(control_count)
+        end
+      end
     end
   end
 
diff --git a/spec/routing/import_routing_spec.rb b/spec/routing/import_routing_spec.rb
index 78ff9c6e6fd0a3a9df72b066513f34ce414fc276..106f92082e47f61871cce389a439124722358128 100644
--- a/spec/routing/import_routing_spec.rb
+++ b/spec/routing/import_routing_spec.rb
@@ -23,6 +23,11 @@
 #   end
 shared_examples 'importer routing' do
   let(:except_actions) { [] }
+  let(:is_realtime) { false }
+
+  before do
+    except_actions.push(is_realtime ? :jobs : :realtime_changes)
+  end
 
   it 'to #create' do
     expect(post("/import/#{provider}")).to route_to("import/#{provider}#create") unless except_actions.include?(:create)
@@ -43,17 +48,22 @@
   it 'to #jobs' do
     expect(get("/import/#{provider}/jobs")).to route_to("import/#{provider}#jobs") unless except_actions.include?(:jobs)
   end
+
+  it 'to #realtime_changes' do
+    expect(get("/import/#{provider}/realtime_changes")).to route_to("import/#{provider}#realtime_changes") unless except_actions.include?(:realtime_changes)
+  end
 end
 
 # personal_access_token_import_github POST     /import/github/personal_access_token(.:format)                                                import/github#personal_access_token
 #                status_import_github GET      /import/github/status(.:format)                                                               import/github#status
 #              callback_import_github GET      /import/github/callback(.:format)                                                             import/github#callback
-#                  jobs_import_github GET      /import/github/jobs(.:format)                                                                 import/github#jobs
+#      realtime_changes_import_github GET      /import/github/realtime_changes(.:format)                                                                 import/github#jobs
 #                       import_github POST     /import/github(.:format)                                                                      import/github#create
 #                   new_import_github GET      /import/github/new(.:format)                                                                  import/github#new
 describe Import::GithubController, 'routing' do
   it_behaves_like 'importer routing' do
     let(:provider) { 'github' }
+    let(:is_realtime) { true }
   end
 
   it 'to #personal_access_token' do
@@ -63,13 +73,14 @@
 
 # personal_access_token_import_gitea POST     /import/gitea/personal_access_token(.:format)                                                 import/gitea#personal_access_token
 #                status_import_gitea GET      /import/gitea/status(.:format)                                                                import/gitea#status
-#                  jobs_import_gitea GET      /import/gitea/jobs(.:format)                                                                  import/gitea#jobs
+#      realtime_changes_import_gitea GET      /import/gitea/realtime_changes(.:format)                                                                  import/gitea#jobs
 #                       import_gitea POST     /import/gitea(.:format)                                                                       import/gitea#create
 #                   new_import_gitea GET      /import/gitea/new(.:format)                                                                   import/gitea#new
 describe Import::GiteaController, 'routing' do
   it_behaves_like 'importer routing' do
     let(:except_actions) { [:callback] }
     let(:provider) { 'gitea' }
+    let(:is_realtime) { true }
   end
 
   it 'to #personal_access_token' do
diff --git a/spec/serializers/namespace_basic_entity_spec.rb b/spec/serializers/namespace_basic_entity_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..f8b71ceb9f31ef2326bd5aa08ba7e74cb6d1d00e
--- /dev/null
+++ b/spec/serializers/namespace_basic_entity_spec.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe NamespaceBasicEntity do
+  set(:group) { create(:group) }
+  let(:entity) do
+    described_class.represent(group)
+  end
+
+  describe '#as_json' do
+    subject { entity.as_json }
+
+    it 'includes required fields' do
+      expect(subject).to include :id, :full_path
+    end
+  end
+end
diff --git a/spec/serializers/namespace_serializer_spec.rb b/spec/serializers/namespace_serializer_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..6e5bdd8c52d1748aae37886dd48a534e8b346d72
--- /dev/null
+++ b/spec/serializers/namespace_serializer_spec.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe NamespaceSerializer do
+  it 'represents NamespaceBasicEntity entities' do
+    expect(described_class.entity_class).to eq(NamespaceBasicEntity)
+  end
+end
diff --git a/spec/serializers/project_import_entity_spec.rb b/spec/serializers/project_import_entity_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..e476da82729b78d4e6b88f18803ce2241bf58d95
--- /dev/null
+++ b/spec/serializers/project_import_entity_spec.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe ProjectImportEntity do
+  include ImportHelper
+
+  set(:project) { create(:project, import_status: :started, import_source: 'namespace/project') }
+  let(:provider_url) { 'https://provider.com' }
+  let(:entity) { described_class.represent(project, provider_url: provider_url) }
+
+  describe '#as_json' do
+    subject { entity.as_json }
+
+    it 'includes required fields' do
+      expect(subject[:import_source]).to eq(project.import_source)
+      expect(subject[:import_status]).to eq(project.import_status)
+      expect(subject[:human_import_status_name]).to eq(project.human_import_status_name)
+      expect(subject[:provider_link]).to eq(provider_project_link_url(provider_url, project[:import_source]))
+    end
+  end
+end
diff --git a/spec/serializers/project_serializer_spec.rb b/spec/serializers/project_serializer_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..22f958fc17f3e14f652d491baa15c74355cfe8c6
--- /dev/null
+++ b/spec/serializers/project_serializer_spec.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe ProjectSerializer do
+  set(:project) { create(:project) }
+  let(:provider_url) { 'http://provider.com' }
+
+  context 'when serializer option is :import' do
+    subject do
+      described_class.new.represent(project, serializer: :import, provider_url: provider_url)
+    end
+
+    before do
+      allow(ProjectImportEntity).to receive(:represent)
+    end
+
+    it 'represents with ProjectImportEntity' do
+      subject
+
+      expect(ProjectImportEntity)
+        .to have_received(:represent)
+              .with(project, serializer: :import, provider_url: provider_url, request: an_instance_of(EntityRequest))
+    end
+  end
+
+  context 'when serializer option is omitted' do
+    subject do
+      described_class.new.represent(project)
+    end
+
+    before do
+      allow(ProjectEntity).to receive(:represent)
+    end
+
+    it 'represents with ProjectEntity' do
+      subject
+
+      expect(ProjectEntity)
+        .to have_received(:represent)
+              .with(project, request: an_instance_of(EntityRequest))
+    end
+  end
+end
diff --git a/spec/serializers/provider_repo_entity_spec.rb b/spec/serializers/provider_repo_entity_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..b67115bab10dd6153b2eb85a51a5c6287379ca1d
--- /dev/null
+++ b/spec/serializers/provider_repo_entity_spec.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe ProviderRepoEntity do
+  include ImportHelper
+
+  let(:provider_repo) { { id: 1, full_name: 'full/name', name: 'name', owner: { login: 'owner' } } }
+  let(:provider) { :github }
+  let(:provider_url) { 'https://github.com' }
+  let(:entity) { described_class.represent(provider_repo, provider: provider, provider_url: provider_url) }
+
+  describe '#as_json' do
+    subject { entity.as_json }
+
+    it 'includes requried fields' do
+      expect(subject[:id]).to eq(provider_repo[:id])
+      expect(subject[:full_name]).to eq(provider_repo[:full_name])
+      expect(subject[:owner_name]).to eq(provider_repo[:owner][:login])
+      expect(subject[:sanitized_name]).to eq(sanitize_project_name(provider_repo[:name]))
+      expect(subject[:provider_link]).to eq(provider_project_link_url(provider_url, provider_repo[:full_name]))
+    end
+  end
+end
diff --git a/spec/serializers/provider_repo_serializer_spec.rb b/spec/serializers/provider_repo_serializer_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..f2be30c36d9d94031b9cc3275ae48b522ddbb1e6
--- /dev/null
+++ b/spec/serializers/provider_repo_serializer_spec.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe ProviderRepoSerializer do
+  it 'represents ProviderRepoEntity entities' do
+    expect(described_class.entity_class).to eq(ProviderRepoEntity)
+  end
+end
diff --git a/spec/support/controllers/githubish_import_controller_shared_examples.rb b/spec/support/controllers/githubish_import_controller_shared_examples.rb
index 697f999e4c4b16f87855b2640a30dfa66d3fe968..5bb1269a19df29ba496e5167690e9841c76139d8 100644
--- a/spec/support/controllers/githubish_import_controller_shared_examples.rb
+++ b/spec/support/controllers/githubish_import_controller_shared_examples.rb
@@ -58,36 +58,54 @@ def assign_session_token(provider)
 shared_examples 'a GitHub-ish import controller: GET status' do
   let(:new_import_url) { public_send("new_import_#{provider}_url") }
   let(:user) { create(:user) }
-  let(:repo) { OpenStruct.new(login: 'vim', full_name: 'asd/vim') }
+  let(:repo) { OpenStruct.new(login: 'vim', full_name: 'asd/vim', name: 'vim', owner: { login: 'owner' }) }
   let(:org) { OpenStruct.new(login: 'company') }
-  let(:org_repo) { OpenStruct.new(login: 'company', full_name: 'company/repo') }
-  let(:extra_assign_expectations) { {} }
+  let(:org_repo) { OpenStruct.new(login: 'company', full_name: 'company/repo', name: 'repo', owner: { login: 'owner' }) }
 
   before do
     assign_session_token(provider)
   end
 
-  it "assigns variables" do
-    project = create(:project, import_type: provider, namespace: user.namespace)
+  it "returns variables for json request" do
+    project = create(:project, import_type: provider, namespace: user.namespace, import_status: :finished, import_source: 'example/repo')
+    group = create(:group)
+    group.add_owner(user)
     stub_client(repos: [repo, org_repo], orgs: [org], org_repos: [org_repo])
 
-    get :status
+    get :status, format: :json
 
-    expect(assigns(:already_added_projects)).to eq([project])
-    expect(assigns(:repos)).to eq([repo, org_repo])
-    extra_assign_expectations.each do |key, value|
-      expect(assigns(key)).to eq(value)
-    end
+    expect(response).to have_gitlab_http_status(200)
+    expect(json_response.dig("imported_projects", 0, "id")).to eq(project.id)
+    expect(json_response.dig("provider_repos", 0, "id")).to eq(repo.id)
+    expect(json_response.dig("provider_repos", 1, "id")).to eq(org_repo.id)
+    expect(json_response.dig("namespaces", 0, "id")).to eq(group.id)
   end
 
   it "does not show already added project" do
-    project = create(:project, import_type: provider, namespace: user.namespace, import_source: 'asd/vim')
+    project = create(:project, import_type: provider, namespace: user.namespace, import_status: :finished, import_source: 'asd/vim')
     stub_client(repos: [repo], orgs: [])
 
+    get :status, format: :json
+
+    expect(json_response.dig("imported_projects", 0, "id")).to eq(project.id)
+    expect(json_response.dig("provider_repos")).to eq([])
+  end
+
+  it "touches the etag cache store" do
+    expect(stub_client(repos: [], orgs: [])).to receive(:repos)
+    expect_next_instance_of(Gitlab::EtagCaching::Store) do |store|
+      expect(store).to receive(:touch) { "realtime_changes_import_#{provider}_path" }
+    end
+
+    get :status, format: :json
+  end
+
+  it "requests provider repos list" do
+    expect(stub_client(repos: [], orgs: [])).to receive(:repos)
+
     get :status
 
-    expect(assigns(:already_added_projects)).to eq([project])
-    expect(assigns(:repos)).to eq([])
+    expect(response).to have_gitlab_http_status(200)
   end
 
   it "handles an invalid access token" do
@@ -100,13 +118,32 @@ def assign_session_token(provider)
     expect(controller).to redirect_to(new_import_url)
     expect(flash[:alert]).to eq("Access denied to your #{Gitlab::ImportSources.title(provider.to_s)} account.")
   end
+
+  it "does not produce N+1 database queries" do
+    stub_client(repos: [repo], orgs: [])
+    group_a = create(:group)
+    group_a.add_owner(user)
+    create(:project, :import_started, import_type: provider, namespace: user.namespace)
+
+    control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do
+      get :status, format: :json
+    end.count
+
+    stub_client(repos: [repo, org_repo], orgs: [])
+    group_b = create(:group)
+    group_b.add_owner(user)
+    create(:project, :import_started, import_type: provider, namespace: user.namespace)
+
+    expect { get :status, format: :json }
+      .not_to exceed_all_query_limit(control_count)
+  end
 end
 
 shared_examples 'a GitHub-ish import controller: POST create' do
   let(:user) { create(:user) }
-  let(:project) { create(:project) }
   let(:provider_username) { user.username }
   let(:provider_user) { OpenStruct.new(login: provider_username) }
+  let(:project) { create(:project, import_type: provider, import_status: :finished, import_source: "#{provider_username}/vim") }
   let(:provider_repo) do
     OpenStruct.new(
       name: 'vim',
@@ -145,6 +182,17 @@ def assign_session_token(provider)
     expect(json_response['errors']).to eq('Name is invalid, Path is old')
   end
 
+  it "touches the etag cache store" do
+    allow(Gitlab::LegacyGithubImport::ProjectCreator)
+      .to receive(:new).with(provider_repo, provider_repo.name, user.namespace, user, access_params, type: provider)
+        .and_return(double(execute: project))
+    expect_next_instance_of(Gitlab::EtagCaching::Store) do |store|
+      expect(store).to receive(:touch) { "realtime_changes_import_#{provider}_path" }
+    end
+
+    post :create, format: :json
+  end
+
   context "when the repository owner is the provider user" do
     context "when the provider user and GitLab user's usernames match" do
       it "takes the current user's namespace" do
@@ -351,7 +399,7 @@ def assign_session_token(provider)
       it 'does not create a new namespace under the user namespace' do
         expect(Gitlab::LegacyGithubImport::ProjectCreator)
             .to receive(:new).with(provider_repo, test_name, user.namespace, user, access_params, type: provider)
-                    .and_return(double(execute: build_stubbed(:project)))
+                    .and_return(double(execute: project))
 
         expect { post :create, params: { target_namespace: "#{user.namespace_path}/test_group", new_name: test_name }, format: :js }
             .not_to change { Namespace.count }
@@ -365,7 +413,7 @@ def assign_session_token(provider)
       it 'does not take the selected namespace and name' do
         expect(Gitlab::LegacyGithubImport::ProjectCreator)
             .to receive(:new).with(provider_repo, test_name, user.namespace, user, access_params, type: provider)
-                    .and_return(double(execute: build_stubbed(:project)))
+                    .and_return(double(execute: project))
 
         post :create, params: { target_namespace: 'foo/foobar/bar', new_name: test_name }, format: :js
       end
@@ -373,7 +421,7 @@ def assign_session_token(provider)
       it 'does not create the namespaces' do
         allow(Gitlab::LegacyGithubImport::ProjectCreator)
             .to receive(:new).with(provider_repo, test_name, kind_of(Namespace), user, access_params, type: provider)
-                    .and_return(double(execute: build_stubbed(:project)))
+                    .and_return(double(execute: project))
 
         expect { post :create, params: { target_namespace: 'foo/foobar/bar', new_name: test_name }, format: :js }
             .not_to change { Namespace.count }
@@ -390,7 +438,7 @@ def assign_session_token(provider)
 
         expect(Gitlab::LegacyGithubImport::ProjectCreator)
             .to receive(:new).with(provider_repo, test_name, group, user, access_params, type: provider)
-                    .and_return(double(execute: build_stubbed(:project)))
+                    .and_return(double(execute: project))
 
         post :create, params: { target_namespace: 'foo', new_name: test_name }, format: :js
       end
@@ -407,3 +455,20 @@ def assign_session_token(provider)
     end
   end
 end
+
+shared_examples 'a GitHub-ish import controller: GET realtime_changes' do
+  let(:user) { create(:user) }
+
+  before do
+    assign_session_token(provider)
+  end
+
+  it 'sets a Poll-Interval header' do
+    project = create(:project, import_type: provider, namespace: user.namespace, import_status: :finished, import_source: 'example/repo')
+
+    get :realtime_changes
+
+    expect(json_response).to eq([{ "id" => project.id, "import_status" => project.import_status }])
+    expect(Integer(response.headers['Poll-Interval'])).to be > -1
+  end
+end