diff --git a/ee/app/models/ai/code_review_authorization.rb b/ee/app/models/ai/code_review_authorization.rb deleted file mode 100644 index 593227d56114303397f9333d891f850c06c73771..0000000000000000000000000000000000000000 --- a/ee/app/models/ai/code_review_authorization.rb +++ /dev/null @@ -1,39 +0,0 @@ -# frozen_string_literal: true - -module Ai - class CodeReviewAuthorization - def initialize(resource) - @resource = resource - end - - def allowed?(user) - return false unless user - - use_duo_agent_platform?(user) || classic_flow_allowed?(user) - end - - private - - attr_reader :resource - - def use_duo_agent_platform?(user) - ::Ai::DuoWorkflows::CodeReview::AvailabilityValidator.new( - user: user, - resource: project_or_group - ).available? - end - - def classic_flow_allowed?(user) - Ability.allowed?(user, :access_ai_review_mr, project_or_group) && - ::Gitlab::Llm::FeatureAuthorizer.new( - container: project_or_group, - feature_name: :review_merge_request, - user: user - ).allowed? - end - - def project_or_group - resource.is_a?(MergeRequest) ? resource.project : resource - end - end -end diff --git a/ee/app/models/ai/duo_code_review.rb b/ee/app/models/ai/duo_code_review.rb new file mode 100644 index 0000000000000000000000000000000000000000..e4f756bc2dccc321f9eb5aa3148f96f1ad5c54c3 --- /dev/null +++ b/ee/app/models/ai/duo_code_review.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Ai + module DuoCodeReview + module_function + + def enabled?(user:, container:) + ModeResolver.new(user: user, container: container).enabled? + end + end +end diff --git a/ee/app/models/ai/duo_code_review/mode_resolver.rb b/ee/app/models/ai/duo_code_review/mode_resolver.rb new file mode 100644 index 0000000000000000000000000000000000000000..73c66f2f2db265b7a3b344e8c15eec6f653cbcdb --- /dev/null +++ b/ee/app/models/ai/duo_code_review/mode_resolver.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Ai + module DuoCodeReview + class ModeResolver + include ::Gitlab::Utils::StrongMemoize + + delegate :mode, :enabled?, to: :active_mode + + # Modes with higher precedence comes first. + MODES = [ + Modes::Dap, # Duo Code Review will use Duo Agent Platform with extended context support. + Modes::Classic, # Duo Code Review will use its classic prompt mode. + Modes::Disabled # Duo Code Review is disabled. + ].freeze + + def initialize(user:, container:) + @user = user + @container = container + end + + private + + attr_reader :user, :container + + def active_mode + MODES.find do |resolver| + mode = resolver.new(user: user, container: container) + break mode if mode.active? + end + end + strong_memoize_attr :active_mode + end + end +end diff --git a/ee/app/models/ai/duo_code_review/modes/base.rb b/ee/app/models/ai/duo_code_review/modes/base.rb new file mode 100644 index 0000000000000000000000000000000000000000..938a8b80b710bd535753b46d048726d401fb5fb5 --- /dev/null +++ b/ee/app/models/ai/duo_code_review/modes/base.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Ai + module DuoCodeReview + module Modes + class Base + def initialize(user:, container:) + @user = user + @container = container + end + + def mode + raise NotImplementedError + end + + def enabled? + raise NotImplementedError + end + + def active? + raise NotImplementedError + end + + private + + attr_reader :user, :container + end + end + end +end diff --git a/ee/app/models/ai/duo_code_review/modes/classic.rb b/ee/app/models/ai/duo_code_review/modes/classic.rb new file mode 100644 index 0000000000000000000000000000000000000000..d99a5ed1fc4cdb5dc929b6fda23c7ec56a2e6f36 --- /dev/null +++ b/ee/app/models/ai/duo_code_review/modes/classic.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Ai + module DuoCodeReview + module Modes + class Classic < Base + def mode + :classic + end + + def enabled? + true + end + + def active? + Ability.allowed?(user, :access_ai_review_mr, container) && + ::Gitlab::Llm::FeatureAuthorizer.new( + container: container, + feature_name: :review_merge_request, + user: user + ).allowed? + end + end + end + end +end diff --git a/ee/app/models/ai/duo_code_review/modes/dap.rb b/ee/app/models/ai/duo_code_review/modes/dap.rb new file mode 100644 index 0000000000000000000000000000000000000000..38a2929345afd05b65da582c86b18d559c144dd7 --- /dev/null +++ b/ee/app/models/ai/duo_code_review/modes/dap.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +module Ai + module DuoCodeReview + module Modes + class Dap < Base + def mode + :dap + end + + def enabled? + true + end + + def active? + return false unless ::Feature.enabled?(:duo_code_review_on_agent_platform, user) + return false unless container.duo_features_enabled + + # For Duo Enterprise: use duo agent platform only for internal GitLab users if feature flag is enabled + return ::Feature.enabled?(:duo_code_review_dap_internal_users, user) if user_has_duo_enterprise_add_on? + + # For Duo Pro/Core: use agent platform if: + # - user has duo_agent_platform access (checks user's add-on assignments) + # - GA rollout is enabled OR experimental features are enabled + # - DWS is configured (for self-managed) + user.allowed_to_use?(:duo_agent_platform) && + (ga_rollout_enabled? || experimental_features_enabled?) && + duo_agent_platform_configured? + end + + private + + def user_has_duo_enterprise_add_on? + ::GitlabSubscriptions::AddOnPurchase.for_active_add_ons([:duo_enterprise], user).exists? + end + + def ga_rollout_enabled? + ::Feature.enabled?(:ai_duo_agent_platform_ga_rollout, container) + end + + def experimental_features_enabled? + if ::Gitlab::Saas.feature_available?(:gitlab_com_subscriptions) + container.root_ancestor.experiment_features_enabled + else + ::Gitlab::CurrentSettings.instance_level_ai_beta_features_enabled? + end + end + + def duo_agent_platform_configured? + feature_setting = selected_feature_setting + + # SaaS customers always have DWS available + # Self-managed instances without a feature_setting record also default to cloud-connected models + # Only self-managed instances with self_hosted? == true need further validation + return true unless feature_setting&.self_hosted? + + # Self-hosted customers need compatible model and DWS configured + return false if feature_setting.self_hosted_model&.unsupported_family_for_duo_agent_platform_code_review? + + ::Gitlab::DuoWorkflow::Client.self_hosted_url.present? + end + + def selected_feature_setting + # rubocop: disable CodeReuse/ServiceClass -- The service below should probably be a model too. + service_result = ::Ai::FeatureSettingSelectionService.new( + user, + :duo_agent_platform, + container.root_ancestor + ).execute + # rubocop: enable CodeReuse/ServiceClass + + service_result.success? ? service_result.payload : nil + end + end + end + end +end diff --git a/ee/app/models/ai/duo_code_review/modes/disabled.rb b/ee/app/models/ai/duo_code_review/modes/disabled.rb new file mode 100644 index 0000000000000000000000000000000000000000..13c480af9840299ccdf76d0c92e71a2b208db3a0 --- /dev/null +++ b/ee/app/models/ai/duo_code_review/modes/disabled.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Ai + module DuoCodeReview + module Modes + class Disabled < Base + def mode + :disabled + end + + def enabled? + false + end + + def active? + true + end + end + end + end +end diff --git a/ee/app/models/ee/group.rb b/ee/app/models/ee/group.rb index ee1ccc80cf84d8017d63f142f3251232775ec578..e1d7d603404e9e2eb2ed7d5ef52ee5113924b8c2 100644 --- a/ee/app/models/ee/group.rb +++ b/ee/app/models/ee/group.rb @@ -318,7 +318,7 @@ def allow_group_items_in_project_autocompletion? end def ai_review_merge_request_allowed?(user) - ::Ai::CodeReviewAuthorization.new(self).allowed?(user) + ::Ai::DuoCodeReview.enabled?(user: user, container: self) end def project_epics_enabled? diff --git a/ee/app/models/ee/merge_request.rb b/ee/app/models/ee/merge_request.rb index 381cfdf452897baa2fca31a2980d5a6573bc3337..9bf8b70147fca5b42ae74aa86d1bf2d3a796415d 100644 --- a/ee/app/models/ee/merge_request.rb +++ b/ee/app/models/ee/merge_request.rb @@ -766,7 +766,7 @@ def requested_changes_for_users(user_ids) end def ai_review_merge_request_allowed?(user) - ::Ai::CodeReviewAuthorization.new(self).allowed?(user) && Ability.allowed?(user, :create_note, self) + ::Ai::DuoCodeReview.enabled?(user: user, container: project) && Ability.allowed?(user, :create_note, self) end def ai_reviewable_diff_files diff --git a/ee/app/models/ee/project.rb b/ee/app/models/ee/project.rb index e4d6dddcf1e1335ad8dad668a9994bcc290b2df9..22256fd1452f2d1a512eb456ef494b5194f2ade5 100644 --- a/ee/app/models/ee/project.rb +++ b/ee/app/models/ee/project.rb @@ -1001,7 +1001,7 @@ def github_external_pull_request_pipelines_available? end def ai_review_merge_request_allowed?(user) - ::Ai::CodeReviewAuthorization.new(self).allowed?(user) + ::Ai::DuoCodeReview.enabled?(user: user, container: self) end override :add_import_job diff --git a/ee/spec/models/ai/code_review_authorization_spec.rb b/ee/spec/models/ai/code_review_authorization_spec.rb deleted file mode 100644 index 7058d93f80c360d20758db82414020d745cfcf68..0000000000000000000000000000000000000000 --- a/ee/spec/models/ai/code_review_authorization_spec.rb +++ /dev/null @@ -1,135 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Ai::CodeReviewAuthorization, feature_category: :duo_agent_platform do - let_it_be(:project) { create(:project) } - let_it_be(:group) { create(:group) } - let_it_be(:merge_request) { create(:merge_request, source_project: project) } - let_it_be(:user) { create(:user, developer_of: project) } - - let(:llm_authorizer) { instance_double(::Gitlab::Llm::FeatureAuthorizer) } - let(:dap_validator) { instance_double(::Ai::DuoWorkflows::CodeReview::AvailabilityValidator) } - - before do - stub_licensed_features(review_merge_request: true) - - allow(::Gitlab::Llm::FeatureAuthorizer).to receive(:new).and_return(llm_authorizer) - allow(llm_authorizer).to receive(:allowed?).and_return(true) - - allow(::Ai::DuoWorkflows::CodeReview::AvailabilityValidator).to receive(:new).and_return(dap_validator) - allow(dap_validator).to receive(:available?).and_return(false) - end - - shared_examples 'classic flow authorization' do - context 'when feature is authorized' do - before do - allow(llm_authorizer).to receive(:allowed?).and_return(true) - end - - it { is_expected.to be(false) } - - context 'when user has permission' do - before do - allow(Ability).to receive(:allowed?).with(user, :access_ai_review_mr, expected_container).and_return(true) - end - - it { is_expected.to be(true) } - end - - context 'when license is not set' do - before do - stub_licensed_features(review_merge_request: false) - end - - it { is_expected.to be(false) } - end - end - - context 'when feature is not authorized' do - before do - allow(llm_authorizer).to receive(:allowed?).and_return(false) - end - - it { is_expected.to be(false) } - end - end - - shared_examples 'DAP flow takes precedence' do - context 'when DAP is available' do - before do - allow(dap_validator).to receive(:available?).and_return(true) - allow(Ability).to receive(:allowed?).and_return(false) - end - - it 'returns true even if classic flow would deny' do - expect(subject).to be(true) - end - - it 'calls AvailabilityValidator with correct resource' do - subject - - expect(::Ai::DuoWorkflows::CodeReview::AvailabilityValidator).to have_received(:new).with( - user: user, - resource: expected_validation_resource - ) - end - end - - context 'when DAP is not available' do - before do - allow(dap_validator).to receive(:available?).and_return(false) - end - - it 'falls back to classic flow' do - allow(Ability).to receive(:allowed?).with(user, :access_ai_review_mr, expected_container).and_return(true) - allow(llm_authorizer).to receive(:allowed?).and_return(true) - - expect(subject).to be(true) - end - end - end - - describe '#allowed?' do - subject(:allowed?) { described_class.new(resource).allowed?(user) } - - context 'with Project resource' do - let(:resource) { project } - let(:expected_container) { project } - let(:expected_validation_resource) { project } - - it_behaves_like 'classic flow authorization' - it_behaves_like 'DAP flow takes precedence' - end - - context 'with Group resource' do - let(:resource) { group } - let(:expected_container) { group } - let(:expected_validation_resource) { group } - - it_behaves_like 'classic flow authorization' - it_behaves_like 'DAP flow takes precedence' - end - - context 'with MergeRequest resource' do - let(:resource) { merge_request } - let(:expected_container) { project } - let(:expected_validation_resource) { project } - - it_behaves_like 'classic flow authorization' - it_behaves_like 'DAP flow takes precedence' - - it 'uses the merge request project for authorization' do - allow(Ability).to receive(:allowed?).with(user, :access_ai_review_mr, project).and_return(true) - allow(llm_authorizer).to receive(:allowed?).and_return(true) - - expect(allowed?).to be(true) - expect(::Gitlab::Llm::FeatureAuthorizer).to have_received(:new).with( - container: project, - feature_name: :review_merge_request, - user: user - ) - end - end - end -end diff --git a/ee/spec/models/ai/duo_code_review/mode_resolver_spec.rb b/ee/spec/models/ai/duo_code_review/mode_resolver_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..ec02467dd8517ab41c38fbe38b094ce220f6c899 --- /dev/null +++ b/ee/spec/models/ai/duo_code_review/mode_resolver_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ai::DuoCodeReview::ModeResolver, feature_category: :code_suggestions do + let_it_be(:user) { build_stubbed(:user) } + let_it_be(:container) { build_stubbed(:group) } + + subject(:active_mode) { described_class.new(user: user, container: container) } + + shared_context 'with DAP mode active' do + before do + allow_next_instance_of(Ai::DuoCodeReview::Modes::Dap) do |instance| + allow(instance).to receive(:active?).and_return(true) + end + end + end + + shared_context 'with Classic mode active' do + before do + allow_next_instance_of(Ai::DuoCodeReview::Modes::Classic) do |instance| + allow(instance).to receive(:active?).and_return(true) + end + end + end + + shared_context 'when disabled' do + before do + allow_next_instance_of(Ai::DuoCodeReview::Modes::Dap) do |instance| + allow(instance).to receive(:active?).and_return(false) + end + + allow_next_instance_of(Ai::DuoCodeReview::Modes::Classic) do |instance| + allow(instance).to receive(:active?).and_return(false) + end + end + end + + describe '#mode' do + context 'when DAP mode is active' do + include_context 'with DAP mode active' + + it 'returns :dap' do + expect(active_mode.mode).to eq(:dap) + end + end + + context 'when Classic mode is active' do + include_context 'with Classic mode active' + + it 'returns :classic' do + expect(active_mode.mode).to eq(:classic) + end + end + + context 'when both DAP and Classic mode are not active' do + include_context 'when disabled' + + it 'returns :disabled' do + expect(active_mode.mode).to eq(:disabled) + end + end + end + + describe '#enabled?' do + context 'when DAP mode is active' do + include_context 'with DAP mode active' + + it 'is enabled' do + expect(active_mode).to be_enabled + end + end + + context 'when Classic mode is active' do + include_context 'with Classic mode active' + + it 'is enabled' do + expect(active_mode).to be_enabled + end + end + + context 'when both DAP and Classic mode are not active' do + include_context 'when disabled' + + it 'is disabled' do + expect(active_mode).not_to be_enabled + end + end + end +end diff --git a/ee/spec/models/ai/duo_code_review/modes/classic_spec.rb b/ee/spec/models/ai/duo_code_review/modes/classic_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..e6eb5b605f15e4c587685a5f54e19191aa1e8688 --- /dev/null +++ b/ee/spec/models/ai/duo_code_review/modes/classic_spec.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ai::DuoCodeReview::Modes::Classic, feature_category: :code_suggestions do + subject(:mode) { described_class.new(user: user, container: container) } + + let_it_be(:project) { create(:project) } + let_it_be(:group) { create(:group) } + let_it_be(:merge_request) { create(:merge_request, source_project: project) } + let_it_be(:user) { create(:user, developer_of: project) } + let_it_be(:container) { project } + + describe '#mode' do + it 'returns the mode name' do + expect(mode.mode).to eq(:classic) + end + end + + describe '#enabled?' do + it 'always returns true' do + expect(mode).to be_enabled + end + end + + describe '#active?' do + let(:feature_authorizer) { instance_double(::Gitlab::Llm::FeatureAuthorizer) } + let(:user_has_access_ai_review_mr_ability) { true } + let(:feature_authorizer_allowed) { true } + + before do + allow(Ability).to receive(:allowed?) + .with(user, :access_ai_review_mr, container) + .and_return(user_has_access_ai_review_mr_ability) + + allow(::Gitlab::Llm::FeatureAuthorizer).to receive(:new) + .with( + container: container, + feature_name: :review_merge_request, + user: user + ) + .and_return(feature_authorizer) + + allow(feature_authorizer).to receive(:allowed?) + .and_return(feature_authorizer_allowed) + end + + shared_examples 'not active' do + it 'returns false' do + expect(mode).not_to be_active + end + end + + shared_examples 'active' do + it 'returns true' do + expect(mode).to be_active + end + end + + context 'when user has access_ai_review_mr ability and feature authorizer allows' do + include_examples 'active' + end + + context 'when user does not have access_ai_review_mr ability' do + let(:user_has_access_ai_review_mr_ability) { false } + + include_examples 'not active' + end + + context 'when feature authorizer does not allow' do + let(:feature_authorizer_allowed) { false } + + include_examples 'not active' + end + + context 'when both conditions fail' do + let(:user_has_access_ai_review_mr_ability) { false } + let(:feature_authorizer_allowed) { false } + + include_examples 'not active' + end + + context 'when user has ability but feature authorizer does not allow' do + let(:user_has_access_ai_review_mr_ability) { true } + let(:feature_authorizer_allowed) { false } + + include_examples 'not active' + end + + context 'when user does not have ability but feature authorizer allows' do + let(:user_has_access_ai_review_mr_ability) { false } + let(:feature_authorizer_allowed) { true } + + include_examples 'not active' + end + end +end diff --git a/ee/spec/models/ai/duo_code_review/modes/dap_spec.rb b/ee/spec/models/ai/duo_code_review/modes/dap_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..1ee71f1284f9b4b4a6006d5543b5cb8ebf3c43df --- /dev/null +++ b/ee/spec/models/ai/duo_code_review/modes/dap_spec.rb @@ -0,0 +1,286 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ai::DuoCodeReview::Modes::Dap, feature_category: :code_suggestions do + subject(:mode) { described_class.new(user: user, container: container) } + + let_it_be(:project) { create(:project) } + let_it_be(:group) { create(:group) } + let_it_be(:merge_request) { create(:merge_request, source_project: project) } + let_it_be(:user) { create(:user, developer_of: project) } + let_it_be(:container) { project } + + describe '#mode' do + it 'returns the mode name' do + expect(mode.mode).to eq(:dap) + end + end + + describe '#enabled?' do + it 'always returns true' do + expect(mode).to be_enabled + end + end + + describe '#active?' do + # Feature flags + let(:duo_code_review_on_agent_platform) { true } + let(:ai_duo_agent_platform_ga_rollout) { true } + let(:duo_code_review_dap_internal_users) { true } + + # Duo features + let(:duo_features_enabled) { true } + let(:experiment_features_enabled) { true } + let(:instance_level_ai_beta_features_enabled) { true } + + # User permissions + let(:user_allowed_to_use_duo_agent_platform) { true } + let(:user_has_duo_enterprise_add_on) { false } + + let(:add_on_purchase) do + class_double(::GitlabSubscriptions::AddOnPurchase, exists?: user_has_duo_enterprise_add_on) + end + + before do + allow(::Gitlab::Saas).to receive(:feature_available?).with(:gitlab_com_subscriptions).and_return(true) + + stub_feature_flags( + duo_code_review_on_agent_platform: duo_code_review_on_agent_platform, + ai_duo_agent_platform_ga_rollout: ai_duo_agent_platform_ga_rollout, + duo_code_review_dap_internal_users: duo_code_review_dap_internal_users + ) + + allow(container).to receive_messages( + duo_features_enabled: duo_features_enabled, + experiment_features_enabled: experiment_features_enabled, + root_ancestor: container + ) + + allow(user).to receive(:allowed_to_use?) + .with(:duo_agent_platform) + .and_return(user_allowed_to_use_duo_agent_platform) + + allow_next_instance_of(::Ai::FeatureSettingSelectionService) do |service| + allow(service).to receive(:execute).and_return( + ServiceResponse.success(payload: nil) + ) + end + + allow(::GitlabSubscriptions::AddOnPurchase) + .to receive(:for_active_add_ons) + .with([:duo_enterprise], user) + .and_return(add_on_purchase) + + allow(::Gitlab::CurrentSettings) + .to receive(:instance_level_ai_beta_features_enabled?) + .and_return(instance_level_ai_beta_features_enabled) + end + + shared_examples 'not active' do + it 'returns false' do + expect(mode).not_to be_active + end + end + + shared_examples 'active' do + it 'returns true' do + expect(mode).to be_active + end + end + + context 'when duo_code_review_on_agent_platform feature flag is disabled' do + let(:duo_code_review_on_agent_platform) { false } + + include_examples 'not active' + end + + context 'when Duo features are disabled' do + let(:duo_features_enabled) { false } + + include_examples 'not active' + end + + context 'when user has Duo Enterprise add-on' do + let(:user_has_duo_enterprise_add_on) { true } + + context 'and duo_code_review_dap_internal_users feature flag is disabled' do + let(:duo_code_review_dap_internal_users) { false } + + include_examples 'not active' + end + + context 'and duo_code_review_dap_internal_users feature flag is enabled' do + include_examples 'active' + end + end + + context 'when user is not allowed to use duo_agent_platform' do + let(:user_allowed_to_use_duo_agent_platform) { false } + + include_examples 'not active' + end + + context 'when ai_duo_agent_platform_ga_rollout is disabled' do + let(:ai_duo_agent_platform_ga_rollout) { false } + let(:experiment_features_enabled) { false } + + context 'and instance is SASS' do + context 'and experimental features are disabled' do + let(:experiment_features_enabled) { false } + + include_examples 'not active' + end + end + + context 'and instance is self-managed' do + context 'and experimental features are disabled' do + let(:instance_level_ai_beta_features_enabled) { false } + + include_examples 'not active' + end + end + end + + context 'when user does not have Duo Enterprise add-on' do + let(:user_has_duo_enterprise_add_on) { false } + + context 'and GA rollout is enabled' do + let(:ai_duo_agent_platform_ga_rollout) { true } + + include_examples 'active' + end + + context 'and GA rollout is disabled but experimental features are enabled' do + let(:ai_duo_agent_platform_ga_rollout) { false } + let(:experiment_features_enabled) { true } + + include_examples 'active' + end + + context 'and GA rollout is disabled and experimental features are disabled' do + let(:ai_duo_agent_platform_ga_rollout) { false } + let(:experiment_features_enabled) { false } + + include_examples 'not active' + end + end + + context 'when instance_level_ai_beta_features_enabled is true' do + let(:instance_level_ai_beta_features_enabled) { true } + let(:ai_duo_agent_platform_ga_rollout) { false } + + include_examples 'active' + end + + context 'when instance_level_ai_beta_features_enabled is false' do + let(:instance_level_ai_beta_features_enabled) { false } + let(:ai_duo_agent_platform_ga_rollout) { false } + let(:experiment_features_enabled) { false } + + include_examples 'not active' + end + + context 'when duo_agent_platform is not configured' do + before do + allow_next_instance_of(::Ai::FeatureSettingSelectionService) do |service| + allow(service).to receive(:execute).and_return( + ServiceResponse.success(payload: nil) + ) + end + end + + context 'and SaaS feature is available' do + include_examples 'active' + end + + context 'and SaaS feature is not available' do + before do + allow(::Gitlab::Saas).to receive(:feature_available?).with(:gitlab_com_subscriptions).and_return(false) + end + + include_examples 'active' + end + end + + context 'when self-hosted model is unsupported for duo agent platform code review' do + let(:self_hosted_model) { instance_double(::Ai::SelfHostedModel) } + let(:feature_setting) do + instance_double( + ::Ai::FeatureSetting, + self_hosted?: true, + self_hosted_model: self_hosted_model + ) + end + + before do + allow(self_hosted_model).to receive(:unsupported_family_for_duo_agent_platform_code_review?).and_return(true) + allow_next_instance_of(::Ai::FeatureSettingSelectionService) do |service| + allow(service).to receive(:execute).and_return( + ServiceResponse.success(payload: feature_setting) + ) + end + end + + include_examples 'not active' + end + + context 'when self-hosted model is supported but DWS URL is not configured' do + let(:self_hosted_model) { instance_double(::Ai::SelfHostedModel) } + let(:feature_setting) do + instance_double( + ::Ai::FeatureSetting, + self_hosted?: true, + self_hosted_model: self_hosted_model + ) + end + + before do + allow(self_hosted_model).to receive(:unsupported_family_for_duo_agent_platform_code_review?).and_return(false) + allow_next_instance_of(::Ai::FeatureSettingSelectionService) do |service| + allow(service).to receive(:execute).and_return( + ServiceResponse.success(payload: feature_setting) + ) + end + allow(::Gitlab::DuoWorkflow::Client).to receive(:self_hosted_url).and_return(nil) + end + + include_examples 'not active' + end + + context 'when self-hosted model is supported and DWS URL is configured' do + let(:self_hosted_model) { instance_double(::Ai::SelfHostedModel) } + let(:feature_setting) do + instance_double( + ::Ai::FeatureSetting, + self_hosted?: true, + self_hosted_model: self_hosted_model + ) + end + + before do + allow(self_hosted_model).to receive(:unsupported_family_for_duo_agent_platform_code_review?).and_return(false) + allow_next_instance_of(::Ai::FeatureSettingSelectionService) do |service| + allow(service).to receive(:execute).and_return( + ServiceResponse.success(payload: feature_setting) + ) + end + allow(::Gitlab::DuoWorkflow::Client).to receive(:self_hosted_url).and_return('https://dws.example.com') + end + + include_examples 'active' + end + + context 'when feature setting service returns failure' do + before do + allow_next_instance_of(::Ai::FeatureSettingSelectionService) do |service| + allow(service).to receive(:execute).and_return( + ServiceResponse.error(message: 'Service error') + ) + end + end + + include_examples 'active' + end + end +end diff --git a/ee/spec/models/ai/duo_code_review/modes/disabled_spec.rb b/ee/spec/models/ai/duo_code_review/modes/disabled_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..ba720030141e31b12c602f1d87265b89db50262b --- /dev/null +++ b/ee/spec/models/ai/duo_code_review/modes/disabled_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ai::DuoCodeReview::Modes::Disabled, feature_category: :code_suggestions do + subject(:mode) { described_class.new(user: user, container: container) } + + let_it_be(:project) { create(:project) } + let_it_be(:group) { create(:group) } + let_it_be(:merge_request) { create(:merge_request, source_project: project) } + let_it_be(:user) { create(:user, developer_of: project) } + let_it_be(:container) { project } + + describe '#mode' do + it 'returns the mode name' do + expect(mode.mode).to eq(:disabled) + end + end + + describe '#enabled?' do + it 'always returns false' do + expect(mode).not_to be_enabled + end + end + + describe '#active?' do + it 'always returns true' do + expect(mode).to be_active + end + end +end diff --git a/ee/spec/models/ai/duo_code_review_spec.rb b/ee/spec/models/ai/duo_code_review_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..b4476eeae48dc81ae39d33f94489e088303f386e --- /dev/null +++ b/ee/spec/models/ai/duo_code_review_spec.rb @@ -0,0 +1,121 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ai::DuoCodeReview, feature_category: :code_suggestions do + let_it_be(:project) { create(:project) } + let_it_be(:group) { create(:group) } + let_it_be(:merge_request) { create(:merge_request, source_project: project) } + let_it_be(:user) { create(:user, developer_of: project) } + + shared_context 'with DAP Duo Code Review enabled' do + before do + stub_feature_flags(duo_code_review_on_agent_platform: true) + stub_feature_flags(ai_duo_agent_platform_ga_rollout: true) + + allow(project).to receive(:duo_features_enabled).and_return(true) + allow(group).to receive(:duo_features_enabled).and_return(true) + + allow(user).to receive(:allowed_to_use?) + .with(:duo_agent_platform) + .and_return(true) + allow(user).to receive(:allowed_to_use?) + .with(:review_merge_request, licensed_feature: :review_merge_request) + .and_return(true) + + allow_next_instance_of(::Ai::FeatureSettingSelectionService) do |service| + allow(service).to receive(:execute).and_return( + ServiceResponse.success(payload: nil) + ) + end + end + end + + shared_context 'with Classic Duo Code Review enabled' do + before do + stub_feature_flags(duo_code_review_on_agent_platform: false) + + allow(user).to receive(:allowed_to_use?) + .with(:duo_agent_platform) + .and_return(false) + allow(user).to receive(:allowed_to_use?) + .with(:review_merge_request, licensed_feature: :review_merge_request) + .and_return(true) + + allow_next_instance_of(::Gitlab::Llm::FeatureAuthorizer) do |instance| + allow(instance).to receive(:allowed?).and_return(true) + end + allow_next_instance_of(Ability) do |instance| + allow(instance).to receive(:allowed?).and_return(true) + end + end + end + + shared_context 'with DAP and Classic Duo Code Review disabled' do + before do + stub_feature_flags(duo_code_review_on_agent_platform: false) + + allow(user).to receive(:allowed_to_use?) + .with(:duo_agent_platform) + .and_return(false) + allow(user).to receive(:allowed_to_use?) + .with(:review_merge_request, licensed_feature: :review_merge_request) + .and_return(false) + + allow_next_instance_of(::Gitlab::Llm::FeatureAuthorizer) do |instance| + allow(instance).to receive(:allowed?).and_return(false) + end + allow_next_instance_of(Ability) do |instance| + allow(instance).to receive(:allowed?).and_return(false) + end + end + end + + describe '.enabled?' do + subject(:enabled) { described_class.enabled?(user: user, container: container) } + + before do + stub_licensed_features(review_merge_request: true) + end + + shared_examples 'enabled when DAP Duo Code Review is enabled' do + context 'when DAP Duo Code Review is enabled' do + include_context 'with DAP Duo Code Review enabled' + + it { is_expected.to be(true) } + end + end + + shared_examples 'enabled when Classic Duo Code Review is enabled' do + context 'when Classic Duo Code Review is enabled' do + include_context 'with Classic Duo Code Review enabled' + + it { is_expected.to be(true) } + end + end + + shared_examples 'disabled when DAP and Classic Duo Code Review are disabled' do + context 'when both DAP and Classic Duo Code Review are disabled' do + include_context 'with DAP and Classic Duo Code Review disabled' + + it { is_expected.to be(false) } + end + end + + context 'with a Group container' do + let(:container) { group } + + include_examples 'enabled when DAP Duo Code Review is enabled' + include_examples 'enabled when Classic Duo Code Review is enabled' + include_examples 'disabled when DAP and Classic Duo Code Review are disabled' + end + + context 'with a Project container' do + let(:container) { project } + + include_examples 'enabled when DAP Duo Code Review is enabled' + include_examples 'enabled when Classic Duo Code Review is enabled' + include_examples 'disabled when DAP and Classic Duo Code Review are disabled' + end + end +end diff --git a/ee/spec/models/ee/group_spec.rb b/ee/spec/models/ee/group_spec.rb index e03276bf67a4a76504a1d43fa5cbebe562f3b1e9..af13c6389a05e0589f26d65560765009db441287 100644 --- a/ee/spec/models/ee/group_spec.rb +++ b/ee/spec/models/ee/group_spec.rb @@ -4326,8 +4326,8 @@ def webhook_headers let_it_be(:group) { create(:group) } let_it_be(:current_user) { create(:user) } - it 'delegates to Ai::CodeReviewAuthorization' do - expect(Ai::CodeReviewAuthorization).to receive(:new).with(group).and_call_original + it 'delegates to Ai::DuoCodeReview' do + expect(Ai::DuoCodeReview).to receive(:enabled?).with(user: current_user, container: group).and_call_original group.ai_review_merge_request_allowed?(current_user) end diff --git a/ee/spec/models/ee/project_spec.rb b/ee/spec/models/ee/project_spec.rb index 21531496b18d7af1028b6ae020db67ff6c75eb0f..340461b490d20271474e0feb3a71216c447799a7 100644 --- a/ee/spec/models/ee/project_spec.rb +++ b/ee/spec/models/ee/project_spec.rb @@ -5267,8 +5267,8 @@ def stub_default_url_options(host) let_it_be(:project) { create(:project) } let_it_be(:current_user) { create(:user) } - it 'delegates to Ai::CodeReviewAuthorization' do - expect(Ai::CodeReviewAuthorization).to receive(:new).with(project).and_call_original + it 'delegates to Ai::DuoCodeReview' do + expect(Ai::DuoCodeReview).to receive(:enabled?).with(user: current_user, container: project).and_call_original project.ai_review_merge_request_allowed?(current_user) end diff --git a/ee/spec/models/merge_request_spec.rb b/ee/spec/models/merge_request_spec.rb index 7372bbdedd50aff79d50d959a56cfa16b172094f..b0037156cf8a4dc60f442d2a1080e35743959456 100644 --- a/ee/spec/models/merge_request_spec.rb +++ b/ee/spec/models/merge_request_spec.rb @@ -3545,11 +3545,8 @@ def stub_foss_conditions_met subject(:ai_review_merge_request_allowed?) { merge_request.ai_review_merge_request_allowed?(current_user) } - it 'delegates to Ai::CodeReviewAuthorization and checks create_note ability' do - authorization = instance_double(Ai::CodeReviewAuthorization, allowed?: true) - - expect(Ai::CodeReviewAuthorization).to receive(:new).with(merge_request).and_return(authorization) - expect(authorization).to receive(:allowed?).with(current_user).and_return(true) + it 'delegates to Ai::DuoCodeReview and checks create_note ability' do + expect(Ai::DuoCodeReview).to receive(:enabled?).with(user: current_user, container: project).and_return(true) expect(Ability).to receive(:allowed?).with(current_user, :create_note, merge_request).and_return(true) expect(ai_review_merge_request_allowed?).to be(true) @@ -3558,9 +3555,8 @@ def stub_foss_conditions_met context 'when user cannot create note' do let(:current_user) { create(:user, guest_of: project) } - it 'returns false even if CodeReviewAuthorization allows' do - authorization = instance_double(Ai::CodeReviewAuthorization, allowed?: true) - allow(Ai::CodeReviewAuthorization).to receive(:new).and_return(authorization) + it 'returns false even if Duo Code Review is enabled' do + allow(Ai::DuoCodeReview).to receive(:enabled?).and_return(true) expect(ai_review_merge_request_allowed?).to be(false) end diff --git a/ee/spec/requests/projects/merge_requests/creations_spec.rb b/ee/spec/requests/projects/merge_requests/creations_spec.rb index 3a0c52900687164d18355713b8a1e4837824ba5c..12b4ef59484ed60232b2f2f2b5d72f2ae1368f33 100644 --- a/ee/spec/requests/projects/merge_requests/creations_spec.rb +++ b/ee/spec/requests/projects/merge_requests/creations_spec.rb @@ -40,9 +40,7 @@ let(:duo_enterprise_add_on) { create(:gitlab_subscription_add_on, :duo_enterprise) } before do - authorization = instance_double(::Ai::CodeReviewAuthorization) - allow(authorization).to receive(:allowed?).with(user).and_return(has_duo_access) - allow(::Ai::CodeReviewAuthorization).to receive(:new).and_return(authorization) + allow(Ai::DuoCodeReview).to receive(:enabled?).with(user: user, container: project).and_return(has_duo_access) project.project_setting.update_attribute(:auto_duo_code_review_enabled, true) project.project_setting.update!(duo_features_enabled: true) diff --git a/ee/spec/support/shared_examples/requests/merge_request_duo_seat_handling_shared_examples.rb b/ee/spec/support/shared_examples/requests/merge_request_duo_seat_handling_shared_examples.rb index cdc51f2321e707691b4ccfd87e4f082eb67f4313..d6e66ec3521cdc4cd17731000d458c1d1b0a6997 100644 --- a/ee/spec/support/shared_examples/requests/merge_request_duo_seat_handling_shared_examples.rb +++ b/ee/spec/support/shared_examples/requests/merge_request_duo_seat_handling_shared_examples.rb @@ -4,9 +4,9 @@ let(:duo_bot) { ::Users::Internal.duo_code_review_bot } before do - authorization = instance_double(::Ai::CodeReviewAuthorization) - allow(authorization).to receive(:allowed?).with(user).and_return(has_duo_access) - allow(::Ai::CodeReviewAuthorization).to receive(:new).and_return(authorization) + allow(Ai::DuoCodeReview).to receive(:enabled?) + .with(user: user, container: project) + .and_return(has_duo_access) end context 'when using /assign_reviewer with Duo bot' do