diff --git a/config/locales/en.yml b/config/locales/en.yml index a91962d5f92bed103186c166d2a513f2295468d7..96318d1cdb4082422711322db898e45fe0db0c9c 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -55,6 +55,12 @@ en: location: 'Location' organization: 'Organization' website_url: 'Website url' + errors: + models: + user_preference: + attributes: + duo_default_namespace_id: + invalid: "specified does not allow you to execute a workflow." views: pagination: previous: "Prev" diff --git a/ee/app/models/ee/user.rb b/ee/app/models/ee/user.rb index 004b456a17c28c63146003d23bfa2c92313f7059..68507ed6a4da0593c343f93ab9a9e1f7f5c5d64c 100644 --- a/ee/app/models/ee/user.rb +++ b/ee/app/models/ee/user.rb @@ -66,6 +66,7 @@ module User to: :user_detail, allow_nil: true delegate :enabled_zoekt?, :enabled_zoekt, :enabled_zoekt=, + :duo_default_namespace_id, :duo_default_namespace_id=, to: :user_preference delegate :policy_advanced_editor?, :policy_advanced_editor, :policy_advanced_editor=, diff --git a/ee/app/models/ee/user_preference.rb b/ee/app/models/ee/user_preference.rb index a4ecacf366a43d6ef506da714692782c1af46d26..e3c6f108a24aa48ddf89d9066d3d0ac74b3a05b4 100644 --- a/ee/app/models/ee/user_preference.rb +++ b/ee/app/models/ee/user_preference.rb @@ -5,7 +5,10 @@ module UserPreference extend ActiveSupport::Concern prepended do + extend ::Gitlab::Utils::Override + belongs_to :default_duo_add_on_assignment, class_name: 'GitlabSubscriptions::UserAddOnAssignment', optional: true + belongs_to :duo_default_namespace, class_name: 'Namespace', optional: true validates :roadmap_epics_state, allow_nil: true, inclusion: { in: ::Epic.available_states.values, message: "%{value} is not a valid epic state id" @@ -14,11 +17,38 @@ module UserPreference validates :epic_notes_filter, inclusion: { in: ::UserPreference::NOTES_FILTERS.values }, presence: true validate :check_seat_for_default_duo_assigment, if: :default_duo_add_on_assignment_id_changed? + validate :validate_duo_default_namespace_id, if: :duo_default_namespace_id_changed? + + def duo_default_namespace_candidates + if ::Gitlab::Saas.feature_available?(:gitlab_duo_saas_only) + duo_core_id = GitlabSubscriptions::AddOn.find_by(name: :duo_core).id + duo_assignable_ids = GitlabSubscriptions::AddOn.where(name: ::GitlabSubscriptions::AddOn::SEAT_ASSIGNABLE_DUO_ADD_ONS).pluck(:id) # rubocop:disable Database/AvoidUsingPluckWithoutLimit -- size is < 5 and relatively constant + + GitlabSubscriptions::AddOnPurchase + .for_duo_add_ons + .active.for_user(user) + .left_outer_joins(:assigned_users) + .where(<<~SQL, duo_core_id, duo_assignable_ids) + subscription_add_on_id = ? OR + (subscription_add_on_id IN (?) AND + subscription_user_add_on_assignments.id IS NOT NULL) + SQL + .includes(:namespace) + .map(&:namespace) + .uniq + else + namespaces = user.authorized_groups.top_level.to_a + namespaces.append(user.namespace) if user.namespace + namespaces + end + end validates :policy_advanced_editor, allow_nil: false, inclusion: { in: [true, false] } attribute :policy_advanced_editor, default: false + # EE:SaaS - namespace with seats for SAAS purpose only + # See https://gitlab.com/gitlab-org/gitlab/-/issues/557584 def eligible_duo_add_on_assignments assignable_enum_value = ::GitlabSubscriptions::AddOn.names.values_at( *::GitlabSubscriptions::AddOn::SEAT_ASSIGNABLE_DUO_ADD_ONS @@ -60,6 +90,35 @@ def get_default_duo_namespace assignments.first.add_on_purchase.namespace end + + override :duo_default_namespace + def duo_default_namespace + namespace = super + + if namespace + duo_default_namespace_candidates.include?(namespace) ? namespace : nil + else # Fallback to deprecated add-on assignment approach + get_default_duo_namespace + end + end + + def duo_default_namespace_id=(namespace_id) + # Prevent fallback to assignment id in future reads + self.default_duo_add_on_assignment_id = nil if namespace_id.nil? + super + end + + private + + def validate_duo_default_namespace_id + return unless duo_default_namespace_id + + valid = duo_default_namespace_candidates.any? { |n| n.id == duo_default_namespace_id } + + return if valid + + errors.add(:duo_default_namespace_id) + end end end end diff --git a/ee/spec/models/user_preference_spec.rb b/ee/spec/models/user_preference_spec.rb index c2b2c26fc40b0c017e043dbffbd1cf6286761708..c29f74ba4072ee98be21d505b8f1410d87f9c5f6 100644 --- a/ee/spec/models/user_preference_spec.rb +++ b/ee/spec/models/user_preference_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe UserPreference do - let_it_be(:user) { create(:user) } + let_it_be(:user) { create(:user, :with_namespace) } let(:user_preference) { create(:user_preference, user: user) } @@ -39,6 +39,12 @@ .class_name('GitlabSubscriptions::UserAddOnAssignment') .optional end + + it 'belongs to duo_default_namespace optionally' do + is_expected.to belong_to(:duo_default_namespace) + .class_name('Namespace') + .optional + end end describe 'roadmap_epics_state' do @@ -232,6 +238,46 @@ end end + describe '#duo_default_namespace_candidates', feature_category: :ai_abstraction_layer do + context 'when SaaS', :saas do + before do + stub_saas_features(duo_chat_on_saas: true) + end + + context 'when user has eligible duo add-on assignments' do + include_context 'with multiple user add-on assignments' + + it 'returns distinct eligible duo add-on assignments' do + result = user_preference.duo_default_namespace_candidates + + expect(result.count).to eq(2) + end + end + + context 'when user has no eligible duo add-on assignments' do + it 'is empty' do + result = user_preference.duo_default_namespace_candidates + + expect(result).to be_empty + end + end + end + + context 'when Self-Managed' do + let_it_be(:top_level_group) { create(:group) } + let_it_be(:subgroup) { create(:group, parent: top_level_group) } + + it 'returns top level authorized groups and user namespace' do + top_level_group.add_maintainer(user) + subgroup.add_maintainer(user) + + result = user_preference.duo_default_namespace_candidates + + expect(result).to eq([top_level_group, user.namespace]) + end + end + end + describe '#get_default_duo_namespace', :saas do context 'when there are multiple eligible duo add-on assignments' do include_context 'with multiple user add-on assignments' @@ -308,4 +354,112 @@ end end end + + describe '#duo_default_namespace' do + let_it_be(:namespace) { create(:group, :private) } + + it 'returns the namespace when accessible' do + namespace.add_developer(user) + + user_preference.duo_default_namespace = namespace + + expect(user_preference.duo_default_namespace).to eq(namespace) + end + + it 'returns nil when not accessible' do + user_preference.duo_default_namespace_id = namespace.id + + expect(user_preference.duo_default_namespace).to be_nil + end + + it 'returns nil when duo_default_namespace is nil' do + user_preference.duo_default_namespace = nil + + expect(user_preference.duo_default_namespace).to be_nil + end + end + + describe '#duo_default_namespace_id=' do + let_it_be(:namespace) { create(:group, :private) } + + def expect_assignment_id_and_namespace_id(assignment_id, namespace_id) + expect(user_preference).to have_attributes( + default_duo_add_on_assignment_id: assignment_id, + duo_default_namespace_id: namespace_id + ) + end + + context 'when SaaS feature is available', :saas do + include_context 'with multiple user add-on assignments' + + before do + stub_saas_features(duo_chat_on_saas: true) + end + + it 'sets default_duo_add_on_assignment_id' do + assignment = user_assignments.first + user_preference.duo_default_namespace_id = assignment.add_on_purchase.namespace_id + + expect_assignment_id_and_namespace_id(assignment.id, nil) + + user_preference.duo_default_namespace_id = nil + + expect_assignment_id_and_namespace_id(nil, nil) + end + + it 'sets default_duo_add_on_assignment_id to nil when no assignment exists for namespace' do + namespace = create(:namespace) + user_preference.duo_default_namespace_id = namespace.id + + expect_assignment_id_and_namespace_id(nil, nil) + end + end + + context 'when self-managed' do + it 'sets duo_default_namespace_id as is' do + namespace.add_reporter(user) + user_preference.duo_default_namespace_id = namespace.id + + expect_assignment_id_and_namespace_id(nil, namespace.id) + + user_preference.duo_default_namespace_id = nil + + expect_assignment_id_and_namespace_id(nil, nil) + end + end + end + + describe '#validate_duo_default_namespace_id' do + let_it_be(:namespace) { create(:group, :private) } + + def expect_valid(valid) + expect(user_preference.valid?).to eq(valid) + expect(user_preference.errors.added?(:duo_default_namespace_id, :invalid)).to be true unless valid + end + + it 'is valid when duo_default_namespace_id is nil' do + user_preference.duo_default_namespace_id = nil + + expect_valid(true) + end + + it 'is valid when user has read access to the namespace' do + namespace.add_developer(user) + user_preference.duo_default_namespace_id = namespace.id + + expect_valid(true) + end + + it 'adds error when user does not have read access to the namespace' do + user_preference.duo_default_namespace_id = namespace.id + + expect_valid(false) + end + + it 'adds error when duo_default_namespace is set directly instead of duo_default_namespace_id' do + user_preference.duo_default_namespace = namespace + + expect_valid(false) + end + end end