diff --git a/app/models/bulk_import.rb b/app/models/bulk_import.rb
index 533d91089883ab6b49dfce075c22737f5e042ac2..f584f30ca96342ab5f0970cf58dd86850da40169 100644
--- a/app/models/bulk_import.rb
+++ b/app/models/bulk_import.rb
@@ -121,6 +121,23 @@ def parent_group_entity
entities.group_entity.where(parent: nil).first
end
+ def destination_group_roots
+ entities.where(parent: nil).filter_map do |entity|
+ entity.group || entity.project
+ end.map(&:root_ancestor).uniq
+ end
+
+ def namespaces_with_unassigned_placeholders
+ namespaces = destination_group_roots
+ namespace_ids = namespaces.collect(&:id)
+
+ reassignable_statuses = Import::SourceUser::STATUSES.slice(*Import::SourceUser::REASSIGNABLE_STATUSES).values
+ source_users = Import::SourceUser.for_namespace(namespace_ids).by_statuses(reassignable_statuses)
+ valid_namespace_ids = source_users.collect(&:namespace_id).uniq
+
+ namespaces.select { |namespace| valid_namespace_ids.include?(namespace.id) }
+ end
+
def source_url
configuration&.url
end
diff --git a/app/presenters/import/pending_reassignment_alert_presenter.rb b/app/presenters/import/pending_reassignment_alert_presenter.rb
new file mode 100644
index 0000000000000000000000000000000000000000..1462867269c110770de6ff583160101db2c1f0b4
--- /dev/null
+++ b/app/presenters/import/pending_reassignment_alert_presenter.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+module Import
+ class PendingReassignmentAlertPresenter < Gitlab::View::Presenter::Simple
+ include Gitlab::Utils::StrongMemoize
+ include ActionView::Helpers::TagHelper
+ include SafeFormatHelper
+
+ presents ::BulkImport, as: :bulk_import
+
+ def show_alert?
+ Feature.enabled?(:importer_user_mapping, current_user) && groups_awaiting_placeholder_assignment.any?
+ end
+
+ def groups_awaiting_placeholder_assignment
+ return [] unless bulk_import
+
+ namespaces = bulk_import.namespaces_with_unassigned_placeholders
+ namespaces.select do |namespace|
+ namespace.owners.include?(current_user)
+ end
+ end
+ strong_memoize_attr :groups_awaiting_placeholder_assignment
+
+ def group_names
+ return '' if groups_awaiting_placeholder_assignment.empty?
+
+ groups_awaiting_placeholder_assignment.collect(&:name).to_sentence
+ end
+
+ def source_hostname
+ bulk_import.configuration.source_hostname
+ end
+
+ def title
+ s_('UserMapping|Placeholder users awaiting reassignment')
+ end
+
+ def body
+ safe_format(
+ s_('UserMapping|As part of the import, placeholder users were created on ' \
+ '%{group_names} and these users were assigned group membership and ' \
+ 'contributions from %{source_hostname}. To reassign contributions from ' \
+ 'placeholder users to GitLab users, visit the Members page of %{group_links}.'),
+ group_names: group_names,
+ source_hostname: source_hostname,
+ group_links: group_links
+ )
+ end
+
+ def group_links
+ placeholders = []
+ tag_pairs = []
+
+ groups_awaiting_placeholder_assignment.collect do |namespace|
+ placeholders << "%{group_#{namespace.id}_link_start}#{namespace.name}%{group_#{namespace.id}_link_end}"
+ tag_pairs << tag_pair(
+ tag.a(href: group_group_members_path(namespace)),
+ :"group_#{namespace.id}_link_start",
+ :"group_#{namespace.id}_link_end"
+ )
+ end
+
+ safe_format(placeholders.to_sentence, *tag_pairs)
+ end
+ end
+end
diff --git a/app/views/import/bulk_imports/history.html.haml b/app/views/import/bulk_imports/history.html.haml
index 81eb08cfbc59278bd6d015bcc07f074061bb5649..13a88867d5a1763f5fedbc2223750d5590e3aa1c 100644
--- a/app/views/import/bulk_imports/history.html.haml
+++ b/app/views/import/bulk_imports/history.html.haml
@@ -7,4 +7,13 @@
- add_page_specific_style 'page_bundles/import'
+- pending_reassignment_presenter = Gitlab::View::Presenter::Factory.new(@bulk_import, current_user: current_user, presenter_class: ::Import::PendingReassignmentAlertPresenter).fabricate!
+- if pending_reassignment_presenter.show_alert?
+ = render Pajamas::AlertComponent.new(variant: :notice, title: pending_reassignment_presenter.title, alert_options: { class: 'gl-mt-4' }) do |c|
+ - c.with_body do
+ = pending_reassignment_presenter.body
+ - c.with_actions do
+ = render Pajamas::ButtonComponent.new(variant: :default, href: help_page_path('user/project/import/index', anchor: 'placeholder-users'), button_options: { class: 'deferred-link gl-alert-action', rel: 'noreferrer noopener' }, target: '_blank') do
+ = _('Learn more')
+
#import-history-mount-element{ data: { id: @bulk_import&.id, details_path: failures_import_bulk_import_path(':id', ':entity_id'), realtime_changes_path: realtime_changes_import_bulk_imports_path(format: :json) } }
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 1e2ea1f4b40f1dd09bc4747bd6dd060ec9f15607..024571676b34dee76d5523d6eb2d775dd8a4b539 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -58450,6 +58450,9 @@ msgstr ""
msgid "UserMapping|Approve reassignment"
msgstr ""
+msgid "UserMapping|As part of the import, placeholder users were created on %{group_names} and these users were assigned group membership and contributions from %{source_hostname}. To reassign contributions from placeholder users to GitLab users, visit the Members page of %{group_links}."
+msgstr ""
+
msgid "UserMapping|Awaiting reassignment"
msgstr ""
@@ -58501,6 +58504,9 @@ msgstr ""
msgid "UserMapping|Placeholder user was made permanent."
msgstr ""
+msgid "UserMapping|Placeholder users awaiting reassignment"
+msgstr ""
+
msgid "UserMapping|Placeholders"
msgstr ""
diff --git a/spec/helpers/import_helper_spec.rb b/spec/helpers/import_helper_spec.rb
index 7a5a69ea5b55a0c4b9c039084d59b9d938920446..2909d451af9bb760e302a0ecb30cdb5135d4e37c 100644
--- a/spec/helpers/import_helper_spec.rb
+++ b/spec/helpers/import_helper_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ImportHelper do
+RSpec.describe ImportHelper, feature_category: :importers do
describe '#sanitize_project_name' do
it 'removes leading tildes' do
expect(helper.sanitize_project_name('~~root')).to eq('root')
diff --git a/spec/models/bulk_import_spec.rb b/spec/models/bulk_import_spec.rb
index 5870edfb25ca8c5bec8f6b45c2506e486b31c4b5..1d8759ea8282b8cca38f4606f98ce53a58dd78c4 100644
--- a/spec/models/bulk_import_spec.rb
+++ b/spec/models/bulk_import_spec.rb
@@ -210,11 +210,32 @@ def supports_block_expectations?
let_it_be(:root_node) { create(:bulk_import_entity) }
- it 'returns the topmost group note of the import entity tree' do
+ it 'returns the topmost node of the first tree of the import entity structure' do
expect(import.parent_group_entity).to eq(root_node)
end
end
+ describe '#destination_group_roots' do
+ subject(:import) do
+ create(:bulk_import, :started, entities: [
+ root_project_entity,
+ root_group_entity,
+ create(:bulk_import_entity, parent: root_group_entity)
+ ])
+ end
+
+ let_it_be(:project_namespace) { create(:group) }
+ let_it_be(:project) { create(:project, namespace: project_namespace) }
+ let_it_be(:root_project_entity) { create(:bulk_import_entity, :project_entity, project: project) }
+
+ let_it_be(:top_level_group) { create(:group) }
+ let_it_be(:root_group_entity) { create(:bulk_import_entity, :group_entity, group: top_level_group) }
+
+ it 'returns the topmost group nodes of the import entity tree' do
+ expect(import.destination_group_roots).to match_array([project_namespace, top_level_group])
+ end
+ end
+
describe '#source_url' do
it 'returns migration source url via configuration' do
import = create(:bulk_import, :with_configuration)
@@ -230,4 +251,27 @@ def supports_block_expectations?
end
end
end
+
+ describe '#namespaces_with_unassigned_placeholders' do
+ let_it_be(:group) { create(:group) }
+ let_it_be(:entity) do
+ create(:bulk_import_entity, :group_entity, bulk_import: finished_bulk_import, group: group)
+ end
+
+ before do
+ create_list(:import_source_user, 5, :completed, namespace: group)
+ end
+
+ context 'when all placeholders have been assigned' do
+ it { expect(finished_bulk_import.namespaces_with_unassigned_placeholders).to be_empty }
+ end
+
+ context 'when some placeholders have not been assigned' do
+ before do
+ create(:import_source_user, :pending_reassignment, namespace: group)
+ end
+
+ it { expect(finished_bulk_import.namespaces_with_unassigned_placeholders).to include(group) }
+ end
+ end
end
diff --git a/spec/presenters/import/pending_reassignment_alert_presenter_spec.rb b/spec/presenters/import/pending_reassignment_alert_presenter_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..a48e67f8da57a123717195a67ef7b995a7a6961d
--- /dev/null
+++ b/spec/presenters/import/pending_reassignment_alert_presenter_spec.rb
@@ -0,0 +1,106 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Import::PendingReassignmentAlertPresenter, :aggregate_failures, feature_category: :importers do
+ include SafeFormatHelper
+
+ let_it_be(:user) { build_stubbed(:user) }
+ let_it_be(:bulk_import) { build_stubbed(:bulk_import, :with_configuration) }
+ let(:presenter) { described_class.new(bulk_import, current_user: user) }
+ let_it_be(:namespaces) { [] }
+
+ before do
+ namespaces.each do |namespace|
+ allow(namespace).to receive(:owners).and_return([user])
+ end
+
+ allow(bulk_import).to receive(:namespaces_with_unassigned_placeholders).and_return(namespaces)
+ end
+
+ describe '#title' do
+ subject { presenter.title }
+
+ it { is_expected.to eq(s_('UserMapping|Placeholder users awaiting reassignment')) }
+ end
+
+ describe '#body' do
+ subject { presenter.body }
+
+ it do
+ is_expected.to eq(
+ safe_format(
+ s_('UserMapping|As part of the import, placeholder users were created on ' \
+ '%{group_names} and these users were assigned group membership and ' \
+ 'contributions from %{source_hostname}. To reassign contributions from ' \
+ 'placeholder users to GitLab users, visit the Members page of %{group_links}.'),
+ group_names: '',
+ source_hostname: 'gitlab.example',
+ group_links: ''
+ ))
+ end
+ end
+
+ context 'with no top level groups' do
+ let_it_be(:namespaces) { [] }
+
+ it 'presents the import values' do
+ expect(presenter.show_alert?).to eq(false)
+ end
+ end
+
+ context 'with one top level group' do
+ let_it_be(:namespaces) do
+ source_users = build_stubbed(:import_source_user)
+ [build_stubbed(:group, id: 1, name: 'blink', import_source_users: [source_users])]
+ end
+
+ it 'presents the import values' do
+ expect(presenter.show_alert?).to eq(true)
+ expect(presenter.group_links).to eq("blink")
+ expect(presenter.groups_awaiting_placeholder_assignment).to match_array(namespaces)
+ expect(presenter.group_names).to eq('blink')
+ expect(presenter.source_hostname).to eq('gitlab.example')
+ end
+
+ context 'when importer_user_mapping feature flag is disabled' do
+ before do
+ stub_feature_flags(importer_user_mapping: false)
+ end
+
+ it 'presents the import values' do
+ expect(presenter.show_alert?).to eq(false)
+ end
+ end
+ end
+
+ context 'with multiple top level groups' do
+ let_it_be(:namespaces) do
+ [
+ build_stubbed(:group, id: 1, name: 'blink'),
+ build_stubbed(:group, id: 3, name: 'marquee'),
+ build_stubbed(:group, id: 7, name: 'details')
+ ]
+ end
+
+ it 'presents the import values' do
+ expect(presenter.show_alert?).to eq(true)
+ expect(presenter.group_links).to eq(
+ "blink, " \
+ "marquee, " \
+ "and details"
+ )
+ expect(presenter.groups_awaiting_placeholder_assignment).to match_array(namespaces)
+ expect(presenter.group_names).to eq('blink, marquee, and details')
+ expect(presenter.source_hostname).to eq('gitlab.example')
+ end
+
+ context 'when the current user is not an owner of a top level group' do
+ it 'excludes that group from the results' do
+ allow(namespaces[0]).to receive(:owners).and_return([])
+
+ expect(presenter.groups_awaiting_placeholder_assignment).to match_array(namespaces[1..])
+ end
+ end
+ end
+end