From 28d67f8baecaba837a0453fae380be35ef94f3a9 Mon Sep 17 00:00:00 2001 From: Sam Word Date: Wed, 16 Jul 2025 19:20:29 -0400 Subject: [PATCH 1/4] Map contributions to personal namespace owner For projects imported to personal namespaces from Bitbucket Server, immediately map all user contributions to the personal namespace owner when user_mapping_to_personal_namespace_owner feature flag is enabled. --- app/models/project_import_data.rb | 4 ++ .../stage/finish_import_worker.rb | 7 +- ...er_mapping_to_personal_namespace_owner.yml | 10 +++ .../project_creator.rb | 6 +- .../bitbucket_server_import/user_finder.rb | 8 +++ lib/import/placeholder_references/pusher.rb | 6 ++ spec/factories/projects.rb | 12 +++- .../importers/pull_request_importer_spec.rb | 63 +++++++++++++++++- .../pull_request_notes/approved_event_spec.rb | 55 +++++++++++++++- .../pull_request_notes/declined_event_spec.rb | 59 ++++++++++++++++- .../pull_request_notes/inline_spec.rb | 59 ++++++++++++++++- .../pull_request_notes/merge_event_spec.rb | 56 +++++++++++++++- .../standalone_notes_spec.rb | 51 ++++++++++++++- .../project_creator_spec.rb | 65 ++++++++++++++++++- .../user_finder_spec.rb | 64 +++++++++++++++++- spec/models/project_import_data_spec.rb | 20 ++++++ .../stage/finish_import_worker_spec.rb | 53 ++++++++++++++- 17 files changed, 578 insertions(+), 20 deletions(-) create mode 100644 config/feature_flags/wip/user_mapping_to_personal_namespace_owner.yml diff --git a/app/models/project_import_data.rb b/app/models/project_import_data.rb index 4c9e8ea9fb0a19..f2f9240bed839b 100644 --- a/app/models/project_import_data.rb +++ b/app/models/project_import_data.rb @@ -52,4 +52,8 @@ def clear_credentials def user_mapping_enabled? self.data&.dig('user_contribution_mapping_enabled') || false end + + def user_mapping_to_personal_namespace_owner_enabled? + self.data&.dig('user_mapping_to_personal_namespace_owner_enabled') || false + end end diff --git a/app/workers/gitlab/bitbucket_server_import/stage/finish_import_worker.rb b/app/workers/gitlab/bitbucket_server_import/stage/finish_import_worker.rb index ecce4f50a327b0..136773642ecd32 100644 --- a/app/workers/gitlab/bitbucket_server_import/stage/finish_import_worker.rb +++ b/app/workers/gitlab/bitbucket_server_import/stage/finish_import_worker.rb @@ -14,7 +14,7 @@ class FinishImportWorker # rubocop:disable Scalability/IdempotentWorker def import(project) placeholder_reference_store = project.placeholder_reference_store - if placeholder_reference_store&.any? + if placeholder_reference_store&.any? && !map_to_personal_namespace_owner?(project) info( project.id, message: 'Delaying finalization as placeholder references are pending', @@ -39,6 +39,11 @@ def reschedule(project) self.class.perform_in(PLACEHOLDER_WAIT_INTERVAL.seconds, project.id) end + + def map_to_personal_namespace_owner?(project) + project.root_ancestor.user_namespace? && + project.import_data.user_mapping_to_personal_namespace_owner_enabled? + end end end end diff --git a/config/feature_flags/wip/user_mapping_to_personal_namespace_owner.yml b/config/feature_flags/wip/user_mapping_to_personal_namespace_owner.yml new file mode 100644 index 00000000000000..771e286b0576c0 --- /dev/null +++ b/config/feature_flags/wip/user_mapping_to_personal_namespace_owner.yml @@ -0,0 +1,10 @@ +--- +name: user_mapping_to_personal_namespace_owner +description: Assign imported contributions to personal namespace owner for all importers +feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/525342 +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/197360 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/556557 +milestone: '18.3' +group: group::import +type: wip +default_enabled: false \ No newline at end of file diff --git a/lib/gitlab/bitbucket_server_import/project_creator.rb b/lib/gitlab/bitbucket_server_import/project_creator.rb index 47536673d95e72..e7f2a98812578c 100644 --- a/lib/gitlab/bitbucket_server_import/project_creator.rb +++ b/lib/gitlab/bitbucket_server_import/project_creator.rb @@ -23,6 +23,9 @@ def execute user_contribution_mapping_enabled = Feature.enabled?(:bitbucket_server_user_mapping, current_user) + user_mapping_to_personal_namespace_owner_enabled = user_contribution_mapping_enabled && + Feature.enabled?(:user_mapping_to_personal_namespace_owner, current_user) + ::Projects::CreateService.new( current_user, name: name, @@ -41,7 +44,8 @@ def execute repo_slug: repo_slug, timeout_strategy: timeout_strategy, bitbucket_server_notes_separate_worker: bitbucket_server_notes_separate_worker_enabled, - user_contribution_mapping_enabled: user_contribution_mapping_enabled + user_contribution_mapping_enabled: user_contribution_mapping_enabled, + user_mapping_to_personal_namespace_owner_enabled: user_mapping_to_personal_namespace_owner_enabled } }, skip_wiki: true diff --git a/lib/gitlab/bitbucket_server_import/user_finder.rb b/lib/gitlab/bitbucket_server_import/user_finder.rb index a7086f683daf76..0d8bdfacea7fc1 100644 --- a/lib/gitlab/bitbucket_server_import/user_finder.rb +++ b/lib/gitlab/bitbucket_server_import/user_finder.rb @@ -27,6 +27,10 @@ def uid(object) if user_mapping_enabled?(project) return unless object[:username] + if project.root_ancestor.user_namespace? && user_mapping_to_personal_namespace_owner_enabled?(project) + return project.root_ancestor.owner_id + end + source_user_for_author(object).mapped_user_id else find_user_id(by: :email, value: object.is_a?(Hash) ? object[:author_email] : object.author_email) @@ -63,6 +67,10 @@ def user_mapping_enabled?(project) !!project.import_data.user_mapping_enabled? end + def user_mapping_to_personal_namespace_owner_enabled?(project) + !!project.import_data.user_mapping_to_personal_namespace_owner_enabled? + end + def source_user_for_author(user_data) source_user_mapper.find_or_create_source_user( source_user_identifier: user_data[:username], diff --git a/lib/import/placeholder_references/pusher.rb b/lib/import/placeholder_references/pusher.rb index e80e09c356bc94..f199ffa3cad528 100644 --- a/lib/import/placeholder_references/pusher.rb +++ b/lib/import/placeholder_references/pusher.rb @@ -5,6 +5,7 @@ module PlaceholderReferences module Pusher def push_reference(project, record, attribute, source_user_identifier) return unless user_mapping_enabled?(project) + return if map_to_personal_namespace_owner?(project) return if source_user_identifier.nil? source_user = source_user_mapper(project).find_source_user(source_user_identifier) @@ -38,6 +39,11 @@ def source_user_mapper(project) def user_mapping_enabled?(project) !!project.import_data.user_mapping_enabled? end + + def map_to_personal_namespace_owner?(project) + project.root_ancestor.user_namespace? && + project.import_data.user_mapping_to_personal_namespace_owner_enabled? + end end end end diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb index 1bedb3aa8517cd..edcbae49688356 100644 --- a/spec/factories/projects.rb +++ b/spec/factories/projects.rb @@ -694,8 +694,16 @@ end trait :import_user_mapping_enabled do - import_data_attributes do - { data: { user_contribution_mapping_enabled: true } } + after(:build) do |project| + project.import_data ||= project.build_import_data + project.import_data.merge_data({ user_contribution_mapping_enabled: true }) + end + end + + trait :user_mapping_to_personal_namespace_owner_enabled do + after(:build) do |project| + project.import_data ||= project.build_import_data + project.import_data.merge_data({ user_mapping_to_personal_namespace_owner_enabled: true }) end end end diff --git a/spec/lib/gitlab/bitbucket_server_import/importers/pull_request_importer_spec.rb b/spec/lib/gitlab/bitbucket_server_import/importers/pull_request_importer_spec.rb index e4ad27086df21a..a80720536a7dad 100644 --- a/spec/lib/gitlab/bitbucket_server_import/importers/pull_request_importer_spec.rb +++ b/spec/lib/gitlab/bitbucket_server_import/importers/pull_request_importer_spec.rb @@ -7,7 +7,10 @@ include Import::UserMappingHelper let_it_be_with_reload(:project) do - create(:project, :repository, :bitbucket_server_import, :import_user_mapping_enabled) + create( + :project, :repository, :bitbucket_server_import, :in_group, + :import_user_mapping_enabled, :user_mapping_to_personal_namespace_owner_enabled + ) end # Identifiers taken from importers/bitbucket_server/pull_request.json @@ -17,6 +20,7 @@ let(:pull_request_data) { Gitlab::Json.parse(fixture_file('importers/bitbucket_server/pull_request.json')) } let(:pull_request) { BitbucketServer::Representation::PullRequest.new(pull_request_data) } + let(:cached_references) { placeholder_user_references(::Import::SOURCE_BITBUCKET_SERVER, project.import_state.id) } subject(:importer) { described_class.new(project, pull_request.to_hash) } @@ -45,7 +49,6 @@ it 'pushes placeholder references', :aggregate_failures do importer.execute - cached_references = placeholder_user_references(::Import::SOURCE_BITBUCKET_SERVER, project.import_state.id) expect(cached_references).to contain_exactly( ['MergeRequestReviewer', instance_of(Integer), 'user_id', reviewer_1_source_user.id], ['MergeRequestReviewer', instance_of(Integer), 'user_id', reviewer_2_source_user.id], @@ -162,6 +165,61 @@ importer.execute end + context 'when importing into a personal namespace' do + let_it_be(:user_namespace) { create(:namespace) } + let_it_be(:project) do + project.update!(namespace: user_namespace) + project + end + + let_it_be(:author_source_user) { generate_source_user(project, 'username') } + let_it_be(:reviewer_1_source_user) { generate_source_user(project, 'john_smith') } + let_it_be(:reviewer_2_source_user) { generate_source_user(project, 'jane_doe') } + + it 'does not push placeholder references' do + importer.execute + + expect(cached_references).to be_empty + end + + it 'creates the merge request mapped to the personal namespace owner' do + importer.execute + + merge_request = project.merge_requests.find_by_iid(pull_request.iid) + + expect(merge_request.author_id).to eq(user_namespace.owner_id) + expect(merge_request.reviewer_ids).to contain_exactly(user_namespace.owner_id) + end + + context 'when user_mapping_to_personal_namespace_owner is disabled' do + before do + project.build_or_assign_import_data( + data: { user_mapping_to_personal_namespace_owner_enabled: false } + ).save! + end + + it 'pushes placeholder references' do + importer.execute + + expect(cached_references).to contain_exactly( + ['MergeRequestReviewer', instance_of(Integer), 'user_id', reviewer_1_source_user.id], + ['MergeRequestReviewer', instance_of(Integer), 'user_id', reviewer_2_source_user.id], + ['MergeRequest', instance_of(Integer), 'author_id', author_source_user.id] + ) + end + + it 'creates the merge request mapped to the placeholder user' do + importer.execute + + merge_request = project.merge_requests.find_by_iid(pull_request.iid) + + expect(merge_request.author_id).to eq(author_source_user.mapped_user_id) + expect(merge_request.reviewer_ids).to match_array([reviewer_1_source_user.mapped_user_id, + reviewer_2_source_user.mapped_user_id]) + end + end + end + context 'when user contribution mapping is disabled' do let_it_be(:reviewer_1) { create(:user, username: 'john_smith', email: 'john@smith.com') } let_it_be(:reviewer_2) { create(:user, username: 'jane_doe', email: 'jane@doe.com') } @@ -211,7 +269,6 @@ it 'does not push placeholder references' do importer.execute - cached_references = placeholder_user_references(::Import::SOURCE_BITBUCKET_SERVER, project.import_state.id) expect(cached_references).to be_empty end end diff --git a/spec/lib/gitlab/bitbucket_server_import/importers/pull_request_notes/approved_event_spec.rb b/spec/lib/gitlab/bitbucket_server_import/importers/pull_request_notes/approved_event_spec.rb index 0c1a4000b28c0f..a6c2ce74877a3d 100644 --- a/spec/lib/gitlab/bitbucket_server_import/importers/pull_request_notes/approved_event_spec.rb +++ b/spec/lib/gitlab/bitbucket_server_import/importers/pull_request_notes/approved_event_spec.rb @@ -6,7 +6,10 @@ include Import::UserMappingHelper let_it_be_with_reload(:project) do - create(:project, :repository, :bitbucket_server_import, :import_user_mapping_enabled) + create( + :project, :repository, :bitbucket_server_import, :in_group, + :import_user_mapping_enabled, :user_mapping_to_personal_namespace_owner_enabled + ) end let_it_be(:merge_request) { create(:merge_request, source_project: project) } @@ -108,6 +111,56 @@ def expect_log(stage:, message:, iid:, event_id:) end end + context 'when importing into a personal namespace' do + let_it_be(:user_namespace) { create(:namespace) } + let_it_be(:project) do + project.update!(namespace: user_namespace) + project + end + + let_it_be(:source_user) { generate_source_user(project, approved_event[:approver_username]) } + + it 'does not push placeholder references' do + importer.execute(approved_event) + + expect(cached_references).to be_empty + end + + it 'creates the approval, reviewer and approval note mapped to the personal namespace owner' do + importer.execute(approved_event) + + expect(merge_request.approvals.first.user_id).to eq(user_namespace.owner_id) + expect(merge_request.notes.first.author_id).to eq(user_namespace.owner_id) + expect(merge_request.reviewers.first.id).to eq(user_namespace.owner_id) + end + + context 'when user_mapping_to_personal_namespace_owner is disabled' do + before do + project.build_or_assign_import_data( + data: { user_mapping_to_personal_namespace_owner_enabled: false } + ).save! + end + + it 'pushes placeholder references' do + importer.execute(approved_event) + + expect(cached_references).to contain_exactly( + ['Approval', instance_of(Integer), 'user_id', source_user.id], + ['MergeRequestReviewer', instance_of(Integer), 'user_id', source_user.id], + ['Note', instance_of(Integer), 'author_id', source_user.id] + ) + end + + it 'creates the approval, reviewer and approval note mapped to the placeholder user' do + importer.execute(approved_event) + + expect(merge_request.approvals.first.user_id).to eq(source_user.mapped_user_id) + expect(merge_request.notes.first.author_id).to eq(source_user.mapped_user_id) + expect(merge_request.reviewers.first.id).to eq(source_user.mapped_user_id) + end + end + end + context 'when user contribution mapping is disabled' do let_it_be(:pull_request_author) do create(:user, username: 'pull_request_author', email: 'pull_request_author@example.org') diff --git a/spec/lib/gitlab/bitbucket_server_import/importers/pull_request_notes/declined_event_spec.rb b/spec/lib/gitlab/bitbucket_server_import/importers/pull_request_notes/declined_event_spec.rb index cc21fd12934844..b405cd3c37c7e8 100644 --- a/spec/lib/gitlab/bitbucket_server_import/importers/pull_request_notes/declined_event_spec.rb +++ b/spec/lib/gitlab/bitbucket_server_import/importers/pull_request_notes/declined_event_spec.rb @@ -6,7 +6,10 @@ include Import::UserMappingHelper let_it_be_with_reload(:project) do - create(:project, :repository, :bitbucket_server_import, :import_user_mapping_enabled) + create( + :project, :repository, :bitbucket_server_import, :in_group, + :import_user_mapping_enabled, :user_mapping_to_personal_namespace_owner_enabled + ) end let_it_be(:merge_request) { create(:merge_request, source_project: project) } @@ -22,6 +25,8 @@ let_it_be(:source_user) { generate_source_user(project, declined_event[:decliner_username]) } + let(:cached_references) { placeholder_user_references(::Import::SOURCE_BITBUCKET_SERVER, project.import_state.id) } + def expect_log(stage:, message:, iid:, event_id:) allow(Gitlab::BitbucketServerImport::Logger).to receive(:info).and_call_original expect(Gitlab::BitbucketServerImport::Logger) @@ -34,7 +39,6 @@ def expect_log(stage:, message:, iid:, event_id:) it 'pushes placeholder references' do importer.execute(declined_event) - cached_references = placeholder_user_references(::Import::SOURCE_BITBUCKET_SERVER, project.import_state.id) expect(cached_references).to contain_exactly( ['Event', instance_of(Integer), 'author_id', source_user.id], ["ResourceStateEvent", instance_of(Integer), "user_id", source_user.id], @@ -90,6 +94,56 @@ def expect_log(stage:, message:, iid:, event_id:) end end + context 'when importing into a personal namespace' do + let_it_be(:user_namespace) { create(:namespace) } + let_it_be(:project) do + project.update!(namespace: user_namespace) + project + end + + let_it_be(:source_user) { generate_source_user(project, declined_event[:decliner_username]) } + + it 'does not push placeholder references' do + importer.execute(declined_event) + + expect(cached_references).to be_empty + end + + it 'imports the declined event mapped to the personal namespace owner' do + importer.execute(declined_event) + + expect(merge_request.resource_state_events.first.user_id).to eq(user_namespace.owner_id) + expect(merge_request.metrics.reload.latest_closed_by_id).to eq(user_namespace.owner_id) + expect(merge_request.events.first.author_id).to eq(user_namespace.owner_id) + end + + context 'when user_mapping_to_personal_namespace_owner is disabled' do + before do + project.build_or_assign_import_data( + data: { user_mapping_to_personal_namespace_owner_enabled: false } + ).save! + end + + it 'pushes placeholder references' do + importer.execute(declined_event) + + expect(cached_references).to contain_exactly( + ['Event', instance_of(Integer), 'author_id', source_user.id], + ["ResourceStateEvent", instance_of(Integer), "user_id", source_user.id], + ['MergeRequest::Metrics', instance_of(Integer), 'latest_closed_by_id', source_user.id] + ) + end + + it 'imports the declined event mapped to the placeholder user' do + importer.execute(declined_event) + + expect(merge_request.resource_state_events.first.user_id).to eq(source_user.mapped_user_id) + expect(merge_request.metrics.reload.latest_closed_by_id).to eq(source_user.mapped_user_id) + expect(merge_request.events.first.author_id).to eq(source_user.mapped_user_id) + end + end + end + context 'when user contribution mapping is disabled' do let_it_be(:decliner_author) { create(:user, username: 'decliner_author', email: 'decliner_author@example.org') } @@ -106,7 +160,6 @@ def expect_log(stage:, message:, iid:, event_id:) it 'does not push placeholder references' do importer.execute(declined_event) - cached_references = placeholder_user_references(::Import::SOURCE_BITBUCKET_SERVER, project.import_state.id) expect(cached_references).to be_empty end diff --git a/spec/lib/gitlab/bitbucket_server_import/importers/pull_request_notes/inline_spec.rb b/spec/lib/gitlab/bitbucket_server_import/importers/pull_request_notes/inline_spec.rb index 09d95a92678346..adaf1f86ce9aa3 100644 --- a/spec/lib/gitlab/bitbucket_server_import/importers/pull_request_notes/inline_spec.rb +++ b/spec/lib/gitlab/bitbucket_server_import/importers/pull_request_notes/inline_spec.rb @@ -6,7 +6,10 @@ include Import::UserMappingHelper let_it_be_with_reload(:project) do - create(:project, :repository, :bitbucket_server_import, :import_user_mapping_enabled) + create( + :project, :repository, :bitbucket_server_import, :in_group, + :import_user_mapping_enabled, :user_mapping_to_personal_namespace_owner_enabled + ) end let_it_be(:merge_request) { create(:merge_request, source_project: project) } @@ -194,6 +197,60 @@ def expect_log(stage:, message:, iid:, comment_id:) end end + context 'when importing into a personal namespace' do + let_it_be(:user_namespace) { create(:namespace) } + let_it_be(:project) do + project.update!(namespace: user_namespace) + project + end + + let_it_be(:reply_source_user) { generate_source_user(project, reply[:author_username]) } + let_it_be(:note_source_user) { generate_source_user(project, pr_inline_comment[:author_username]) } + + it 'does not push placeholder references' do + importer.execute(pr_inline_comment) + + expect(cached_references).to be_empty + end + + it 'imports the threaded discussion mapped to the personal namespace owner' do + importer.execute(pr_inline_comment) + + notes = merge_request.notes.order(:id).to_a + start_note = notes.first + reply_note = notes.last + expect(start_note.author_id).to eq(user_namespace.owner_id) + expect(reply_note.author_id).to eq(user_namespace.owner_id) + end + + context 'when user_mapping_to_personal_namespace_owner is disabled' do + before do + project.build_or_assign_import_data( + data: { user_mapping_to_personal_namespace_owner_enabled: false } + ).save! + end + + it 'pushes placeholder references' do + importer.execute(pr_inline_comment) + + expect(cached_references).to contain_exactly( + ['DiffNote', instance_of(Integer), 'author_id', note_source_user.id], + ['DiffNote', instance_of(Integer), 'author_id', reply_source_user.id] + ) + end + + it 'imports the threaded discussion mapped to the placeholder user' do + importer.execute(pr_inline_comment) + + notes = merge_request.notes.order(:id).to_a + start_note = notes.first + reply_note = notes.last + expect(start_note.author_id).to eq(note_source_user.mapped_user_id) + expect(reply_note.author_id).to eq(reply_source_user.mapped_user_id) + end + end + end + context 'when user contribution mapping is disabled' do let_it_be(:reply_author) { create(:user, username: 'reply_author', email: 'reply_author@example.org') } let_it_be(:inline_note_author) do diff --git a/spec/lib/gitlab/bitbucket_server_import/importers/pull_request_notes/merge_event_spec.rb b/spec/lib/gitlab/bitbucket_server_import/importers/pull_request_notes/merge_event_spec.rb index edb2c04b23380b..3a7316ba1e1658 100644 --- a/spec/lib/gitlab/bitbucket_server_import/importers/pull_request_notes/merge_event_spec.rb +++ b/spec/lib/gitlab/bitbucket_server_import/importers/pull_request_notes/merge_event_spec.rb @@ -6,7 +6,10 @@ include Import::UserMappingHelper let_it_be_with_reload(:project) do - create(:project, :repository, :bitbucket_server_import, :import_user_mapping_enabled) + create( + :project, :repository, :bitbucket_server_import, :in_group, + :import_user_mapping_enabled, :user_mapping_to_personal_namespace_owner_enabled + ) end let_it_be(:merge_request) { create(:merge_request, source_project: project) } @@ -24,6 +27,8 @@ let_it_be(:source_user) { generate_source_user(project, merge_event[:committer_username]) } + let(:cached_references) { placeholder_user_references(::Import::SOURCE_BITBUCKET_SERVER, project.import_state.id) } + def expect_log(stage:, message:, iid:, event_id:) allow(Gitlab::BitbucketServerImport::Logger).to receive(:info).and_call_original expect(Gitlab::BitbucketServerImport::Logger) @@ -36,7 +41,6 @@ def expect_log(stage:, message:, iid:, event_id:) it 'pushes placeholder references' do importer.execute(merge_event) - cached_references = placeholder_user_references(::Import::SOURCE_BITBUCKET_SERVER, project.import_state.id) expect(cached_references).to contain_exactly( ['MergeRequest::Metrics', instance_of(Integer), 'merged_by_id', source_user.id] ) @@ -59,6 +63,54 @@ def expect_log(stage:, message:, iid:, event_id:) importer.execute(merge_event) end + context 'when importing into a personal namespace' do + let_it_be(:user_namespace) { create(:namespace) } + let_it_be(:project) do + project.update!(namespace: user_namespace) + project + end + + let_it_be(:source_user) { generate_source_user(project, merge_event[:committer_username]) } + + it 'does not push placeholder references' do + importer.execute(merge_event) + + expect(cached_references).to be_empty + end + + it 'imports the merge event mapped to the personal namespace owner' do + importer.execute(merge_event) + + metrics = merge_request.metrics.reload + + expect(metrics.merged_by_id).to eq(user_namespace.owner_id) + end + + context 'when user_mapping_to_personal_namespace_owner is disabled' do + before do + project.build_or_assign_import_data( + data: { user_mapping_to_personal_namespace_owner_enabled: false } + ).save! + end + + it 'pushes placeholder references' do + importer.execute(merge_event) + + expect(cached_references).to contain_exactly( + ['MergeRequest::Metrics', instance_of(Integer), 'merged_by_id', source_user.id] + ) + end + + it 'imports the merge event mapped to the placeholder user' do + importer.execute(merge_event) + + metrics = merge_request.metrics.reload + + expect(metrics.merged_by_id).to eq(source_user.mapped_user_id) + end + end + end + context 'when user contribution mapping is disabled' do let_it_be(:pull_request_author) do create(:user, username: 'pull_request_author', email: 'pull_request_author@example.org') diff --git a/spec/lib/gitlab/bitbucket_server_import/importers/pull_request_notes/standalone_notes_spec.rb b/spec/lib/gitlab/bitbucket_server_import/importers/pull_request_notes/standalone_notes_spec.rb index b9e18f756c0876..4726d2ef0466b2 100644 --- a/spec/lib/gitlab/bitbucket_server_import/importers/pull_request_notes/standalone_notes_spec.rb +++ b/spec/lib/gitlab/bitbucket_server_import/importers/pull_request_notes/standalone_notes_spec.rb @@ -6,7 +6,10 @@ include Import::UserMappingHelper let_it_be_with_reload(:project) do - create(:project, :repository, :bitbucket_server_import, :import_user_mapping_enabled) + create( + :project, :repository, :bitbucket_server_import, :in_group, + :import_user_mapping_enabled, :user_mapping_to_personal_namespace_owner_enabled + ) end let_it_be(:merge_request) { create(:merge_request, source_project: project) } @@ -216,6 +219,52 @@ def expect_log(stage:, message:, iid:, comment_id:) end end + context 'when importing into a personal namespace' do + let_it_be(:user_namespace) { create(:namespace) } + let_it_be(:project) do + project.update!(namespace: user_namespace) + project + end + + let_it_be(:source_user) { generate_source_user(project, pr_comment[:author_username]) } + + it 'does not push placeholder references' do + importer.execute(pr_comment) + + expect(cached_references).to be_empty + end + + it 'imports the stand alone comments mapped to the personal namespace owner' do + expect { importer.execute(pr_comment) }.to change { Note.count }.by(1) + expect(merge_request.notes.first).to have_attributes( + author_id: user_namespace.owner_id + ) + end + + context 'when user_mapping_to_personal_namespace_owner is disabled' do + before do + project.build_or_assign_import_data( + data: { user_mapping_to_personal_namespace_owner_enabled: false } + ).save! + end + + it 'pushes placeholder reference' do + importer.execute(pr_comment) + + expect(cached_references).to contain_exactly( + ['Note', instance_of(Integer), 'author_id', source_user.id] + ) + end + + it 'imports the stand alone comments mapped to the placeholder user' do + expect { importer.execute(pr_comment) }.to change { Note.count }.by(1) + expect(merge_request.notes.first).to have_attributes( + author_id: source_user.mapped_user_id + ) + end + end + end + context 'when user contribution mapping is disabled' do let_it_be(:note_author) { create(:user, username: 'note_author', email: 'note_author@example.org') } diff --git a/spec/lib/gitlab/bitbucket_server_import/project_creator_spec.rb b/spec/lib/gitlab/bitbucket_server_import/project_creator_spec.rb index 454e9f18c8a9f0..d5b056ba19b814 100644 --- a/spec/lib/gitlab/bitbucket_server_import/project_creator_spec.rb +++ b/spec/lib/gitlab/bitbucket_server_import/project_creator_spec.rb @@ -78,7 +78,8 @@ repo_slug: repo_slug, timeout_strategy: timeout_strategy, bitbucket_server_notes_separate_worker: true, - user_contribution_mapping_enabled: true + user_contribution_mapping_enabled: true, + user_mapping_to_personal_namespace_owner_enabled: true } }, skip_wiki: true @@ -90,10 +91,11 @@ creator.execute end - context 'when feature flags are disabled' do + context 'when all feature flags are disabled' do before do stub_feature_flags(bitbucket_server_notes_separate_worker: false) stub_feature_flags(bitbucket_server_user_mapping: false) + stub_feature_flags(user_mapping_to_personal_namespace_owner: false) end it 'disables these options in the import_data' do @@ -105,7 +107,64 @@ repo_slug: repo_slug, timeout_strategy: timeout_strategy, bitbucket_server_notes_separate_worker: false, - user_contribution_mapping_enabled: false + user_contribution_mapping_enabled: false, + user_mapping_to_personal_namespace_owner_enabled: false + } + } + } + + expect(Projects::CreateService).to receive(:new) + .with(current_user, a_hash_including(expected_params)) + + creator.execute + end + end + + context 'when user_mapping_to_personal_namespace_owner is enabled but bitbucket_server_user_mapping is disabled' do + before do + stub_feature_flags(bitbucket_server_user_mapping: false) + stub_feature_flags(user_mapping_to_personal_namespace_owner: true) + end + + it 'sets user_mapping_to_personal_namespace_owner_enabled to false' do + expected_params = { + import_data: { + credentials: session_data, + data: { + project_key: project_key, + repo_slug: repo_slug, + timeout_strategy: timeout_strategy, + bitbucket_server_notes_separate_worker: true, + user_contribution_mapping_enabled: false, + user_mapping_to_personal_namespace_owner_enabled: false + } + } + } + + expect(Projects::CreateService).to receive(:new) + .with(current_user, a_hash_including(expected_params)) + + creator.execute + end + end + + context 'when user_mapping_to_personal_namespace_owner is disabled but bitbucket_server_user_mapping is enabled' do + before do + stub_feature_flags(bitbucket_server_user_mapping: true) + stub_feature_flags(user_mapping_to_personal_namespace_owner: false) + end + + it 'sets user_mapping_to_personal_namespace_owner_enabled to false' do + expected_params = { + import_data: { + credentials: session_data, + data: { + project_key: project_key, + repo_slug: repo_slug, + timeout_strategy: timeout_strategy, + bitbucket_server_notes_separate_worker: true, + user_contribution_mapping_enabled: true, + user_mapping_to_personal_namespace_owner_enabled: false } } } diff --git a/spec/lib/gitlab/bitbucket_server_import/user_finder_spec.rb b/spec/lib/gitlab/bitbucket_server_import/user_finder_spec.rb index afdfcb22b7760b..458c9f4de21e78 100644 --- a/spec/lib/gitlab/bitbucket_server_import/user_finder_spec.rb +++ b/spec/lib/gitlab/bitbucket_server_import/user_finder_spec.rb @@ -4,9 +4,13 @@ RSpec.describe Gitlab::BitbucketServerImport::UserFinder, :clean_gitlab_redis_shared_state, feature_category: :importers do let_it_be(:user) { create(:user) } + let_it_be(:user_namespace) { create(:namespace) } let_it_be_with_reload(:project) do - create(:project, :repository, :bitbucket_server_import, :import_user_mapping_enabled) + create( + :project, :repository, :bitbucket_server_import, :in_group, + :import_user_mapping_enabled, :user_mapping_to_personal_namespace_owner_enabled + ) end let(:source_user) { build_stubbed(:import_source_user, :completed) } @@ -31,6 +35,31 @@ user_finder.author_id(user_representation) ).to eq(source_user.mapped_user.id) end + + context 'when the project is imported into a personal namespace' do + before do + project.update!(namespace: user_namespace) + end + + it 'returns the user namespace owner id' do + author_id = user_finder.author_id(user_representation) + + expect(author_id).to eq(user_namespace.owner_id) + end + + context 'when user_mapping_to_personal_namespace_owner is disabled' do + before do + allow(project.import_data).to receive(:user_mapping_to_personal_namespace_owner_enabled?) + .and_return(false) + end + + it 'returns the mapped user' do + expect( + user_finder.author_id(user_representation) + ).to eq(source_user.mapped_user.id) + end + end + end end describe '#uid' do @@ -53,6 +82,39 @@ expect(user_id).to be_nil end + + context 'when the project is imported into a personal namespace' do + before do + project.update!(namespace: user_namespace) + end + + it 'returns the user namespace owner id' do + user_id = user_finder.uid(user_representation) + + expect(user_id).to eq(user_namespace.owner_id) + end + + context 'when user_mapping_to_personal_namespace_owner is disabled' do + before do + allow(project.import_data).to receive(:user_mapping_to_personal_namespace_owner_enabled?) + .and_return(false) + end + + it 'takes a user data hash and finds the mapped user ID' do + user_id = user_finder.uid(user_representation) + + expect(user_id).to eq(source_user.mapped_user.id) + end + + it 'returns nil when username is nil' do + user_representation[:username] = nil + + user_id = user_finder.uid(user_representation) + + expect(user_id).to be_nil + end + end + end end context 'when user contribution mapping is disabled' do diff --git a/spec/models/project_import_data_spec.rb b/spec/models/project_import_data_spec.rb index b83763a4ad6aee..1a64022b8d54d9 100644 --- a/spec/models/project_import_data_spec.rb +++ b/spec/models/project_import_data_spec.rb @@ -70,4 +70,24 @@ expect(import_data.user_mapping_enabled?).to be(false) end end + + describe '#user_mapping_to_personal_namespace_owner_enabled?' do + it 'returns user_mapping_to_personal_namespace_owner_enabled when present in data' do + import_data = described_class.new(data: { 'user_mapping_to_personal_namespace_owner_enabled' => true }) + + expect(import_data.user_mapping_to_personal_namespace_owner_enabled?).to be(true) + end + + it 'returns false when user_mapping_to_personal_namespace_owner_enabled is not present in data' do + import_data = described_class.new(data: { 'number' => 10 }) + + expect(import_data.user_mapping_to_personal_namespace_owner_enabled?).to be(false) + end + + it 'returns false when data is nil' do + import_data = described_class.new + + expect(import_data.user_mapping_to_personal_namespace_owner_enabled?).to be(false) + end + end end diff --git a/spec/workers/gitlab/bitbucket_server_import/stage/finish_import_worker_spec.rb b/spec/workers/gitlab/bitbucket_server_import/stage/finish_import_worker_spec.rb index d3304ea2c5573f..060b3ae06c3de0 100644 --- a/spec/workers/gitlab/bitbucket_server_import/stage/finish_import_worker_spec.rb +++ b/spec/workers/gitlab/bitbucket_server_import/stage/finish_import_worker_spec.rb @@ -3,7 +3,13 @@ require 'spec_helper' RSpec.describe Gitlab::BitbucketServerImport::Stage::FinishImportWorker, feature_category: :importers do - let_it_be(:project) { create(:project, :import_started, import_type: :bitbucket_server) } + let_it_be_with_reload(:project) do + create( + :project, :import_started, :in_group, + :user_mapping_to_personal_namespace_owner_enabled, + import_type: :bitbucket_server + ) + end subject(:worker) { described_class.new } @@ -78,6 +84,51 @@ worker.perform(project.id) end + + context 'when importing into a personal namespace' do + before do + project.update!(namespace: create(:namespace)) + + allow_next_instance_of(::Import::PlaceholderReferences::Store) do |store| + allow(store).to receive_messages(any?: true) + end + end + + it 'does not queue LoadPlaceholderReferencesWorker' do + expect(Import::LoadPlaceholderReferencesWorker).not_to receive(:perform_async) + + worker.perform(project.id) + end + + it 'does not re-enqueue itself' do + expect(described_class).not_to receive(:perform_in) + + worker.perform(project.id) + end + + context 'when user_mapping_to_personal_namespace_owner is disabled' do + before do + project.build_or_assign_import_data( + data: { user_mapping_to_personal_namespace_owner_enabled: false } + ).save! + end + + it 'queues LoadPlaceholderReferencesWorker' do + expect(Import::LoadPlaceholderReferencesWorker).to receive(:perform_async).with( + project.import_type, + project.import_state.id + ) + + worker.perform(project.id) + end + + it 'schedules itself to run again after 30 seconds' do + expect(described_class).to receive(:perform_in).with(30.seconds, project.id) + + worker.perform(project.id) + end + end + end end end end -- GitLab From 5af0bf43183d08b7c26becd925c27da028b1a539 Mon Sep 17 00:00:00 2001 From: Sam Word Date: Mon, 21 Jul 2025 10:56:02 -0400 Subject: [PATCH 2/4] Updates from feedback --- .../user_mapping_to_personal_namespace_owner.yml | 2 +- lib/gitlab/bitbucket_server_import/user_finder.rb | 4 ++-- spec/models/project_import_data_spec.rb | 14 +++++++++----- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/config/feature_flags/wip/user_mapping_to_personal_namespace_owner.yml b/config/feature_flags/wip/user_mapping_to_personal_namespace_owner.yml index 771e286b0576c0..a954c590b13c1c 100644 --- a/config/feature_flags/wip/user_mapping_to_personal_namespace_owner.yml +++ b/config/feature_flags/wip/user_mapping_to_personal_namespace_owner.yml @@ -2,7 +2,7 @@ name: user_mapping_to_personal_namespace_owner description: Assign imported contributions to personal namespace owner for all importers feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/525342 -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/197360 +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/198010 rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/556557 milestone: '18.3' group: group::import diff --git a/lib/gitlab/bitbucket_server_import/user_finder.rb b/lib/gitlab/bitbucket_server_import/user_finder.rb index 0d8bdfacea7fc1..522814380a9e64 100644 --- a/lib/gitlab/bitbucket_server_import/user_finder.rb +++ b/lib/gitlab/bitbucket_server_import/user_finder.rb @@ -64,11 +64,11 @@ def build_cache_key(by, value) end def user_mapping_enabled?(project) - !!project.import_data.user_mapping_enabled? + project.import_data.user_mapping_enabled? end def user_mapping_to_personal_namespace_owner_enabled?(project) - !!project.import_data.user_mapping_to_personal_namespace_owner_enabled? + project.import_data.user_mapping_to_personal_namespace_owner_enabled? end def source_user_for_author(user_data) diff --git a/spec/models/project_import_data_spec.rb b/spec/models/project_import_data_spec.rb index 1a64022b8d54d9..c626b550148bc2 100644 --- a/spec/models/project_import_data_spec.rb +++ b/spec/models/project_import_data_spec.rb @@ -53,9 +53,11 @@ describe '#user_mapping_enabled?' do it 'returns user_contribution_mapping_enabled when present in data' do - import_data = described_class.new(data: { 'user_contribution_mapping_enabled' => true }) + import_data_enabled = described_class.new(data: { 'user_contribution_mapping_enabled' => true }) + import_data_disabled = described_class.new(data: { 'user_contribution_mapping_enabled' => false }) - expect(import_data.user_mapping_enabled?).to be(true) + expect(import_data_enabled.user_mapping_enabled?).to be(true) + expect(import_data_disabled.user_mapping_enabled?).to be(false) end it 'returns false when user_contribution_mapping_enabled is not present in data' do @@ -72,10 +74,12 @@ end describe '#user_mapping_to_personal_namespace_owner_enabled?' do - it 'returns user_mapping_to_personal_namespace_owner_enabled when present in data' do - import_data = described_class.new(data: { 'user_mapping_to_personal_namespace_owner_enabled' => true }) + it 'returns user_mapping_to_personal_namespace_owner_enabled when present in data', :aggregate_failures do + import_data_enabled = described_class.new(data: { 'user_mapping_to_personal_namespace_owner_enabled' => true }) + import_data_disabled = described_class.new(data: { 'user_mapping_to_personal_namespace_owner_enabled' => false }) - expect(import_data.user_mapping_to_personal_namespace_owner_enabled?).to be(true) + expect(import_data_enabled.user_mapping_to_personal_namespace_owner_enabled?).to be(true) + expect(import_data_disabled.user_mapping_to_personal_namespace_owner_enabled?).to be(false) end it 'returns false when user_mapping_to_personal_namespace_owner_enabled is not present in data' do -- GitLab From d63558db296edd5045e9f65c545ef3c2283519d3 Mon Sep 17 00:00:00 2001 From: Sam Word Date: Mon, 21 Jul 2025 11:56:12 -0400 Subject: [PATCH 3/4] Added newline to end of FF --- .../wip/user_mapping_to_personal_namespace_owner.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/feature_flags/wip/user_mapping_to_personal_namespace_owner.yml b/config/feature_flags/wip/user_mapping_to_personal_namespace_owner.yml index a954c590b13c1c..87977895847e52 100644 --- a/config/feature_flags/wip/user_mapping_to_personal_namespace_owner.yml +++ b/config/feature_flags/wip/user_mapping_to_personal_namespace_owner.yml @@ -7,4 +7,4 @@ rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/556557 milestone: '18.3' group: group::import type: wip -default_enabled: false \ No newline at end of file +default_enabled: false -- GitLab From 130d5a92eb670af43bb5b2b40866e268645a9768 Mon Sep 17 00:00:00 2001 From: Sam Word Date: Tue, 22 Jul 2025 11:05:21 -0400 Subject: [PATCH 4/4] Removed unnecessary personal namespace check --- .../stage/finish_import_worker.rb | 7 +-- .../stage/finish_import_worker_spec.rb | 45 ------------------- 2 files changed, 1 insertion(+), 51 deletions(-) diff --git a/app/workers/gitlab/bitbucket_server_import/stage/finish_import_worker.rb b/app/workers/gitlab/bitbucket_server_import/stage/finish_import_worker.rb index 136773642ecd32..ecce4f50a327b0 100644 --- a/app/workers/gitlab/bitbucket_server_import/stage/finish_import_worker.rb +++ b/app/workers/gitlab/bitbucket_server_import/stage/finish_import_worker.rb @@ -14,7 +14,7 @@ class FinishImportWorker # rubocop:disable Scalability/IdempotentWorker def import(project) placeholder_reference_store = project.placeholder_reference_store - if placeholder_reference_store&.any? && !map_to_personal_namespace_owner?(project) + if placeholder_reference_store&.any? info( project.id, message: 'Delaying finalization as placeholder references are pending', @@ -39,11 +39,6 @@ def reschedule(project) self.class.perform_in(PLACEHOLDER_WAIT_INTERVAL.seconds, project.id) end - - def map_to_personal_namespace_owner?(project) - project.root_ancestor.user_namespace? && - project.import_data.user_mapping_to_personal_namespace_owner_enabled? - end end end end diff --git a/spec/workers/gitlab/bitbucket_server_import/stage/finish_import_worker_spec.rb b/spec/workers/gitlab/bitbucket_server_import/stage/finish_import_worker_spec.rb index 060b3ae06c3de0..6503896cd8b5df 100644 --- a/spec/workers/gitlab/bitbucket_server_import/stage/finish_import_worker_spec.rb +++ b/spec/workers/gitlab/bitbucket_server_import/stage/finish_import_worker_spec.rb @@ -84,51 +84,6 @@ worker.perform(project.id) end - - context 'when importing into a personal namespace' do - before do - project.update!(namespace: create(:namespace)) - - allow_next_instance_of(::Import::PlaceholderReferences::Store) do |store| - allow(store).to receive_messages(any?: true) - end - end - - it 'does not queue LoadPlaceholderReferencesWorker' do - expect(Import::LoadPlaceholderReferencesWorker).not_to receive(:perform_async) - - worker.perform(project.id) - end - - it 'does not re-enqueue itself' do - expect(described_class).not_to receive(:perform_in) - - worker.perform(project.id) - end - - context 'when user_mapping_to_personal_namespace_owner is disabled' do - before do - project.build_or_assign_import_data( - data: { user_mapping_to_personal_namespace_owner_enabled: false } - ).save! - end - - it 'queues LoadPlaceholderReferencesWorker' do - expect(Import::LoadPlaceholderReferencesWorker).to receive(:perform_async).with( - project.import_type, - project.import_state.id - ) - - worker.perform(project.id) - end - - it 'schedules itself to run again after 30 seconds' do - expect(described_class).to receive(:perform_in).with(30.seconds, project.id) - - worker.perform(project.id) - end - end - end end end end -- GitLab