From 5d9edb28aed17826c27627cae74d06239fa21cfc Mon Sep 17 00:00:00 2001 From: agius Date: Wed, 7 May 2025 10:41:04 -0700 Subject: [PATCH 1/9] User OrganizationUserDetail in mentions, autocomplete, participants Part 3 of our effort to allow internal bot users to be scoped per-organization. This allows users with an Organizations::OrganizationUserDetail record to be @ mentioned within projects or groups in an organization by the alias rather than by their username. Noteables mentioning these users will link to the orignal username, not the alias, but will display with the alias. Changelog: added --- .../organizations/organization_user_detail.rb | 31 ++++++++ .../concerns/users/participable_service.rb | 15 ++++ app/services/projects/participants_service.rb | 5 ++ .../references/user_reference_filter.rb | 28 ++++++- .../references/user_reference_filter_spec.rb | 25 +++++++ .../organization_user_detail_spec.rb | 75 +++++++++++++++++++ .../projects/participants_service_spec.rb | 39 +++++++++- 7 files changed, 216 insertions(+), 2 deletions(-) diff --git a/app/models/organizations/organization_user_detail.rb b/app/models/organizations/organization_user_detail.rb index 96862832a03274..a5922b7fa624ad 100644 --- a/app/models/organizations/organization_user_detail.rb +++ b/app/models/organizations/organization_user_detail.rb @@ -2,10 +2,41 @@ module Organizations class OrganizationUserDetail < ApplicationRecord + include Referable + belongs_to :organization, inverse_of: :organization_user_details, optional: false belongs_to :user, inverse_of: :organization_user_details, optional: false validates :username, presence: true, uniqueness: { scope: :organization_id } validates :display_name, presence: true + + scope :for_references, -> { includes(:organization, :user) } + scope :for_organization, ->(organization) { where(organization: organization) } + scope :with_usernames, ->(*usernames) { + uniq_usernames = usernames.flatten.compact.uniq + return none if uniq_usernames.blank? + + downcase_usernames = uniq_usernames.map(&:downcase) + + where("LOWER(username) IN (?)", downcase_usernames) + } + + # Referable methods should be the same as User + def reference_prefix + '@' + end + + def reference_pattern + @reference_pattern ||= + %r{ + (?#{Gitlab::PathRegex::FULL_NAMESPACE_FORMAT_REGEX}) + }x + end + + def to_reference(*) + "#{reference_prefix}#{username}" + end end end diff --git a/app/services/concerns/users/participable_service.rb b/app/services/concerns/users/participable_service.rb index a8e7d238414fde..76f00c52e85053 100644 --- a/app/services/concerns/users/participable_service.rb +++ b/app/services/concerns/users/participable_service.rb @@ -73,6 +73,8 @@ def participant_as_hash(participant) group_as_hash(participant) when User user_as_hash(participant) + when Organizations::OrganizationUserDetail + org_user_detail_as_hash(participant) else participant end @@ -99,6 +101,19 @@ def group_as_hash(group) } end + def org_user_detail_as_hash(org_user_detail) + user = org_user_detail.user + { + type: user.class.name, + username: org_user_detail.username, + name: org_user_detail.display_name, + avatar_url: user.avatar_url, + availability: lazy_user_availability(user).itself, # calling #itself to avoid returning a BatchLoader instance + original_username: user.username, + original_displayname: user.name + } + end + def group_counts groups_for_count = params[:search] ? groups : current_user.authorized_groups diff --git a/app/services/projects/participants_service.rb b/app/services/projects/participants_service.rb index 7e9dbce4e6d7a8..6518d4d37e7a7e 100644 --- a/app/services/projects/participants_service.rb +++ b/app/services/projects/participants_service.rb @@ -12,6 +12,7 @@ def execute(noteable) noteable_owner + participants_in_noteable + all_members + + organization_user_details + project_members participants += groups unless relation_at_search_limit?(project_members) @@ -30,6 +31,10 @@ def all_members [{ username: "all", name: "All Project and Group Members", count: project_members_relation.count }] end + def organization_user_details + project.organization.organization_user_details + end + def project_members_relation project.authorized_users end diff --git a/lib/banzai/filter/references/user_reference_filter.rb b/lib/banzai/filter/references/user_reference_filter.rb index bce181b6a882d1..8971530079858a 100644 --- a/lib/banzai/filter/references/user_reference_filter.rb +++ b/lib/banzai/filter/references/user_reference_filter.rb @@ -49,7 +49,10 @@ def object_link_filter(text, pattern, link_content: nil, link_reference: false) link_to_all(link_content: link_content) else cached_call(:banzai_url_for_object, match, path: [User, username.downcase]) do - if namespace = namespaces[username.downcase] + # order is important: per-organization usernames should be checked before global namespace + if org_user_detail = org_user_details[username.downcase] + link_to_org_user_detail(org_user_detail) + elsif namespace = namespaces[username.downcase] link_to_namespace(namespace, link_content: link_content) || match else match @@ -71,6 +74,16 @@ def namespaces .transform_keys(&:downcase) end + # check for users that have an aliased name within an organization, + # for example the bot users created by Users::Internal + def org_user_details + @org_user_details ||= Organizations::OrganizationUserDetail.for_references + .for_organization(organization) + .with_usernames(usernames) + .index_by(&:username) + .transform_keys(&:downcase) + end + # Returns all usernames referenced in the current document. def usernames refs = Set.new @@ -126,10 +139,23 @@ def link_to_user(user, namespace, link_content: nil) link_tag(url, data, content, namespace.owner_name) end + def link_to_org_user_detail(org_user_detail, link_content: nil) + user = org_user_detail.user + url = urls.user_url(user, only_path: context[:only_path]) + data = data_attribute(user: user.id) + content = link_content || org_user_detail.to_reference + + link_tag(url, data, content, org_user_detail.username) + end + def link_tag(url, data, link_content, title) %(#{link_content}) end + def organization + @organization ||= parent&.organization || Organizations::Organization.first + end + def parent context[:project] || context[:group] end diff --git a/spec/lib/banzai/filter/references/user_reference_filter_spec.rb b/spec/lib/banzai/filter/references/user_reference_filter_spec.rb index d37993cb316b76..5764644d62a32f 100644 --- a/spec/lib/banzai/filter/references/user_reference_filter_spec.rb +++ b/spec/lib/banzai/filter/references/user_reference_filter_spec.rb @@ -223,6 +223,31 @@ def get_reference(user) end end + describe '#org_user_detail' do + let(:org_user_detail) { create(:organization_user_detail, organization: project.organization) } + + it 'supports mentioning users aliased within organization' do + reference = org_user_detail.to_reference + doc = reference_filter("Hey #{reference}", project: project) + + expect(doc.css('a').first.attr('href')).to eq urls.user_url(org_user_detail.user) + end + + context 'in group context' do + let(:group) { create(:group, developers: [group_member]) } + let(:group_member) { create(:user) } + let(:org_user_detail) { create(:organization_user_detail, organization: group.organization) } + let(:context) { { author: group_member, project: nil, group: group } } + + it 'supports mentioning a single user' do + reference = org_user_detail.to_reference + doc = reference_filter("Hey #{reference}", context) + + expect(doc.css('a').first.attr('href')).to eq urls.user_url(org_user_detail.user) + end + end + end + context 'checking N+1' do let(:user2) { create(:user) } let(:group) { create(:group) } diff --git a/spec/models/organizations/organization_user_detail_spec.rb b/spec/models/organizations/organization_user_detail_spec.rb index 268f6009db94ec..341aa4fd1ebdb4 100644 --- a/spec/models/organizations/organization_user_detail_spec.rb +++ b/spec/models/organizations/organization_user_detail_spec.rb @@ -15,4 +15,79 @@ it { is_expected.to validate_presence_of(:display_name) } it { is_expected.to validate_uniqueness_of(:username).scoped_to(:organization_id) } end + + describe 'scopes' do + let_it_be(:organization_user_detail) { create(:organization_user_detail) } + + describe '.for_references' do + it 'includes related records' do + instance = nil + + ActiveRecord::QueryRecorder.new(skip_cached: false) do + instance = described_class.for_references.where(id: organization_user_detail.id).first + end + + experiment = ActiveRecord::QueryRecorder.new(skip_cached: false) do + expect(instance.user).to be_a(User) + expect(instance.organization).to be_a(Organizations::Organization) + end + + expect(experiment.count).to eq(0) + end + end + + describe '.for_organization' do + it 'scopes query to organization' do + expect(described_class.for_organization(organization_user_detail.organization).count).to eq(1) + expect(described_class.for_organization(99999).count).to eq(0) + end + end + + describe '.with_usernames' do + it 'locates all users within argument' do + username = organization_user_detail.username + expect(described_class.with_usernames('fakeusername', username).count).to eq(1) + end + + it 'matches usernames case-insensitively' do + username = organization_user_detail.username + expect(described_class.with_usernames('fakeusername', username.upcase).count).to eq(1) + end + + it 'returns none when no arguments / nil arguments passed' do + expect(described_class.with_usernames.count).to eq(0) + expect(described_class.with_usernames(nil).count).to eq(0) + end + end + + describe 'Referable methods' do + describe 'reference_prefix' do + subject(:reference_prefix) { described_class.new.reference_prefix } + + it { is_expected.to eq('@') } + end + + describe 'reference_pattern' do + subject(:reference_pattern) { described_class.new.reference_pattern } + + it 'matches @username patterns' do + expect(reference_pattern).to be_a(Regexp) + + expect(reference_pattern.match?('@username')).to be true + expect(reference_pattern.match?('@user.name')).to be true + expect(reference_pattern.match?('@user-name')).to be true + expect(reference_pattern.match?('@-name')).to be false + expect(reference_pattern.match?('user@name')).to be false + end + end + + describe 'to_reference' do + subject(:to_reference) { described_class.new(username: username).to_reference } + + let(:username) { 'test_user_name' } + + it { is_expected.to eq('@test_user_name') } + end + end + end end diff --git a/spec/services/projects/participants_service_spec.rb b/spec/services/projects/participants_service_spec.rb index ffaa955df673eb..b645d780825400 100644 --- a/spec/services/projects/participants_service_spec.rb +++ b/spec/services/projects/participants_service_spec.rb @@ -7,6 +7,8 @@ let_it_be(:user) { create(:user) } let_it_be(:project) { create(:project, :public) } let_it_be(:noteable) { create(:issue, project: project) } + let_it_be(:org_user_detail) { create(:organization_user_detail, organization: project.organization) } + let_it_be(:other_org_user_detail) { create(:organization_user_detail) } let(:params) { {} } @@ -26,7 +28,7 @@ def run_service group = create(:group, owners: user) expect(run_service.pluck(:username)).to eq([ - noteable.author.username, 'all', user.username, group.full_path + noteable.author.username, 'all', org_user_detail.username, user.username, group.full_path ]) end @@ -87,6 +89,27 @@ def run_service end end + describe 'organization_user_detail items' do + subject(:org_user_detail_items) { run_service.select { |hash| hash[:original_username].present? } } + + it 'includes items for per-organization user details within the noteable organization' do + expect(org_user_detail_items).to include(a_hash_including( + type: User.name, + username: org_user_detail.username, + name: org_user_detail.display_name, + avatar_url: org_user_detail.user.avatar_url, + original_username: org_user_detail.user.username, + original_displayname: org_user_detail.user.name + )) + end + + it 'does not include items from other organizations' do + expect(org_user_detail_items).not_to include(a_hash_including( + username: other_org_user_detail.username + )) + end + end + describe 'group items' do subject(:group_items) { run_service.select { |hash| hash[:type].eql?('Group') } } @@ -181,6 +204,20 @@ def run_service end end + describe '#organization_user_details' do + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project, :public) } + let_it_be(:org_user_detail) { create(:organization_user_detail, organization: project.organization) } + let_it_be(:other_org_user_detail) { create(:organization_user_detail) } + + let(:service) { described_class.new(project, user) } + + subject(:org_user_details) { service.organization_user_details } + + it { is_expected.to include(org_user_detail) } + it { is_expected.not_to include(other_org_user_detail) } + end + describe '#project_members' do subject(:usernames) { service.project_members.map { |member| member[:username] } } -- GitLab From a6718bd0587fd994002100b3eb16075ce5015d0f Mon Sep 17 00:00:00 2001 From: agius Date: Sun, 1 Jun 2025 20:52:56 -0700 Subject: [PATCH 2/9] Set internal usernames and display names per-organization Sets the usernames and display names for internal user bots per-organization, to ensure we don't have a linearly increasing search time to find a valid username for new organization bots. --- lib/users/internal.rb | 36 +++++++++++++++++++++------- spec/lib/users/internal_spec.rb | 42 +++++++++++++++++++++++++-------- 2 files changed, 59 insertions(+), 19 deletions(-) diff --git a/lib/users/internal.rb b/lib/users/internal.rb index 095c35bbc64c18..4f7cef27d5b7dd 100644 --- a/lib/users/internal.rb +++ b/lib/users/internal.rb @@ -40,7 +40,7 @@ def ghost unique_internal(User.where(user_type: :ghost), 'ghost', email) do |u| u.bio = _('This is a "Ghost User", created to hold all issues authored by users that have ' \ 'since been deleted. This user cannot be removed.') - u.name = 'Ghost User' + u.name = display_name_with_organization_suffix('Ghost') end end @@ -49,7 +49,7 @@ def alert_bot unique_internal(User.where(user_type: :alert_bot), 'alert-bot', email_pattern) do |u| u.bio = 'The GitLab alert bot' - u.name = 'GitLab Alert Bot' + u.name = display_name_with_organization_suffix('GitLab Alert Bot') u.avatar = bot_avatar(image: 'alert-bot.png') u.confirmed_at = Time.zone.now u.private_profile = true @@ -61,7 +61,7 @@ def migration_bot unique_internal(User.where(user_type: :migration_bot), 'migration-bot', email_pattern) do |u| u.bio = 'The GitLab migration bot' - u.name = 'GitLab Migration Bot' + u.name = display_name_with_organization_suffix('GitLab Migration Bot') u.confirmed_at = Time.zone.now u.private_profile = true end @@ -73,7 +73,7 @@ def security_bot unique_internal(User.where(user_type: :security_bot), 'GitLab-Security-Bot', email_pattern) do |u| u.bio = 'System bot that monitors detected vulnerabilities for solutions ' \ 'and creates merge requests with the fixes.' - u.name = 'GitLab Security Bot' + u.name = display_name_with_organization_suffix('GitLab Security Bot') u.avatar = bot_avatar(image: 'security-bot.png') u.confirmed_at = Time.zone.now u.private_profile = true @@ -85,7 +85,7 @@ def support_bot unique_internal(User.where(user_type: :support_bot), 'support-bot', email_pattern) do |u| u.bio = 'The GitLab support bot used for Service Desk' - u.name = 'GitLab Support Bot' + u.name = display_name_with_organization_suffix('GitLab Support Bot') u.avatar = bot_avatar(image: 'support-bot.png') u.confirmed_at = Time.zone.now u.private_profile = true @@ -101,7 +101,7 @@ def automation_bot unique_internal(User.where(user_type: :automation_bot), 'automation-bot', email_pattern) do |u| u.bio = 'The GitLab automation bot used for automated workflows and tasks' - u.name = 'GitLab Automation Bot' + u.name = display_name_with_organization_suffix('GitLab Automation Bot') u.avatar = bot_avatar(image: 'support-bot.png') # todo: add an avatar for automation-bot u.confirmed_at = Time.zone.now u.private_profile = true @@ -113,7 +113,7 @@ def llm_bot unique_internal(User.where(user_type: :llm_bot), 'GitLab-Llm-Bot', email_pattern) do |u| u.bio = 'The Gitlab LLM bot used for fetching LLM-generated content' - u.name = 'GitLab LLM Bot' + u.name = display_name_with_organization_suffix('GitLab LLM Bot') u.avatar = bot_avatar(image: 'support-bot.png') # todo: add an avatar for llm-bot u.confirmed_at = Time.zone.now u.private_profile = true @@ -125,7 +125,7 @@ def duo_code_review_bot unique_internal(User.where(user_type: :duo_code_review_bot), 'GitLabDuo', email_pattern) do |u| u.bio = 'GitLab Duo bot for handling AI tasks' - u.name = 'GitLab Duo' + u.name = display_name_with_organization_suffix('GitLab Duo') u.avatar = bot_avatar(image: 'duo-bot.png') u.confirmed_at = Time.zone.now u.private_profile = true @@ -137,7 +137,7 @@ def admin_bot unique_internal(User.where(user_type: :admin_bot), 'GitLab-Admin-Bot', email_pattern) do |u| u.bio = 'Admin bot used for tasks that require admin privileges' - u.name = 'GitLab Admin Bot' + u.name = display_name_with_organization_suffix('GitLab Admin Bot') u.avatar = bot_avatar(image: 'admin-bot.png') u.admin = true u.confirmed_at = Time.zone.now @@ -161,6 +161,22 @@ def unique_internal(scope, username, email_pattern, &block) scope.first || create_unique_internal(scope, username, email_pattern, &block) end + def first_organization + Organizations::Organization.first + end + + def username_with_organization_suffix(username) + return username if @organization.nil? || @organization.path == 'default' + + [username, @organization.path].join('_') + end + + def display_name_with_organization_suffix(display_name) + return display_name if @organization.nil? || @organization.path == 'default' + + "#{display_name} (#{@organization.name})" + end + def create_unique_internal(scope, username, email_pattern, &creation_block) # Since we only want a single one of these in an instance, we use an # exclusive lease to ensure than this block is never run concurrently. @@ -188,6 +204,8 @@ def create_unique_internal(scope, username, email_pattern, &creation_block) uniquify = Gitlab::Utils::Uniquify.new + username = username_with_organization_suffix(username) + username = uniquify.string(username) { |s| Namespace.by_path(s) } email = uniquify.string(->(n) { Kernel.sprintf(email_pattern, n) }) do |s| diff --git a/spec/lib/users/internal_spec.rb b/spec/lib/users/internal_spec.rb index 362022869777ec..6e28dc2ddf7b78 100644 --- a/spec/lib/users/internal_spec.rb +++ b/spec/lib/users/internal_spec.rb @@ -7,6 +7,14 @@ let_it_be(:organization) { create(:organization) } shared_examples 'bot users' do |bot_type, username, email| + subject(:bot_user) { described_class.for_organization(organization).public_send(bot_type) } + + let(:bot_username) do + described_class.for_organization(organization).send( + :username_with_organization_suffix, username + ) + end + it 'creates the user if it does not exist' do expect do described_class.for_organization(organization).public_send(bot_type) @@ -14,15 +22,17 @@ end it 'creates a route for the namespace of the created user' do - bot_user = described_class.for_organization(organization).public_send(bot_type) - expect(bot_user.namespace.route).to be_present expect(bot_user.namespace.organization).to eq(organization) end - it 'assigns the organization to the created user' do - bot_user = described_class.for_organization(organization).public_send(bot_type) + it 'creates a user with a username suffixed with the organization name' do + expect(bot_user.username).to include(username) + expect(bot_user.username).to include(organization.path) + expect(bot_user.name).to include("(#{organization.name})") + end + it 'assigns the organization to the created user' do expect(bot_user.organizations).to eq([organization]) end @@ -36,10 +46,10 @@ context 'when a regular user exists with the bot username' do it 'creates a user with a non-conflicting username' do - create(:user, username: username) + create(:user, username: bot_username) expect do - described_class.for_organization(organization).public_send(bot_type) + bot_user end.to change { User.where(user_type: bot_type).count }.by(1) end end @@ -49,17 +59,17 @@ create(:user, email: email) expect do - described_class.for_organization(organization).public_send(bot_type) + bot_user end.to change { User.where(user_type: bot_type).count }.by(1) end end context 'when a group namespace exists with path that is equal to the bot username' do it 'creates a user with a non-conflicting username' do - create(:group, path: username) + create(:group, path: bot_username) expect do - described_class.for_organization(organization).public_send(bot_type) + bot_user end.to change { User.where(user_type: bot_type).count }.by(1) end end @@ -71,10 +81,22 @@ it 'creates the bot user' do expect do - described_class.for_organization(organization).public_send(bot_type) + bot_user end.to change { User.where(user_type: bot_type).count }.by(1) end end + + context 'when no organization is passed' do + subject(:bot_user) { described_class.public_send(bot_type) } + + it 'sets username without suffix' do + expect(bot_user.username).to eq(username) + end + + it 'sets display name without suffix' do + expect(bot_user.name).not_to include(organization.name) + end + end end shared_examples 'bot user avatars' do |bot_type, avatar_filename| -- GitLab From 813764a1eb3e919e195cc2742b5dac761c3d7f70 Mon Sep 17 00:00:00 2001 From: agius Date: Sun, 1 Jun 2025 21:36:03 -0700 Subject: [PATCH 3/9] Add organization_users_internal FF Adds a feature flag so we can control whether per-org internal users or the internal users from the default org are used by the application --- app/services/projects/participants_service.rb | 2 ++ .../beta/organization_users_internal.yml | 10 +++++++ .../references/user_reference_filter.rb | 13 +++++---- lib/users/internal.rb | 22 ++++++++++----- .../references/user_reference_filter_spec.rb | 27 +++++++++++++++++++ spec/lib/users/internal_spec.rb | 24 +++++++++++++++++ .../projects/participants_service_spec.rb | 16 +++++++++++ 7 files changed, 103 insertions(+), 11 deletions(-) create mode 100644 config/feature_flags/beta/organization_users_internal.yml diff --git a/app/services/projects/participants_service.rb b/app/services/projects/participants_service.rb index 6518d4d37e7a7e..3a1fec3d994939 100644 --- a/app/services/projects/participants_service.rb +++ b/app/services/projects/participants_service.rb @@ -32,6 +32,8 @@ def all_members end def organization_user_details + return [] unless Feature.enabled?(:organization_users_internal, project.organization) + project.organization.organization_user_details end diff --git a/config/feature_flags/beta/organization_users_internal.yml b/config/feature_flags/beta/organization_users_internal.yml new file mode 100644 index 00000000000000..697feb9aa273fb --- /dev/null +++ b/config/feature_flags/beta/organization_users_internal.yml @@ -0,0 +1,10 @@ +--- +name: organization_users_internal +description: +feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/442780 +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/190607 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/547109 +milestone: '18.1' +group: group::authentication +type: beta +default_enabled: false diff --git a/lib/banzai/filter/references/user_reference_filter.rb b/lib/banzai/filter/references/user_reference_filter.rb index 8971530079858a..01613a98e5c1b2 100644 --- a/lib/banzai/filter/references/user_reference_filter.rb +++ b/lib/banzai/filter/references/user_reference_filter.rb @@ -77,11 +77,14 @@ def namespaces # check for users that have an aliased name within an organization, # for example the bot users created by Users::Internal def org_user_details - @org_user_details ||= Organizations::OrganizationUserDetail.for_references - .for_organization(organization) - .with_usernames(usernames) - .index_by(&:username) - .transform_keys(&:downcase) + return @org_user_details if @org_user_details + return @org_user_details = {} unless Feature.enabled?(:organization_users_internal, organization) + + @org_user_details = Organizations::OrganizationUserDetail.for_references + .for_organization(organization) + .with_usernames(usernames) + .index_by(&:username) + .transform_keys(&:downcase) end # Returns all usernames referenced in the current document. diff --git a/lib/users/internal.rb b/lib/users/internal.rb index 4f7cef27d5b7dd..868a2137a83dd7 100644 --- a/lib/users/internal.rb +++ b/lib/users/internal.rb @@ -28,6 +28,8 @@ def support_bot_id strong_memoize_attr :support_bot_id end + include Gitlab::Utils::StrongMemoize + # rubocop:disable CodeReuse/ActiveRecord -- Need to instantiate a record here def initialize(organization: nil) @organization = organization @@ -154,29 +156,32 @@ def bot_avatar(image:) # NOTE: This method is patched in spec/spec_helper.rb to allow use of exclusive lease in RSpec's # :before_all scope to keep the specs DRY. def unique_internal(scope, username, email_pattern, &block) - if @organization + if @organization && organization_users_internal_enabled? scope = scope.joins(:organization_users).where(organization_users: { organization: @organization }) end scope.first || create_unique_internal(scope, username, email_pattern, &block) end - def first_organization - Organizations::Organization.first - end - def username_with_organization_suffix(username) return username if @organization.nil? || @organization.path == 'default' + return username unless organization_users_internal_enabled? [username, @organization.path].join('_') end def display_name_with_organization_suffix(display_name) return display_name if @organization.nil? || @organization.path == 'default' + return display_name unless organization_users_internal_enabled? "#{display_name} (#{@organization.name})" end + def organization_users_internal_enabled? + Feature.enabled?(:organization_users_internal, @organization) + end + strong_memoize_attr :organization_users_internal_enabled? + def create_unique_internal(scope, username, email_pattern, &creation_block) # Since we only want a single one of these in an instance, we use an # exclusive lease to ensure than this block is never run concurrently. @@ -218,7 +223,12 @@ def create_unique_internal(scope, username, email_pattern, &creation_block) &creation_block ) - user_organization = @organization || Organizations::Organization.first + user_organization = if @organization && organization_users_internal_enabled? + @organization + else + Organizations::Organization.first + end + user.assign_personal_namespace(user_organization) user.organizations << user_organization diff --git a/spec/lib/banzai/filter/references/user_reference_filter_spec.rb b/spec/lib/banzai/filter/references/user_reference_filter_spec.rb index 5764644d62a32f..bc18309aa3719b 100644 --- a/spec/lib/banzai/filter/references/user_reference_filter_spec.rb +++ b/spec/lib/banzai/filter/references/user_reference_filter_spec.rb @@ -246,6 +246,33 @@ def get_reference(user) expect(doc.css('a').first.attr('href')).to eq urls.user_url(org_user_detail.user) end end + + context 'when organization_users_internal FF is disabled' do + before do + stub_feature_flags(organization_users_internal: false) + end + + it 'does not support mentioning users aliased within organization' do + reference = org_user_detail.to_reference + doc = reference_filter("Hey #{reference}", project: project) + + expect(doc.css('a')).to be_empty + end + + context 'in group context' do + let(:group) { create(:group, developers: [group_member]) } + let(:group_member) { create(:user) } + let(:org_user_detail) { create(:organization_user_detail, organization: group.organization) } + let(:context) { { author: group_member, project: nil, group: group } } + + it 'does not support mentioning a single user' do + reference = org_user_detail.to_reference + doc = reference_filter("Hey #{reference}", context) + + expect(doc.css('a')).to be_empty + end + end + end end context 'checking N+1' do diff --git a/spec/lib/users/internal_spec.rb b/spec/lib/users/internal_spec.rb index 6e28dc2ddf7b78..32463e34670820 100644 --- a/spec/lib/users/internal_spec.rb +++ b/spec/lib/users/internal_spec.rb @@ -97,6 +97,30 @@ expect(bot_user.name).not_to include(organization.name) end end + + context 'when organization_users_internal FF is disabled' do + before do + stub_feature_flags(organization_users_internal: false) + end + + it 'assigns the default organization to the created user' do + expect(bot_user.organizations).to eq([first_organization]) + end + + it 'does not create a new user if one already exists for the first organization' do + described_class.for_organization(first_organization).public_send(bot_type) + + expect do + described_class.for_organization(organization).public_send(bot_type) + end.not_to change { User.count } + end + + it 'creates a user with no username or display name suffix' do + expect(bot_user.username).to include(username) + expect(bot_user.username).not_to include(organization.path) + expect(bot_user.name).not_to include("(#{organization.name})") + end + end end shared_examples 'bot user avatars' do |bot_type, avatar_filename| diff --git a/spec/services/projects/participants_service_spec.rb b/spec/services/projects/participants_service_spec.rb index b645d780825400..30e1d2b16c59b5 100644 --- a/spec/services/projects/participants_service_spec.rb +++ b/spec/services/projects/participants_service_spec.rb @@ -108,6 +108,22 @@ def run_service username: other_org_user_detail.username )) end + + context 'when organization_users_internal FF is disabled' do + before do + stub_feature_flags(organization_users_internal: false) + end + + it { is_expected.to be_empty } + + it 'returns results in correct order' do + group = create(:group, owners: user) + + expect(run_service.pluck(:username)).to eq([ + noteable.author.username, 'all', user.username, group.full_path + ]) + end + end end describe 'group items' do -- GitLab From d661ca6251fe0182ca5047eefe619b4369629076 Mon Sep 17 00:00:00 2001 From: agius Date: Tue, 3 Jun 2025 18:18:11 -0700 Subject: [PATCH 4/9] Create OrganizationUserDetail in Users::Internal Creates the per-organization user alias in Users::Internal. Also checks for conflicts with existing Namespace and Users in the same organization --- app/models/namespace.rb | 13 +++ .../organizations/organization_user_detail.rb | 9 ++ lib/users/internal.rb | 37 +++++--- spec/factories/users.rb | 15 +-- spec/lib/users/internal_spec.rb | 72 ++++++++++++-- spec/models/namespace_spec.rb | 95 +++++++++++++++++++ .../organization_user_detail_spec.rb | 43 +++++++++ 7 files changed, 258 insertions(+), 26 deletions(-) diff --git a/app/models/namespace.rb b/app/models/namespace.rb index b6c8630a99468d..bad5b142bba4e1 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -163,6 +163,7 @@ class Namespace < ApplicationRecord validate :changing_shared_runners_enabled_is_allowed, unless: -> { project_namespace? } validate :changing_allow_descendants_override_disabled_shared_runners_is_allowed, unless: -> { project_namespace? } validate :parent_organization_match + validate :no_conflict_with_organization_user_details, if: :path_changed? delegate :name, to: :owner, allow_nil: true, prefix: true delegate :avatar_url, to: :owner, allow_nil: true @@ -389,6 +390,10 @@ def username_reserved?(username) without_project_namespaces.top_level.find_by_path_or_name(username).present? end + def username_reserved_for_organization?(username, organization) + without_project_namespaces.top_level.in_organization(organization).find_by_path_or_name(username).present? + end + def self_or_ancestors_archived_setting_subquery namespace_setting_reflection = reflect_on_association(:namespace_settings) namespace_setting_table = Arel::Table.new(namespace_setting_reflection.table_name) @@ -840,6 +845,14 @@ def parent_organization_match errors.add(:organization_id, _("must match the parent organization's ID")) end + # route / path global uniqueness is handled by Routeable concern + # here we are checking only for conflicts with per-organization username aliases + def no_conflict_with_organization_user_details + return unless Organizations::OrganizationUserDetail.for_organization(organization).with_usernames(path).any? + + errors.add(:path, _('has already been taken')) + end + def cross_namespace_reference?(from) return false if from == self diff --git a/app/models/organizations/organization_user_detail.rb b/app/models/organizations/organization_user_detail.rb index a5922b7fa624ad..63a435df7adb56 100644 --- a/app/models/organizations/organization_user_detail.rb +++ b/app/models/organizations/organization_user_detail.rb @@ -10,6 +10,8 @@ class OrganizationUserDetail < ApplicationRecord validates :username, presence: true, uniqueness: { scope: :organization_id } validates :display_name, presence: true + validate :no_namespace_conflicts + scope :for_references, -> { includes(:organization, :user) } scope :for_organization, ->(organization) { where(organization: organization) } scope :with_usernames, ->(*usernames) { @@ -38,5 +40,12 @@ def reference_pattern def to_reference(*) "#{reference_prefix}#{username}" end + + def no_namespace_conflicts + return if username.blank? + return unless Namespace.username_reserved_for_organization?(username, organization) + + errors.add(:username, _('has already been taken')) + end end end diff --git a/lib/users/internal.rb b/lib/users/internal.rb index 868a2137a83dd7..46fb725ee08200 100644 --- a/lib/users/internal.rb +++ b/lib/users/internal.rb @@ -42,7 +42,7 @@ def ghost unique_internal(User.where(user_type: :ghost), 'ghost', email) do |u| u.bio = _('This is a "Ghost User", created to hold all issues authored by users that have ' \ 'since been deleted. This user cannot be removed.') - u.name = display_name_with_organization_suffix('Ghost') + u.name = 'Ghost' end end @@ -51,7 +51,7 @@ def alert_bot unique_internal(User.where(user_type: :alert_bot), 'alert-bot', email_pattern) do |u| u.bio = 'The GitLab alert bot' - u.name = display_name_with_organization_suffix('GitLab Alert Bot') + u.name = 'GitLab Alert Bot' u.avatar = bot_avatar(image: 'alert-bot.png') u.confirmed_at = Time.zone.now u.private_profile = true @@ -63,7 +63,7 @@ def migration_bot unique_internal(User.where(user_type: :migration_bot), 'migration-bot', email_pattern) do |u| u.bio = 'The GitLab migration bot' - u.name = display_name_with_organization_suffix('GitLab Migration Bot') + u.name = 'GitLab Migration Bot' u.confirmed_at = Time.zone.now u.private_profile = true end @@ -75,7 +75,7 @@ def security_bot unique_internal(User.where(user_type: :security_bot), 'GitLab-Security-Bot', email_pattern) do |u| u.bio = 'System bot that monitors detected vulnerabilities for solutions ' \ 'and creates merge requests with the fixes.' - u.name = display_name_with_organization_suffix('GitLab Security Bot') + u.name = 'GitLab Security Bot' u.avatar = bot_avatar(image: 'security-bot.png') u.confirmed_at = Time.zone.now u.private_profile = true @@ -87,7 +87,7 @@ def support_bot unique_internal(User.where(user_type: :support_bot), 'support-bot', email_pattern) do |u| u.bio = 'The GitLab support bot used for Service Desk' - u.name = display_name_with_organization_suffix('GitLab Support Bot') + u.name = 'GitLab Support Bot' u.avatar = bot_avatar(image: 'support-bot.png') u.confirmed_at = Time.zone.now u.private_profile = true @@ -103,7 +103,7 @@ def automation_bot unique_internal(User.where(user_type: :automation_bot), 'automation-bot', email_pattern) do |u| u.bio = 'The GitLab automation bot used for automated workflows and tasks' - u.name = display_name_with_organization_suffix('GitLab Automation Bot') + u.name = 'GitLab Automation Bot' u.avatar = bot_avatar(image: 'support-bot.png') # todo: add an avatar for automation-bot u.confirmed_at = Time.zone.now u.private_profile = true @@ -115,7 +115,7 @@ def llm_bot unique_internal(User.where(user_type: :llm_bot), 'GitLab-Llm-Bot', email_pattern) do |u| u.bio = 'The Gitlab LLM bot used for fetching LLM-generated content' - u.name = display_name_with_organization_suffix('GitLab LLM Bot') + u.name = 'GitLab LLM Bot' u.avatar = bot_avatar(image: 'support-bot.png') # todo: add an avatar for llm-bot u.confirmed_at = Time.zone.now u.private_profile = true @@ -127,7 +127,7 @@ def duo_code_review_bot unique_internal(User.where(user_type: :duo_code_review_bot), 'GitLabDuo', email_pattern) do |u| u.bio = 'GitLab Duo bot for handling AI tasks' - u.name = display_name_with_organization_suffix('GitLab Duo') + u.name = 'GitLab Duo' u.avatar = bot_avatar(image: 'duo-bot.png') u.confirmed_at = Time.zone.now u.private_profile = true @@ -139,7 +139,7 @@ def admin_bot unique_internal(User.where(user_type: :admin_bot), 'GitLab-Admin-Bot', email_pattern) do |u| u.bio = 'Admin bot used for tasks that require admin privileges' - u.name = display_name_with_organization_suffix('GitLab Admin Bot') + u.name = 'GitLab Admin Bot' u.avatar = bot_avatar(image: 'admin-bot.png') u.admin = true u.confirmed_at = Time.zone.now @@ -209,16 +209,15 @@ def create_unique_internal(scope, username, email_pattern, &creation_block) uniquify = Gitlab::Utils::Uniquify.new - username = username_with_organization_suffix(username) - - username = uniquify.string(username) { |s| Namespace.by_path(s) } + global_username = username_with_organization_suffix(username) + global_username = uniquify.string(global_username) { |s| Namespace.by_path(s) } email = uniquify.string(->(n) { Kernel.sprintf(email_pattern, n) }) do |s| User.find_by_email(s) end user = scope.build( - username: username, + username: global_username, email: email, &creation_block ) @@ -232,6 +231,18 @@ def create_unique_internal(scope, username, email_pattern, &creation_block) user.assign_personal_namespace(user_organization) user.organizations << user_organization + uniquify = Gitlab::Utils::Uniquify.new + organization_username = uniquify.string(username) { |s| Namespace.in_organization(user_organization).by_path(s) } + + if organization_users_internal_enabled? + org_user_details = user_organization.organization_user_details.build( + user: user, + username: organization_username, + display_name: display_name_with_organization_suffix(user.name) + ) + user.organization_user_details << org_user_details + end + Users::UpdateService.new(user, user: user).execute(validate: false) user ensure diff --git a/spec/factories/users.rb b/spec/factories/users.rb index e096221d401285..9651bc4105629b 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -12,6 +12,10 @@ color_scheme_id { 1 } color_mode_id { 1 } + transient do + organization { create(:organization) } # rubocop: disable RSpec/FactoryBot/InlineAssociation -- required since this is a transient attribute, not a factory association + end + after(:build) do |user, evaluator| # UserWithNamespaceShim is not defined in gdk reset-data. We assume the shim is enabled in this case. assign_ns = if defined?(UserWithNamespaceShim) @@ -27,7 +31,7 @@ .order(:created_at).first || # We create an organization next even though we are building here. We need to ensure # that an organization exists so other entities can belong to the same organization - create(:organization) + evaluator.organization user.assign_personal_namespace(org) end @@ -38,15 +42,12 @@ end trait :with_namespace do - # rubocop: disable RSpec/FactoryBot/InlineAssociation -- We need to pass an Organization to this method - namespace { assign_personal_namespace(create(:organization)) } - # rubocop: enable RSpec/FactoryBot/InlineAssociation + namespace { assign_personal_namespace(organization) } end trait :with_organization do - after(:create) do |user| - organization = create(:organization) - create(:organization_user, user: user, organization: organization) + after(:create) do |user, evaluator| + create(:organization_user, user: user, organization: evaluator.organization) end end diff --git a/spec/lib/users/internal_spec.rb b/spec/lib/users/internal_spec.rb index 32463e34670820..84d5e274799003 100644 --- a/spec/lib/users/internal_spec.rb +++ b/spec/lib/users/internal_spec.rb @@ -26,16 +26,24 @@ expect(bot_user.namespace.organization).to eq(organization) end - it 'creates a user with a username suffixed with the organization name' do + it 'creates a user with a global username suffixed with the organization name' do expect(bot_user.username).to include(username) expect(bot_user.username).to include(organization.path) - expect(bot_user.name).to include("(#{organization.name})") + expect(bot_user.name).not_to include("(#{organization.name})") end it 'assigns the organization to the created user' do expect(bot_user.organizations).to eq([organization]) end + it 'creates an organization_user_detail with a per-organization username' do + expect(bot_user.organization_user_details.count).to eq(1) + + detail = bot_user.organization_user_details.first + expect(detail.username).to eq(username) + expect(detail.display_name).to include("(#{organization.name})") + end + it 'does not create a new user if it already exists' do described_class.for_organization(organization).public_send(bot_type) @@ -45,13 +53,37 @@ end context 'when a regular user exists with the bot username' do - it 'creates a user with a non-conflicting username' do - create(:user, username: bot_username) + let!(:user) { create(:user, :with_namespace, username: bot_username) } + it 'creates a user with a non-conflicting username' do expect do bot_user end.to change { User.where(user_type: bot_type).count }.by(1) end + + it 'creates organization_user_detail with non-conflicting username' do + expect(bot_user.organization_user_details.count).to eq(1) + + detail = bot_user.organization_user_details.first + expect(detail.username).not_to eq(user.username) + end + + context 'when user belongs to another organization' do + let!(:user) { create(:user, :with_namespace, username: bot_username, organization: first_organization) } + + it 'creates a non-conflicting global username and simple per-org username' do + expect do + bot_user + end.to change { User.where(user_type: bot_type).count }.by(1) + + expect(bot_user.username).not_to eq(user.username) + + expect(bot_user.organization_user_details.count).to eq(1) + + detail = bot_user.organization_user_details.first + expect(detail.username).to eq(username) + end + end end context 'when a regular user exists with the bot user email' do @@ -65,13 +97,37 @@ end context 'when a group namespace exists with path that is equal to the bot username' do - it 'creates a user with a non-conflicting username' do - create(:group, path: bot_username) + let!(:group) { create(:group, path: bot_username) } + it 'creates a user with a non-conflicting username' do expect do bot_user end.to change { User.where(user_type: bot_type).count }.by(1) end + + it 'creates organization_user_detail with non-conflicting username' do + expect(bot_user.organization_user_details.count).to eq(1) + + detail = bot_user.organization_user_details.first + expect(detail.username).not_to eq(group.path) + end + + context 'when the group namespace is in another organization' do + let!(:group) { create(:group, path: bot_username, organization: first_organization) } + + it 'creates a non-conflicting global username and simple per-org username' do + expect do + bot_user + end.to change { User.where(user_type: bot_type).count }.by(1) + + expect(bot_user.username).not_to eq(bot_username) + + expect(bot_user.organization_user_details.count).to eq(1) + + detail = bot_user.organization_user_details.first + expect(detail.username).to eq(username) + end + end end context 'when a domain allowlist is in place' do @@ -120,6 +176,10 @@ expect(bot_user.username).not_to include(organization.path) expect(bot_user.name).not_to include("(#{organization.name})") end + + it 'does not create an organization_user_detail' do + expect(bot_user.organization_user_details).to be_empty + end end end diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb index c0f4cd2e71f455..f26e6168fdfd58 100644 --- a/spec/models/namespace_spec.rb +++ b/spec/models/namespace_spec.rb @@ -283,6 +283,35 @@ end end end + + describe '#no_conflict_with_organization_user_details' do + let!(:user) { create(:user, :with_namespace, organization: organization) } + let!(:organization_user_detail) do + create(:organization_user_detail, organization: organization, user: user, username: 'test-username') + end + + let(:other_organization) { create(:organization) } + + it 'ensures no Organizations::OrganizationUserDetail has username conflicting with path' do + namespace = build(:namespace, path: 'test-username', organization: organization) + + expect(namespace).not_to be_valid + expect(namespace.errors[:path]).to include('has already been taken') + end + + it 'allows conflicting usernames for other organizations' do + namespace = build(:namespace, path: 'test-username', organization: other_organization) + + expect(namespace).to be_valid + end + + it 'does not check validation unless path has changed' do + namespace = create(:namespace) + + expect(namespace).not_to receive(:no_conflict_with_organization_user_details) + namespace.update!(two_factor_grace_period: 36) + end + end end describe "ReferencePatternValidation" do @@ -1794,6 +1823,72 @@ end end + describe ".username_reserved_for_organization?" do + subject(:username_reserved) { described_class.username_reserved_for_organization?(username, organization) } + + let(:username) { 'capyabra' } + + let_it_be(:user) { create(:user, name: 'capybara') } + let_it_be(:group) { create(:group, name: 'capybara-group') } + let_it_be(:subgroup) { create(:group, parent: group, name: 'capybara-subgroup') } + let_it_be(:project) { create(:project, group: group, name: 'capybara-project') } + + let_it_be(:other_organization) { create(:organization) } + let_it_be(:user_other_org) do + create(:user, :with_namespace, username: 'other-capybara', organization: other_organization) + end + + let_it_be(:group_other_org) { create(:group, path: 'other-capybara-group', organization: other_organization) } + + context 'when given a project name' do + let(:username) { 'capyabra-project' } + + it { is_expected.to eq(false) } + end + + context 'when given a sub-group name' do + let(:username) { 'capybara-subgroup' } + + it { is_expected.to eq(false) } + end + + context 'when given a top-level group' do + let(:username) { 'capybara-group' } + + it { is_expected.to eq(true) } + end + + context 'when given a top-level group in another organization' do + let(:username) { 'other-capybara-group' } + + it { is_expected.to eq(false) } + end + + context 'when given an existing username' do + let(:username) { 'capybara' } + + it { is_expected.to eq(true) } + end + + context 'when given an existing username in another organization' do + let(:username) { 'other-capybara' } + + it { is_expected.to eq(false) } + end + + context 'when given a username with varying capitalization' do + let(:username) { 'CaPyBaRa' } + + it { is_expected.to eq(true) } + end + + context 'when given a username with varying capitalization in another organization' do + let(:username) { 'OtHeR-CaPyBaRa' } + + it { is_expected.to eq(false) } + end + end + describe "#default_branch_protection" do let(:namespace) { create(:namespace) } let(:default_branch_protection) { nil } diff --git a/spec/models/organizations/organization_user_detail_spec.rb b/spec/models/organizations/organization_user_detail_spec.rb index 341aa4fd1ebdb4..24e3fb53e92a1f 100644 --- a/spec/models/organizations/organization_user_detail_spec.rb +++ b/spec/models/organizations/organization_user_detail_spec.rb @@ -14,6 +14,49 @@ it { is_expected.to validate_presence_of(:username) } it { is_expected.to validate_presence_of(:display_name) } it { is_expected.to validate_uniqueness_of(:username).scoped_to(:organization_id) } + + describe '#no_namespace_conflicts' do + subject(:organization_user_detail) do + build(:organization_user_detail, username: username, organization: organization) + end + + let_it_be(:organization) { create(:organization) } + let_it_be(:other_organization) { create(:organization) } + + let(:username) { 'capybara' } + + it { is_expected.to be_valid } + + context 'when a User exists in the same organization with the same username' do + let!(:user) { create(:user, :with_namespace, username: username, organization: organization) } + + it 'adds a validation error on username' do + expect(organization_user_detail).not_to be_valid + expect(organization_user_detail.errors[:username]).to include('has already been taken') + end + end + + context 'when a User exists in another organization with the same username' do + let!(:user) { create(:user, :with_namespace, username: username, organization: other_organization) } + + it { is_expected.to be_valid } + end + + context 'when a group exists in the same organization with a path equal to the username' do + let!(:group) { create(:group, path: username, organization: organization) } + + it 'adds a validation error on username' do + expect(organization_user_detail).not_to be_valid + expect(organization_user_detail.errors[:username]).to include('has already been taken') + end + end + + context 'when a group exists in another organization with a path equal to the username' do + let!(:group) { create(:group, path: username, organization: other_organization) } + + it { is_expected.to be_valid } + end + end end describe 'scopes' do -- GitLab From 1c207c7e877e050529a94b795d64185a1ea7da5c Mon Sep 17 00:00:00 2001 From: agius Date: Tue, 3 Jun 2025 21:03:24 -0700 Subject: [PATCH 5/9] Fix OrganizationUserDetail creation alongside initial user --- app/models/namespace.rb | 9 +++++++-- .../organizations/organization_user_detail.rb | 7 ++++++- spec/models/namespace_spec.rb | 20 +++++++++++++++++++ .../organization_user_detail_spec.rb | 8 ++++++++ 4 files changed, 41 insertions(+), 3 deletions(-) diff --git a/app/models/namespace.rb b/app/models/namespace.rb index bad5b142bba4e1..22c2abe72354bb 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -390,8 +390,13 @@ def username_reserved?(username) without_project_namespaces.top_level.find_by_path_or_name(username).present? end - def username_reserved_for_organization?(username, organization) - without_project_namespaces.top_level.in_organization(organization).find_by_path_or_name(username).present? + def username_reserved_for_organization?(username, organization, excluding: []) + without_project_namespaces + .top_level + .in_organization(organization) + .where.not(id: excluding) + .find_by_path_or_name(username) + .present? end def self_or_ancestors_archived_setting_subquery diff --git a/app/models/organizations/organization_user_detail.rb b/app/models/organizations/organization_user_detail.rb index 63a435df7adb56..45aa18fae674d7 100644 --- a/app/models/organizations/organization_user_detail.rb +++ b/app/models/organizations/organization_user_detail.rb @@ -43,7 +43,12 @@ def to_reference(*) def no_namespace_conflicts return if username.blank? - return unless Namespace.username_reserved_for_organization?(username, organization) + + return unless Namespace.username_reserved_for_organization?( + username, + organization, + excluding: user.namespace + ) errors.add(:username, _('has already been taken')) end diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb index f26e6168fdfd58..99b25fc9e8cf9a 100644 --- a/spec/models/namespace_spec.rb +++ b/spec/models/namespace_spec.rb @@ -1870,6 +1870,26 @@ it { is_expected.to eq(true) } end + context 'when excluding a particular namespace from the check' do + subject(:username_reserved) do + described_class.username_reserved_for_organization?(username, organization, excluding: [user.namespace]) + end + + let(:username) { 'capybara' } + + it { is_expected.to eq(false) } + end + + context 'when exclude parameter contains nil values' do + subject(:username_reserved) do + described_class.username_reserved_for_organization?(username, organization, excluding: [nil, nil]) + end + + let(:username) { 'capybara' } + + it { is_expected.to eq(true) } + end + context 'when given an existing username in another organization' do let(:username) { 'other-capybara' } diff --git a/spec/models/organizations/organization_user_detail_spec.rb b/spec/models/organizations/organization_user_detail_spec.rb index 24e3fb53e92a1f..354a6bc3e0adc5 100644 --- a/spec/models/organizations/organization_user_detail_spec.rb +++ b/spec/models/organizations/organization_user_detail_spec.rb @@ -34,6 +34,14 @@ expect(organization_user_detail).not_to be_valid expect(organization_user_detail.errors[:username]).to include('has already been taken') end + + context 'when the user is the same as the OrganizationUserDetail' do + subject(:organization_user_detail) do + build(:organization_user_detail, user: user, username: username, organization: organization) + end + + it { is_expected.to be_valid } + end end context 'when a User exists in another organization with the same username' do -- GitLab From 91e4f20d8c9417848192ba65c688c578c6b9e9bf Mon Sep 17 00:00:00 2001 From: agius Date: Tue, 3 Jun 2025 22:50:36 -0700 Subject: [PATCH 6/9] Fix factory transient attribute --- spec/factories/users.rb | 8 ++++---- spec/lib/users/internal_spec.rb | 2 +- spec/models/namespace_spec.rb | 4 ++-- .../models/organizations/organization_user_detail_spec.rb | 4 ++-- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/spec/factories/users.rb b/spec/factories/users.rb index 9651bc4105629b..e718a645bd8bd7 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -13,7 +13,7 @@ color_mode_id { 1 } transient do - organization { create(:organization) } # rubocop: disable RSpec/FactoryBot/InlineAssociation -- required since this is a transient attribute, not a factory association + in_organization { create(:organization) } # rubocop: disable RSpec/FactoryBot/InlineAssociation -- required since this is a transient attribute, not a factory association end after(:build) do |user, evaluator| @@ -31,7 +31,7 @@ .order(:created_at).first || # We create an organization next even though we are building here. We need to ensure # that an organization exists so other entities can belong to the same organization - evaluator.organization + evaluator.in_organization user.assign_personal_namespace(org) end @@ -42,12 +42,12 @@ end trait :with_namespace do - namespace { assign_personal_namespace(organization) } + namespace { assign_personal_namespace(in_organization) } end trait :with_organization do after(:create) do |user, evaluator| - create(:organization_user, user: user, organization: evaluator.organization) + create(:organization_user, user: user, organization: evaluator.in_organization) end end diff --git a/spec/lib/users/internal_spec.rb b/spec/lib/users/internal_spec.rb index 84d5e274799003..f21e3f8c1deb2a 100644 --- a/spec/lib/users/internal_spec.rb +++ b/spec/lib/users/internal_spec.rb @@ -69,7 +69,7 @@ end context 'when user belongs to another organization' do - let!(:user) { create(:user, :with_namespace, username: bot_username, organization: first_organization) } + let!(:user) { create(:user, :with_namespace, username: bot_username, in_organization: first_organization) } it 'creates a non-conflicting global username and simple per-org username' do expect do diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb index 99b25fc9e8cf9a..7134adae1dddcd 100644 --- a/spec/models/namespace_spec.rb +++ b/spec/models/namespace_spec.rb @@ -285,7 +285,7 @@ end describe '#no_conflict_with_organization_user_details' do - let!(:user) { create(:user, :with_namespace, organization: organization) } + let!(:user) { create(:user, :with_namespace, in_organization: organization) } let!(:organization_user_detail) do create(:organization_user_detail, organization: organization, user: user, username: 'test-username') end @@ -1835,7 +1835,7 @@ let_it_be(:other_organization) { create(:organization) } let_it_be(:user_other_org) do - create(:user, :with_namespace, username: 'other-capybara', organization: other_organization) + create(:user, :with_namespace, username: 'other-capybara', in_organization: other_organization) end let_it_be(:group_other_org) { create(:group, path: 'other-capybara-group', organization: other_organization) } diff --git a/spec/models/organizations/organization_user_detail_spec.rb b/spec/models/organizations/organization_user_detail_spec.rb index 354a6bc3e0adc5..ce0486ea9f88d7 100644 --- a/spec/models/organizations/organization_user_detail_spec.rb +++ b/spec/models/organizations/organization_user_detail_spec.rb @@ -28,7 +28,7 @@ it { is_expected.to be_valid } context 'when a User exists in the same organization with the same username' do - let!(:user) { create(:user, :with_namespace, username: username, organization: organization) } + let!(:user) { create(:user, :with_namespace, username: username, in_organization: organization) } it 'adds a validation error on username' do expect(organization_user_detail).not_to be_valid @@ -45,7 +45,7 @@ end context 'when a User exists in another organization with the same username' do - let!(:user) { create(:user, :with_namespace, username: username, organization: other_organization) } + let!(:user) { create(:user, :with_namespace, username: username, in_organization: other_organization) } it { is_expected.to be_valid } end -- GitLab From d64ae188508216db4bd51d29ea211aacfa06bc3d Mon Sep 17 00:00:00 2001 From: agius Date: Wed, 4 Jun 2025 15:06:22 -0700 Subject: [PATCH 7/9] De-duplicate users and aliases --- app/services/projects/participants_service.rb | 12 ++++++++- .../projects/participants_service_spec.rb | 27 ++++++++++++++++--- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/app/services/projects/participants_service.rb b/app/services/projects/participants_service.rb index 3a1fec3d994939..559fb5d62eb453 100644 --- a/app/services/projects/participants_service.rb +++ b/app/services/projects/participants_service.rb @@ -17,7 +17,9 @@ def execute(noteable) participants += groups unless relation_at_search_limit?(project_members) - render_participants_as_hash(participants.uniq) + render_participants_as_hash( + uniquify_participants(participants) + ) end def project_members @@ -40,6 +42,14 @@ def organization_user_details def project_members_relation project.authorized_users end + + private + + def uniquify_participants(participants) + detail_users = participants.select { |part| part.is_a?(Organizations::OrganizationUserDetail) }.map(&:user) + participants -= detail_users + participants.uniq + end end end diff --git a/spec/services/projects/participants_service_spec.rb b/spec/services/projects/participants_service_spec.rb index 30e1d2b16c59b5..ddbc411f9d26bc 100644 --- a/spec/services/projects/participants_service_spec.rb +++ b/spec/services/projects/participants_service_spec.rb @@ -7,13 +7,19 @@ let_it_be(:user) { create(:user) } let_it_be(:project) { create(:project, :public) } let_it_be(:noteable) { create(:issue, project: project) } - let_it_be(:org_user_detail) { create(:organization_user_detail, organization: project.organization) } - let_it_be(:other_org_user_detail) { create(:organization_user_detail) } + let_it_be(:org_user_detail) do + create(:organization_user_detail, organization: project.organization, username: 'spec_bot') + end + + let_it_be(:other_org_user_detail) do + create(:organization_user_detail, username: 'spec_bot') + end let(:params) { {} } before_all do project.add_developer(user) + project.add_developer(org_user_detail.user) end before do @@ -105,7 +111,8 @@ def run_service it 'does not include items from other organizations' do expect(org_user_detail_items).not_to include(a_hash_including( - username: other_org_user_detail.username + username: other_org_user_detail.username, + original_username: other_org_user_detail.user.username )) end @@ -120,10 +127,22 @@ def run_service group = create(:group, owners: user) expect(run_service.pluck(:username)).to eq([ - noteable.author.username, 'all', user.username, group.full_path + noteable.author.username, 'all', user.username, org_user_detail.user.username, group.full_path ]) end end + + context 'including other item types' do + subject(:items) { run_service } + + it 'does not seprately include user for organization_user_details items' do + matching_items = items.select do |item| + [org_user_detail.username, org_user_detail.user.username].include?(item[:username]) + end + expect(matching_items.count).to eq(1) + expect(matching_items.first).to have_key(:original_username) + end + end end describe 'group items' do -- GitLab From ec478fe97252fa009af086908d4a229f89c1bfe3 Mon Sep 17 00:00:00 2001 From: agius Date: Mon, 9 Jun 2025 13:08:57 -0700 Subject: [PATCH 8/9] Replace users with aliases inline Unifies finding of Users with OrganizationUserDetails in Projects::ParticipantService --- app/models/user.rb | 4 ++ app/services/projects/participants_service.rb | 38 +++++++++------- .../participants_autocomplete_spec.rb | 43 ++++++++++++++++++ .../projects/participants_service_spec.rb | 44 ++++++++++++------- 4 files changed, 99 insertions(+), 30 deletions(-) diff --git a/app/models/user.rb b/app/models/user.rb index 1e6637291d9c41..88b65fbe2c46f7 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -685,6 +685,10 @@ def blocked? .includes(:projects) end + scope :with_organization_user_details, -> do + includes(organization_user_details: [:organization]) + end + scope :left_join_user_detail, -> { left_joins(:user_detail) } scope :preload_user_detail, -> { preload(:user_detail) } diff --git a/app/services/projects/participants_service.rb b/app/services/projects/participants_service.rb index 559fb5d62eb453..cbf8df4e57ed7d 100644 --- a/app/services/projects/participants_service.rb +++ b/app/services/projects/participants_service.rb @@ -12,14 +12,12 @@ def execute(noteable) noteable_owner + participants_in_noteable + all_members + - organization_user_details + project_members participants += groups unless relation_at_search_limit?(project_members) + participants = organization_user_details_for_participants(participants.uniq) - render_participants_as_hash( - uniquify_participants(participants) - ) + render_participants_as_hash(participants) end def project_members @@ -33,22 +31,32 @@ def all_members [{ username: "all", name: "All Project and Group Members", count: project_members_relation.count }] end - def organization_user_details - return [] unless Feature.enabled?(:organization_users_internal, project.organization) - - project.organization.organization_user_details - end - def project_members_relation - project.authorized_users + project.authorized_users.with_organization_user_details end private - def uniquify_participants(participants) - detail_users = participants.select { |part| part.is_a?(Organizations::OrganizationUserDetail) }.map(&:user) - participants -= detail_users - participants.uniq + def organization + project.organization + end + strong_memoize_attr :organization + + # for users that have an OrganizationUserDetail for the current organization, use this instead + # of the User model for rendering username, display_name, and other details + # details should be pre-loaded to avoid N+1 queries + def organization_user_details_for_participants(participants) + return participants unless Feature.enabled?(:organization_users_internal, organization) + + participants.map do |participant| + next participant unless participant.is_a?(User) + + detail = participant.organization_user_details.to_a.find do |det| + det.organization == organization + end + + detail.presence || participant + end end end end diff --git a/spec/features/participants_autocomplete_spec.rb b/spec/features/participants_autocomplete_spec.rb index dbeca601617590..e8126c68862d79 100644 --- a/spec/features/participants_autocomplete_spec.rb +++ b/spec/features/participants_autocomplete_spec.rb @@ -86,4 +86,47 @@ include_examples "open suggestions when typing @", 'commit' end + + context 'when mentioning users with OrganizationUserDetail username alias' do + let_it_be(:organization) { create(:organization) } + let_it_be(:user) { create(:user, in_organization: organization) } + let_it_be(:author) { create(:user, in_organization: organization) } + let_it_be(:project) { create(:project, :public, :repository, organization: organization) } + let_it_be(:admin_bot) { Users::Internal.for_organization(organization).admin_bot } + + let(:organization_user_detail) { admin_bot.organization_user_details.first } + let(:resource_name) { 'issue' } + + let(:note) { create(:note, noteable: noteable, project: noteable.project) } + let(:noteable) { create(:issue, author: author, project: project) } + + before_all do + project.add_owner(admin_bot) + end + + before do + note + end + + it 'creates admin_bot with OrganizationUserDetail alias' do + expect(admin_bot.organization_user_details.count).to eq(1) + expect(admin_bot.username).not_to eq(organization_user_detail.username) + + visit project_issue_path(project, noteable) + fill_in 'Comment', with: "#{User.reference_prefix}#{admin_bot.username[0..2]}" + + expect(find_autocomplete_menu).to have_text(organization_user_detail.username) + expect(find_autocomplete_menu).not_to have_text(admin_bot.username) + + send_keys [:arrow_down, :enter] + + click_on 'Comment' + wait_for_requests + + expect(page).to have_link( + "#{User.reference_prefix}#{organization_user_detail.username}", + href: user_path(admin_bot) + ) + end + end end diff --git a/spec/services/projects/participants_service_spec.rb b/spec/services/projects/participants_service_spec.rb index ddbc411f9d26bc..6dcf33added20b 100644 --- a/spec/services/projects/participants_service_spec.rb +++ b/spec/services/projects/participants_service_spec.rb @@ -34,7 +34,7 @@ def run_service group = create(:group, owners: user) expect(run_service.pluck(:username)).to eq([ - noteable.author.username, 'all', org_user_detail.username, user.username, group.full_path + noteable.author.username, 'all', user.username, org_user_detail.username, group.full_path ]) end @@ -73,6 +73,34 @@ def run_service expect { run_service }.not_to exceed_query_limit(control) end + + it 'avoids N+1 OrganizationUserDetail queries' do + developer = create(:user) + project.add_developer(developer) + create( + :organization_user_detail, + user: developer, + organization: project.organization, + username: 'test_alias', + display_name: 'Test McAlias' + ) + + control = ActiveRecord::QueryRecorder.new { run_service.to_a } + + BatchLoader::Executor.clear_current + + developer2 = create(:user) + project.add_developer(developer2) + create( + :organization_user_detail, + user: developer2, + organization: project.organization, + username: 'test_alias2', + display_name: 'Test McAlias2' + ) + + expect { run_service.to_a }.not_to exceed_query_limit(control) + end end it 'does not return duplicate author' do @@ -239,20 +267,6 @@ def run_service end end - describe '#organization_user_details' do - let_it_be(:user) { create(:user) } - let_it_be(:project) { create(:project, :public) } - let_it_be(:org_user_detail) { create(:organization_user_detail, organization: project.organization) } - let_it_be(:other_org_user_detail) { create(:organization_user_detail) } - - let(:service) { described_class.new(project, user) } - - subject(:org_user_details) { service.organization_user_details } - - it { is_expected.to include(org_user_detail) } - it { is_expected.not_to include(other_org_user_detail) } - end - describe '#project_members' do subject(:usernames) { service.project_members.map { |member| member[:username] } } -- GitLab From fa5f4e28c4d112254b30d62842d8061934a6a78c Mon Sep 17 00:00:00 2001 From: agius Date: Thu, 12 Jun 2025 20:28:22 -0700 Subject: [PATCH 9/9] Incorporate review feedback Adds a fallback for UserReferenceFilter to use the author's organization when neither group nor project organization is available, such as in snippets. --- .../references/user_reference_filter.rb | 30 +++++++++++-------- lib/users/internal.rb | 9 ++++-- .../participants_autocomplete_spec.rb | 11 ++----- .../references/user_reference_filter_spec.rb | 22 ++++++++++++++ 4 files changed, 50 insertions(+), 22 deletions(-) diff --git a/lib/banzai/filter/references/user_reference_filter.rb b/lib/banzai/filter/references/user_reference_filter.rb index 01613a98e5c1b2..cbfdcf037e568f 100644 --- a/lib/banzai/filter/references/user_reference_filter.rb +++ b/lib/banzai/filter/references/user_reference_filter.rb @@ -7,6 +7,8 @@ module References # # A special `@all` reference is also supported. class UserReferenceFilter < ReferenceFilter + include Gitlab::Utils::StrongMemoize + self.reference_type = :user self.object_class = User @@ -68,24 +70,25 @@ def object_link_filter(text, pattern, link_content: nil, link_reference: false) # The keys of this Hash are the namespace paths, the values the # corresponding Namespace objects. def namespaces - @namespaces ||= Namespace.preload(:owner, :route) - .where_full_path_in(usernames) - .index_by(&:full_path) - .transform_keys(&:downcase) + Namespace.preload(:owner, :route) + .where_full_path_in(usernames) + .index_by(&:full_path) + .transform_keys(&:downcase) end + strong_memoize_attr :namespaces # check for users that have an aliased name within an organization, # for example the bot users created by Users::Internal def org_user_details - return @org_user_details if @org_user_details - return @org_user_details = {} unless Feature.enabled?(:organization_users_internal, organization) + return {} unless Feature.enabled?(:organization_users_internal, organization) - @org_user_details = Organizations::OrganizationUserDetail.for_references - .for_organization(organization) - .with_usernames(usernames) - .index_by(&:username) - .transform_keys(&:downcase) + Organizations::OrganizationUserDetail.for_references + .for_organization(organization) + .with_usernames(usernames) + .index_by(&:username) + .transform_keys(&:downcase) end + strong_memoize_attr :org_user_details # Returns all usernames referenced in the current document. def usernames @@ -156,8 +159,11 @@ def link_tag(url, data, link_content, title) end def organization - @organization ||= parent&.organization || Organizations::Organization.first + parent&.organization || + context[:author]&.organizations&.first || + Organizations::Organization.first end + strong_memoize_attr :organization def parent context[:project] || context[:group] diff --git a/lib/users/internal.rb b/lib/users/internal.rb index 46fb725ee08200..777fe401d283d6 100644 --- a/lib/users/internal.rb +++ b/lib/users/internal.rb @@ -164,19 +164,24 @@ def unique_internal(scope, username, email_pattern, &block) end def username_with_organization_suffix(username) - return username if @organization.nil? || @organization.path == 'default' + return username if @organization.nil? || @organization == first_organization return username unless organization_users_internal_enabled? [username, @organization.path].join('_') end def display_name_with_organization_suffix(display_name) - return display_name if @organization.nil? || @organization.path == 'default' + return display_name if @organization.nil? || @organization == first_organization return display_name unless organization_users_internal_enabled? "#{display_name} (#{@organization.name})" end + def first_organization + Organizations::Organization.first + end + strong_memoize_attr :first_organization + def organization_users_internal_enabled? Feature.enabled?(:organization_users_internal, @organization) end diff --git a/spec/features/participants_autocomplete_spec.rb b/spec/features/participants_autocomplete_spec.rb index e8126c68862d79..9ae4e63c186e3d 100644 --- a/spec/features/participants_autocomplete_spec.rb +++ b/spec/features/participants_autocomplete_spec.rb @@ -88,26 +88,21 @@ end context 'when mentioning users with OrganizationUserDetail username alias' do - let_it_be(:organization) { create(:organization) } - let_it_be(:user) { create(:user, in_organization: organization) } - let_it_be(:author) { create(:user, in_organization: organization) } + let_it_be(:author) { create(:user, :with_organization) } + let_it_be(:organization) { author.organizations.first } let_it_be(:project) { create(:project, :public, :repository, organization: organization) } let_it_be(:admin_bot) { Users::Internal.for_organization(organization).admin_bot } let(:organization_user_detail) { admin_bot.organization_user_details.first } let(:resource_name) { 'issue' } - let(:note) { create(:note, noteable: noteable, project: noteable.project) } + let!(:note) { create(:note, noteable: noteable, project: noteable.project) } let(:noteable) { create(:issue, author: author, project: project) } before_all do project.add_owner(admin_bot) end - before do - note - end - it 'creates admin_bot with OrganizationUserDetail alias' do expect(admin_bot.organization_user_details.count).to eq(1) expect(admin_bot.username).not_to eq(organization_user_detail.username) diff --git a/spec/lib/banzai/filter/references/user_reference_filter_spec.rb b/spec/lib/banzai/filter/references/user_reference_filter_spec.rb index bc18309aa3719b..527395eab9d96b 100644 --- a/spec/lib/banzai/filter/references/user_reference_filter_spec.rb +++ b/spec/lib/banzai/filter/references/user_reference_filter_spec.rb @@ -225,6 +225,7 @@ def get_reference(user) describe '#org_user_detail' do let(:org_user_detail) { create(:organization_user_detail, organization: project.organization) } + let(:author) { create(:user, in_organization: project.organization) } it 'supports mentioning users aliased within organization' do reference = org_user_detail.to_reference @@ -233,6 +234,27 @@ def get_reference(user) expect(doc.css('a').first.attr('href')).to eq urls.user_url(org_user_detail.user) end + it 'supports mentioning user aliases for snippets' do + reference = org_user_detail.to_reference + doc = reference_filter("Hey #{reference}", author: author) + + expect(doc.css('a').first.attr('href')).to eq urls.user_url(org_user_detail.user) + end + + context 'with no context for filter' do + let!(:user) { create(:user, :with_organization) } + let!(:org_user_detail) { create(:organization_user_detail, user: user, organization: user.organizations.first) } + + it 'supports mentioning user aliases from default organization' do + expect(Organizations::Organization.count).to eq(1) + + reference = org_user_detail.to_reference + doc = reference_filter("Hey #{reference}") + + expect(doc.css('a').first.attr('href')).to eq urls.user_url(org_user_detail.user) + end + end + context 'in group context' do let(:group) { create(:group, developers: [group_member]) } let(:group_member) { create(:user) } -- GitLab